From b60cb67009733b48db2f19f4728b4505a8b7d160 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Jul 2026 16:08:03 +0200 Subject: [PATCH 01/98] fix(streaming): block login on complete world reveal Make initial login and portal arrival consume one WorldRevealReadinessBarrier that joins near-tier mesh publication, destination composite uploads, and collision residency before normal world geometry becomes visible. This ports retail SmartBox's blocking-cell completion edge into the asynchronous client instead of exposing a ground-only login. Add focused outdoor, indoor, texture, and invalid-claim tests; update the retail pseudocode, architecture, divergence record, issue ledger, roadmap baseline, and synchronized agent guidance. Co-authored-by: OpenAI Codex --- AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/ISSUES.md | 24 +++- docs/README.md | 2 +- docs/architecture/acdream-architecture.md | 14 ++ .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 4 +- docs/plans/2026-05-12-milestones.md | 2 +- ...2026-07-16-portal-completion-pseudocode.md | 23 +++- src/AcDream.App/Input/PlayerModeAutoEntry.cs | 22 ++-- src/AcDream.App/Rendering/GameWindow.cs | 120 ++++++++--------- .../Streaming/WorldRevealReadinessBarrier.cs | 96 ++++++++++++++ .../WorldRevealReadinessBarrierTests.cs | 124 ++++++++++++++++++ .../Input/AutoEnterPlayerModeTests.cs | 25 ++-- 14 files changed, 360 insertions(+), 102 deletions(-) create mode 100644 src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs create mode 100644 tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs diff --git a/AGENTS.md b/AGENTS.md index 51b38ef1..80d040ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ book, effect indicators, shared main-panel switching, and positive/negative effe foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete; three independent conformance/architecture/adversarial re-reviews are clean, the Release build succeeds (the clean documentation audit exposes 17 pre-existing test-project warnings, #228), and the full suite -is 6,452 passed / 5 skipped. Its unattended seven-destination +is 6,457 passed / 5 skipped. Its unattended seven-destination connected input/portal/performance rebaseline also passes without an interactive desktop. Remaining ownership cleanup and registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6 locomotion/collision/projectile/teleport visual gate, then the final two-client diff --git a/CLAUDE.md b/CLAUDE.md index 4f02729d..9e6adcd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ book, effect indicators, shared main-panel switching, and positive/negative effe foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete; three independent conformance/architecture/adversarial re-reviews are clean, the Release build succeeds (the clean documentation audit exposes 17 pre-existing test-project warnings, #228), and the full suite -is 6,452 passed / 5 skipped. Its unattended seven-destination +is 6,457 passed / 5 skipped. Its unattended seven-destination connected input/portal/performance rebaseline also passes without an interactive desktop. Remaining ownership cleanup and registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6 locomotion/collision/projectile/teleport visual gate, then the final two-client diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 94752ad6..a87fdd92 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -98,7 +98,7 @@ production runtime failure. **Acceptance:** A non-incremental Release solution build reports zero warnings and zero errors without suppressing analyzers or weakening nullable checking; -the full 6,452-pass / 5-skip suite remains green. +the full 6,457-pass / 5-skip suite remains green. --- @@ -7431,6 +7431,28 @@ outdoors at the angle that previously erased it. # Recently closed +## #229 — [DONE 2026-07-20] Initial login revealed an incomplete world + +**Closed:** 2026-07-20 +**Resolution:** Login auto-entry waited only for the terrain height or EnvCell +floor needed by local physics. It then removed the sky-only render gate while +the destination's static meshes, nearby scenery, and composite textures could +still be uploading. Portal travel appeared to repair the world because it +already waited on all of those domains before revealing its destination. + +Login and portal arrival now share `WorldRevealReadinessBarrier`, the async +equivalent of retail `SmartBox::UseTime` keeping position completion behind +`CellManager::blocking_for_cells`. Both paths invalidate destination texture +readiness, prepare composites after mesh publication, and require the same +render-plus-collision predicate. Focused login/barrier tests and the complete +Release suite pass; capped connected visual verification remains the final +gate. + +**Research:** +`docs/research/2026-07-16-portal-completion-pseudocode.md` §2. + +--- + ## #215 — [DONE 2026-07-13] Same-dungeon death respawn removed the dungeon floor physics **Closed:** 2026-07-13 diff --git a/docs/README.md b/docs/README.md index c6a643d5..2ae73f6b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ banner cannot silently override the current program state. - **Divergence audit:** 178 active rows — IA 17, AD 37, AP 85, TS 34, and UN 5 — plus the retained retired TS-37 history note. - **Latest automated baseline:** Release build succeeds with 17 known - test-project warnings (`#228`); 6,452 tests passed and 5 intentionally + test-project warnings (`#228`); 6,457 tests passed and 5 intentionally skipped. The unattended connected R6 route completed seven portal materializations, production input exercises, and a graceful close. See diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 58785815..4476e988 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -239,6 +239,7 @@ src/ Streaming/ StreamingController.cs -> done GpuWorldState.cs -> done + WorldRevealReadinessBarrier.cs -> shared login/portal reveal owner Input/ PlayerMovementController.cs -> active movement driver Plugins/ @@ -705,6 +706,19 @@ replace state produced by another job. CPU mesh extraction may still be schedule onto `ObjectMeshManager`'s thread-safe work queue, but the worker never publishes partially hydrated cell membership or shell placement to the renderer. +### World-reveal readiness ownership + +`WorldRevealReadinessBarrier` is the single App-layer owner of the edge that +allows normal world geometry to become visible at initial login or after a +portal. It joins `StreamingController`/`GpuWorldState` static-mesh publication, +`WbDrawDispatcher` composite-texture warmup, and `PhysicsEngine` destination +residency. Login's sky-only gate and portal-space transit consume the same +predicate; they differ only in presentation. Composite preparation advances +after `WbMeshAdapter.Tick` on the render thread, never from the streaming +worker. This is acdream's asynchronous equivalent of retail +`SmartBox::UseTime` holding position completion while +`CellManager::blocking_for_cells` is set. + ### Teleport cell identity at the streaming boundary The network and physics layers carry retail's complete `Position`: an diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 7ff19324..231a8856 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -67,7 +67,7 @@ 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. Login placement keeps #135's split: a hydratable indoor claim gates on its EnvCell floor (`IsSpawnCellReady`) rather than meaningless dungeon terrain; outdoor placement gates on terrain. **#218 refinement (2026-07-16):** F751 portal-space exit joins BOTH publication domains in `TeleportWorldReady`: indoor requires the center landblock's Near-tier static/EnvCell mesh set uploaded plus the EnvCell in physics, while outdoor requires that Near-tier render readiness plus terrain/collision residency for the priority 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 forced-placement path. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Rendering/GameWindow.cs` (`TeleportWorldReady` + TAS transit tick); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `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 completing its blocking cell load before `EndTeleportAnimation`: neither an empty collision world, a terrain-only Far shell, nor a published-but-not-drawable GPU landblock may be revealed. Indoor does not require a terrain heightmap, only the owning render landblock and the exact EnvCell. | Gate opens early → grey/untextured reveal, free-fall, wrong-cell rooting, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → portal transit reaches its existing loud timeout/forced-placement diagnostic instead of silently hanging forever. | retail synchronous cell load before SetPosition / `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | +| 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 priority 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 forced-placement path. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (login auto-entry + TAS transit tick); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `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 an empty 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, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `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) | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 9a95b345..e2cdd444 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -920,7 +920,7 @@ diagnostic scaffolding, not yet the final collision system. PositionManager facade remains the final R6 ownership cleanup. Final 2026-07-20 gate: three independent retail/architecture/adversarial re-reviews are clean; Release builds successfully with 17 known test-project - warnings tracked by #228; the repository suite is 6,452 passed / 5 skipped. + warnings tracked by #228; the repository suite is 6,457 passed / 5 skipped. The unattended connected Release rebaseline also passes: seven portal materializations across Caul, Sawato, Rynthid, Aerlinthe, Holtburg, and same-location revisits; production-dispatcher turn, @@ -1446,7 +1446,7 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen. ## Cross-cutting work tracked in parallel -- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,452 passed / 5 intentional skips (2026-07-20). +- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,457 passed / 5 intentional skips (2026-07-20). - **Memory files.** Live subsystem state and digests are indexed by `claude-memory/MEMORY.md`; stable engineering references remain under `memory/`. Canonical program status stays in milestones/roadmap/issues. - **`CLAUDE.md` discipline.** Check all four references (ACE, ACViewer, WorldBuilder, Chorizite) before committing to an approach. WorldBuilder is the closest stack match and should be checked first. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 39b0e3cf..2ae4b3fa 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -19,7 +19,7 @@ Carried: registered TS-50/TS-51 timing residuals (the complete-root-Frame/object-workset cutover is automated-test complete 2026-07-20; three independent re-reviews are clean, Release builds with 17 known test-project warnings tracked by #228, and -6,452 tests pass / 5 skip), and +6,457 tests pass / 5 skip), and the deferred Modern Pipeline track (MP1b+). The separate #225 shared-alpha implementation has passed connected lifetime/performance gates and awaits only its lifestone/particle visual comparison. diff --git a/docs/research/2026-07-16-portal-completion-pseudocode.md b/docs/research/2026-07-16-portal-completion-pseudocode.md index cbf0b501..bf1ae775 100644 --- a/docs/research/2026-07-16-portal-completion-pseudocode.md +++ b/docs/research/2026-07-16-portal-completion-pseudocode.md @@ -64,7 +64,7 @@ sequence without advancing time and republish all part transforms. Forcing a Ready motion, recognizing recall by id, or freezing the previously published part pose would all diverge from this general retail mechanism. -## 2. Portal exit waits for render publication as well as collision data +## 2. Login and portal exit wait for render publication as well as collision data Named retail references: @@ -82,6 +82,15 @@ that teleport is no longer in progress, `EndTeleportAnimation` begins `TAS_TUNNEL_CONTINUE`; the normal world is not revealed before the blocking load edge has completed. +The same `SmartBox::UseTime` edge governs initial position completion. It does +not distinguish “enough collision to stand” from “enough render state to show +the world”: while `CellManager::blocking_for_cells` is set it only checks +prefetch status, and only after the block clears does it change the player's +position, set `position_update_complete`, advance object/physics/landscape +time, and draw the normal scene. Therefore acdream's login auto-entry cannot +gate on one sampled terrain height while portal arrival uses a complete render +barrier. Both presentations must consume one shared asynchronous equivalent. + acdream's load is split into two independent readiness domains: ```text @@ -98,18 +107,28 @@ worker/register path Required asynchronous equivalent: ```text -TeleportWorldReady(destination): +WorldRevealReady(destination): if destination claim cannot be hydrated: return true so the existing loud forced-placement path diagnoses it if destination is indoor: return render center landblock Near-tier and ready + AND destination composite textures ready AND destination EnvCell collision data ready return every landblock in the priority near ring Near-tier and render-ready + AND destination composite textures ready AND every landblock in that ring collision-resident ``` +`WorldRevealReadinessBarrier.Begin` invalidates destination-scoped composite +readiness for both the first accepted player position and each accepted portal +destination. After the render-thread mesh tick, `Prepare` advances composite +uploads only when the required static meshes are published. Login remains +behind its sky-only render gate and portal travel remains in its tunnel until +the same `IsReady` predicate opens. This prevents the two paths from drifting +apart again while preserving their different presentations. + Streaming completion is generation- and tier-aware. Every hard recenter, dungeon collapse, and dungeon expansion advances a token carried by Load, Promote, Unload, and their results. This rejects an old build even when its id diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs index 1c702c3f..2895bee2 100644 --- a/src/AcDream.App/Input/PlayerModeAutoEntry.cs +++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs @@ -11,8 +11,8 @@ namespace AcDream.App.Input; /// Why is this its own class? The auto-entry has four independent /// preconditions (live session reaches InWorld, the player /// entity has been streamed into the world dictionary, the player -/// movement controller is constructible, and the terrain under the -/// spawn position has streamed in) plus a manual-override path +/// movement controller is constructible, and the initial world is fully +/// drawable and collidable) plus a manual-override path /// (the user can flip into fly mode before the auto-entry fires — /// their choice wins). All five interact with each other in a way /// that's painful to test through GameWindow but trivial here against @@ -44,7 +44,7 @@ public sealed class PlayerModeAutoEntry private readonly Func _isLiveInWorld; private readonly Func _isPlayerEntityPresent; private readonly Func _isPlayerControllerReady; - private readonly Func _isSpawnGroundReady; + private readonly Func _isWorldReady; private readonly Action _enterPlayerMode; private bool _armed; @@ -62,12 +62,10 @@ public sealed class PlayerModeAutoEntry /// PlayerMovementController is set up. Stays true once player mode /// is established; the auto-entry's job is to flip it from false /// to true exactly once. - /// True iff the terrain under the - /// player's spawn position has streamed into the physics engine. - /// #106 gate-2 (2026-06-09): entering player mode earlier integrates - /// gravity against an empty world and free-falls the player into the - /// void. Retail never has this state — it loads cells synchronously; - /// this hold is the async-streaming equivalent of that invariant. + /// True iff the player's destination is + /// complete across render publication, composite textures, and collision. + /// Retail keeps position completion behind one blocking cell-load edge; + /// acdream's asynchronous domains must converge before entry. /// Action invoked on the firing /// tick. The same routine the manual Tab handler invokes (fly → /// player transition). Must construct the controller + chase @@ -77,13 +75,13 @@ public sealed class PlayerModeAutoEntry Func isLiveInWorld, Func isPlayerEntityPresent, Func isPlayerControllerReady, - Func isSpawnGroundReady, + Func isWorldReady, Action enterPlayerMode) { _isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld)); _isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent)); _isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady)); - _isSpawnGroundReady = isSpawnGroundReady ?? throw new ArgumentNullException(nameof(isSpawnGroundReady)); + _isWorldReady = isWorldReady ?? throw new ArgumentNullException(nameof(isWorldReady)); _enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode)); } @@ -117,7 +115,7 @@ public sealed class PlayerModeAutoEntry if (!_isLiveInWorld()) return false; if (!_isPlayerEntityPresent()) return false; if (!_isPlayerControllerReady()) return false; - if (!_isSpawnGroundReady()) return false; + if (!_isWorldReady()) return false; _armed = false; _enterPlayerMode(); diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c34f12bb..227a5cb3 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -147,6 +147,7 @@ public sealed class GameWindow : IDisposable private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements; private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Streaming.StreamingController? _streamingController; + private AcDream.App.Streaming.WorldRevealReadinessBarrier? _worldRevealReadiness; private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9) private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25) @@ -1413,41 +1414,11 @@ public sealed class GameWindow : IDisposable AcDream.Core.Net.WorldSession.State.InWorld, isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid), isPlayerControllerReady: () => true, - // #106 gate-2: hold player-mode entry until the terrain under - // the spawn position has streamed in — entering earlier - // integrates gravity against an empty world and free-falls - // the player into the void (retail loads cells synchronously; - // this is the async-streaming equivalent of that invariant). - isSpawnGroundReady: () => - { - if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false; - - // #107 / #135: spawn-ground readiness is spawn-claim aware. For an - // INDOOR claim (sealed dungeon / building interior) the ground the - // player lands on is the EnvCell FLOOR (its BSP), so gate on the - // cell's hydration (IsSpawnCellReady) — NOT the terrain heightmap. - // A dungeon's cells sit in their landblock at an arbitrary (often - // negative) offset, so the spawn's WORLD position can fall in a - // NEIGHBOUR terrain landblock that the #135 dungeon collapse - // deliberately does not load; requiring terrain there hangs login - // forever (cellReady true, SampleTerrainZ null). Retail loads the - // cell synchronously and places the player on the cell floor — - // cellReady is the faithful indoor equivalent (#106/#107, AD-2). - // (Before #135 this only passed by accident: the 25×25 window - // happened to stream the neighbour terrain.) - if (LastSpawns.TryGetValue(_playerServerGuid, out var sp) - && sp.Position is { } spawnClaim - && spawnClaim.LandblockId != 0 - && (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u - && !IsSpawnClaimUnhydratable(spawnClaim.LandblockId)) - return _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId); - - // Outdoor spawn, OR an unhydratable indoor claim that will demote to - // an outdoor position: hold until the terrain under the spawn streams - // (the original #106 gate — entering against an empty world free-falls - // the player into the void). - return _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null; - }, + // Retail SmartBox::UseTime (0x00455410) completes the player + // position only after CellManager's blocking load clears. The + // shared acdream barrier joins collision, static-mesh upload, + // and composite-texture readiness for both login and portals. + isWorldReady: LoginWorldReady, enterPlayerMode: EnterPlayerModeFromAutoEntry); } @@ -2873,6 +2844,20 @@ public sealed class GameWindow : IDisposable retirementCoordinator: _landblockRetirements); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; + _worldRevealReadiness = new AcDream.App.Streaming.WorldRevealReadinessBarrier( + isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident, + isSpawnCellReady: _physicsEngine.IsSpawnCellReady, + isTerrainNeighborhoodReady: _physicsEngine.IsNeighborhoodTerrainResident, + areCompositeTexturesReady: () => _wbDrawDispatcher?.CompositeTexturesReady == true, + prepareCompositeTextures: (destinationCell, radius) => + _wbDrawDispatcher?.PrepareCompositeTextures( + _worldState.Entities, + _worldState.FlatViewGeneration, + destinationCell, + radius), + invalidateCompositeTextures: () => + _wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(), + isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable); // Phase 4.7: optional live-mode startup. Connect to the ACE server, // enter the world as the first character on the account, and stream @@ -3624,6 +3609,13 @@ public sealed class GameWindow : IDisposable isSealedDungeon: IsSealedDungeonCell(position.LandblockId)); } + // Retail SmartBox::UseTime (0x00455410) does not complete the player + // position while CellManager is blocking for destination cells. Begin + // the same shared reveal lifetime used by portal arrivals: initial + // login must not inherit the dispatcher's default/previous composite + // readiness and expose a terrain-only or partially uploaded world. + _worldRevealReadiness?.Begin(); + // Streaming is normally gated until this point, so the next loaded- // landblock callback will materialize deferred records. Also cover an // already-resident block (tests, reconnect teardown overlap) without @@ -7465,7 +7457,8 @@ public sealed class GameWindow : IDisposable // standing on loaded ground with wall collision and a non-empty immediate view. The far // ring (out to the streaming window) still drains at the per-frame budget after portal // space exits. Bigger = a more complete arrival but a longer portal-space hold. - private const int TeleportNearRingRadius = 1; + private const int TeleportNearRingRadius = + AcDream.App.Streaming.WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius; /// /// Bind a sequence-correlated teleport Position to presentation and @@ -7544,7 +7537,7 @@ public sealed class GameWindow : IDisposable _pendingTeleportCell = p.LandblockId; _teleportHoldSeconds = 0f; _teleportForced = false; - _wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(); + _worldRevealReadiness?.Begin(); if (_streamingController is not null) { _streamingController.PriorityLandblockId = @@ -7615,18 +7608,24 @@ public sealed class GameWindow : IDisposable /// loudly rather than holding forever. /// private bool TeleportWorldReady(uint destCell) + => _worldRevealReadiness?.IsReady(destCell) == true; + + private bool LoginWorldReady() { - if (IsSpawnClaimUnhydratable(destCell)) return true; - bool indoor = (destCell & 0xFFFFu) >= 0x0100u; - int renderRadius = indoor ? 0 : TeleportNearRingRadius; - bool renderReady = _streamingController?.IsRenderNeighborhoodResident( - destCell, - renderRadius) == true; - renderReady &= _wbDrawDispatcher?.CompositeTexturesReady == true; - return indoor - ? renderReady && _physicsEngine.IsSpawnCellReady(destCell) - : renderReady - && _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius); + return TryGetLoginWorldCell(out uint cell) + && _worldRevealReadiness?.IsReady(cell) == true; + } + + private bool TryGetLoginWorldCell(out uint cell) + { + cell = 0; + if (_liveEntities is null + || !_liveEntities.TryGetSnapshot(_playerServerGuid, out var playerSpawn) + || playerSpawn.Position is not { } position) + return false; + + cell = position.LandblockId; + return cell != 0; } // The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only @@ -10113,24 +10112,13 @@ public sealed class GameWindow : IDisposable using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload)) { _wbMeshAdapter?.Tick(); - if (_teleportTransit.IsActive && _pendingTeleportCell != 0) - { - int compositeRadius = (_pendingTeleportCell & 0xFFFFu) >= 0x0100u - ? 0 - : TeleportNearRingRadius; - bool destinationMeshesPublished = - _streamingController?.IsRenderNeighborhoodResident( - _pendingTeleportCell, - compositeRadius) == true; - if (destinationMeshesPublished) - { - _wbDrawDispatcher?.PrepareCompositeTextures( - _worldState.Entities, - _worldState.FlatViewGeneration, - _pendingTeleportCell, - compositeRadius); - } - } + uint revealCell = _teleportTransit.IsActive + ? _pendingTeleportCell + : IsLiveModeWaitingForLogin && TryGetLoginWorldCell(out uint loginCell) + ? loginCell + : 0u; + if (revealCell != 0) + _worldRevealReadiness?.Prepare(revealCell); _particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); } if (_frameDiag) diff --git a/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs new file mode 100644 index 00000000..102b88a0 --- /dev/null +++ b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs @@ -0,0 +1,96 @@ +namespace AcDream.App.Streaming; + +/// +/// Joins the render-publication, texture, and collision domains that must be +/// complete before the normal world viewport is revealed. Retail keeps +/// SmartBox::position_update_complete false while +/// CellManager::blocking_for_cells is set +/// (SmartBox::UseTime, 0x00455410). acdream loads those domains +/// asynchronously, so login and portal arrival must share this explicit +/// equivalent of retail's single blocking-cell edge. +/// +internal sealed class WorldRevealReadinessBarrier +{ + internal const int OutdoorNeighborhoodRadius = 1; + + private readonly Func _isRenderNeighborhoodReady; + private readonly Func _isSpawnCellReady; + private readonly Func _isTerrainNeighborhoodReady; + private readonly Func _areCompositeTexturesReady; + private readonly Action _prepareCompositeTextures; + private readonly Action _invalidateCompositeTextures; + private readonly Func _isSpawnClaimUnhydratable; + + public WorldRevealReadinessBarrier( + Func isRenderNeighborhoodReady, + Func isSpawnCellReady, + Func isTerrainNeighborhoodReady, + Func areCompositeTexturesReady, + Action prepareCompositeTextures, + Action invalidateCompositeTextures, + Func isSpawnClaimUnhydratable) + { + _isRenderNeighborhoodReady = isRenderNeighborhoodReady + ?? throw new ArgumentNullException(nameof(isRenderNeighborhoodReady)); + _isSpawnCellReady = isSpawnCellReady + ?? throw new ArgumentNullException(nameof(isSpawnCellReady)); + _isTerrainNeighborhoodReady = isTerrainNeighborhoodReady + ?? throw new ArgumentNullException(nameof(isTerrainNeighborhoodReady)); + _areCompositeTexturesReady = areCompositeTexturesReady + ?? throw new ArgumentNullException(nameof(areCompositeTexturesReady)); + _prepareCompositeTextures = prepareCompositeTextures + ?? throw new ArgumentNullException(nameof(prepareCompositeTextures)); + _invalidateCompositeTextures = invalidateCompositeTextures + ?? throw new ArgumentNullException(nameof(invalidateCompositeTextures)); + _isSpawnClaimUnhydratable = isSpawnClaimUnhydratable + ?? throw new ArgumentNullException(nameof(isSpawnClaimUnhydratable)); + } + + /// + /// Starts a new reveal lifetime. Texture readiness is destination-scoped; + /// carrying the prior login/portal result across a new destination would + /// open the barrier before that destination's composites were uploaded. + /// + public void Begin() => _invalidateCompositeTextures(); + + /// + /// Advances render-thread texture preparation after all required static + /// meshes for the destination neighborhood have been published. + /// + public void Prepare(uint destinationCell) + { + if (destinationCell == 0 || _isSpawnClaimUnhydratable(destinationCell)) + return; + + int radius = RequiredRenderRadius(destinationCell); + if (_isRenderNeighborhoodReady(destinationCell, radius)) + _prepareCompositeTextures(destinationCell, radius); + } + + /// + /// True only when the same destination is drawable and collidable. An + /// impossible indoor claim intentionally crosses the barrier so the + /// existing loud forced-placement path can expose the invalid server data. + /// + public bool IsReady(uint destinationCell) + { + if (destinationCell == 0) + return false; + if (_isSpawnClaimUnhydratable(destinationCell)) + return true; + + int radius = RequiredRenderRadius(destinationCell); + if (!_isRenderNeighborhoodReady(destinationCell, radius) + || !_areCompositeTexturesReady()) + return false; + + return IsIndoor(destinationCell) + ? _isSpawnCellReady(destinationCell) + : _isTerrainNeighborhoodReady(destinationCell, radius); + } + + private static int RequiredRenderRadius(uint destinationCell) => + IsIndoor(destinationCell) ? 0 : OutdoorNeighborhoodRadius; + + private static bool IsIndoor(uint cellId) => (cellId & 0xFFFFu) >= 0x0100u; +} diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs new file mode 100644 index 00000000..52eb54b9 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs @@ -0,0 +1,124 @@ +using AcDream.App.Streaming; + +namespace AcDream.App.Tests.Streaming; + +public sealed class WorldRevealReadinessBarrierTests +{ + private sealed class State + { + public bool RenderReady; + public bool SpawnCellReady; + public bool TerrainReady; + public bool CompositeReady; + public bool Unhydratable; + public int Invalidations; + public int Preparations; + public uint PreparedCell; + public int PreparedRadius = -1; + public int RenderRadius = -1; + public int TerrainRadius = -1; + + public WorldRevealReadinessBarrier Build() => new( + isRenderNeighborhoodReady: (cell, radius) => + { + RenderRadius = radius; + return RenderReady; + }, + isSpawnCellReady: _ => SpawnCellReady, + isTerrainNeighborhoodReady: (cell, radius) => + { + TerrainRadius = radius; + return TerrainReady; + }, + areCompositeTexturesReady: () => CompositeReady, + prepareCompositeTextures: (cell, radius) => + { + Preparations++; + PreparedCell = cell; + PreparedRadius = radius; + }, + invalidateCompositeTextures: () => Invalidations++, + isSpawnClaimUnhydratable: _ => Unhydratable); + } + + [Fact] + public void Begin_InvalidatesPriorDestinationTextureReadiness() + { + var state = new State(); + var barrier = state.Build(); + + barrier.Begin(); + + Assert.Equal(1, state.Invalidations); + } + + [Fact] + public void OutdoorReveal_JoinsNearRenderTexturesAndTerrain() + { + const uint outdoorCell = 0x11340021u; + var state = new State(); + var barrier = state.Build(); + + Assert.False(barrier.IsReady(outdoorCell)); + Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, state.RenderRadius); + + state.RenderReady = true; + barrier.Prepare(outdoorCell); + Assert.Equal(1, state.Preparations); + Assert.Equal(outdoorCell, state.PreparedCell); + Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, state.PreparedRadius); + + state.CompositeReady = true; + Assert.False(barrier.IsReady(outdoorCell)); + + state.TerrainReady = true; + Assert.True(barrier.IsReady(outdoorCell)); + Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, state.TerrainRadius); + } + + [Fact] + public void IndoorReveal_UsesCenterRenderAndExactEnvCellPhysics() + { + const uint indoorCell = 0x11340100u; + var state = new State + { + RenderReady = true, + CompositeReady = true, + TerrainReady = true, + }; + var barrier = state.Build(); + + barrier.Prepare(indoorCell); + Assert.Equal(0, state.PreparedRadius); + Assert.False(barrier.IsReady(indoorCell)); + Assert.Equal(-1, state.TerrainRadius); + + state.SpawnCellReady = true; + Assert.True(barrier.IsReady(indoorCell)); + Assert.Equal(0, state.RenderRadius); + } + + [Fact] + public void Prepare_WaitsForStaticMeshPublication() + { + var state = new State(); + var barrier = state.Build(); + + barrier.Prepare(0x11340021u); + + Assert.Equal(0, state.Preparations); + } + + [Fact] + public void ImpossibleClaim_CrossesExistingLoudRecoveryPath() + { + var state = new State { Unhydratable = true }; + var barrier = state.Build(); + + barrier.Prepare(0x113401FFu); + + Assert.True(barrier.IsReady(0x113401FFu)); + Assert.Equal(0, state.Preparations); + Assert.Equal(-1, state.RenderRadius); + } +} diff --git a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs index 86d4bb5d..4ba0fea6 100644 --- a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs +++ b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs @@ -24,9 +24,9 @@ public sealed class AutoEnterPlayerModeTests public bool LiveInWorld; public bool PlayerEntityPresent; public bool PlayerControllerReady; - // Defaults TRUE: the hydration hold is not under test in the - // original K.2 cases (see SpawnGroundNotReady test for it). - public bool SpawnGroundReady = true; + // Defaults TRUE: the world-reveal hold is not under test in the + // original K.2 cases (see WorldNotReady test for it). + public bool WorldReady = true; public int EnteredCount; public PlayerModeAutoEntry Build() => @@ -34,23 +34,20 @@ public sealed class AutoEnterPlayerModeTests isLiveInWorld: () => LiveInWorld, isPlayerEntityPresent: () => PlayerEntityPresent, isPlayerControllerReady: () => PlayerControllerReady, - isSpawnGroundReady: () => SpawnGroundReady, + isWorldReady: () => WorldReady, enterPlayerMode: () => EnteredCount++); } [Fact] - public void TryEnter_Armed_SpawnGroundNotReady_DoesNotFire() + public void TryEnter_Armed_WorldNotReady_DoesNotFire() { - // #106 gate-2 (2026-06-09): player physics must not start before - // the terrain under the spawn position has streamed in — entering - // earlier free-falls the player into the void (gravity integrates - // against an empty world; retail never has this state because it - // loads cells synchronously). The guard holds until the streaming - // pipeline registers the spawn landblock. + // #229 extends #106's ground-only hold to retail's complete blocking + // cell-load edge. Player mode must not expose the viewport until the + // render, composite-texture, and collision domains have all converged. var s = new State { LiveInWorld = true, PlayerEntityPresent = true, - PlayerControllerReady = true, SpawnGroundReady = false, + PlayerControllerReady = true, WorldReady = false, }; var guard = s.Build(); guard.Arm(); @@ -59,8 +56,8 @@ public sealed class AutoEnterPlayerModeTests Assert.Equal(0, s.EnteredCount); Assert.True(guard.IsArmed); - // Terrain hydrates → fires on the next tick. - s.SpawnGroundReady = true; + // Render, texture, and collision domains converge → next tick fires. + s.WorldReady = true; Assert.True(guard.TryEnter()); Assert.Equal(1, s.EnteredCount); } From db45b81f7586f747b937fe6bc1560a665938a310 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Jul 2026 22:32:23 +0200 Subject: [PATCH 02/98] test(streaming): expose world reveal lifecycle --- ...26-07-20-automated-world-lifecycle-gate.md | 142 ++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 28 +++- .../WorldRevealLifecycleTelemetry.cs | 158 ++++++++++++++++++ .../Streaming/WorldRevealReadinessBarrier.cs | 68 +++++++- .../WorldRevealLifecycleTelemetryTests.cs | 105 ++++++++++++ .../WorldRevealReadinessBarrierTests.cs | 42 +++++ 6 files changed, 532 insertions(+), 11 deletions(-) create mode 100644 docs/plans/2026-07-20-automated-world-lifecycle-gate.md create mode 100644 src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs create mode 100644 tests/AcDream.App.Tests/Streaming/WorldRevealLifecycleTelemetryTests.cs diff --git a/docs/plans/2026-07-20-automated-world-lifecycle-gate.md b/docs/plans/2026-07-20-automated-world-lifecycle-gate.md new file mode 100644 index 00000000..0f04441b --- /dev/null +++ b/docs/plans/2026-07-20-automated-world-lifecycle-gate.md @@ -0,0 +1,142 @@ +# Automated world-lifecycle gate + +**Status:** In progress (2026-07-20) +**Owner:** M3 stabilization / R6 rendering-lifecycle rebaseline +**Depends on:** `WorldRevealReadinessBarrier`, retained-UI automation, +`FrameProfiler`, connected local ACE + +## Outcome + +Create a deterministic connected Release gate that catches the structural +failures which previously required someone to stare at the client for several +minutes: + +- the first login frame cannot expose world geometry before the destination's + render meshes, composite textures, and collision data are all ready; +- every portal/recall lifetime crosses the same readiness edge, materializes + once, and returns to the normal viewport once; +- dungeon entry and exit use the same lifecycle without grey frames, + stale-source geometry, or an uncollidable destination; +- graceful logout followed by a fresh reconnect starts a new reveal lifetime + and cannot inherit readiness or resources from the previous process; +- repeated destinations do not cause cumulative memory, owner-count, + allocation, update-time, or GPU-time growth; +- each stable checkpoint produces machine-readable state plus a screenshot for + later human comparison. + +This is a correctness and resource-lifetime gate. Screenshots preserve visual +evidence, but automation does **not** claim retail visual equivalence. Color, +composition, animation feel, and subtle geometry artifacts remain user gates. + +## Canonical runtime contract + +`WorldRevealReadinessBarrier` remains the only definition of destination +readiness. It will expose a structured snapshot rather than forcing telemetry +to repeat the readiness formula. A snapshot records the destination cell, +indoor/outdoor classification, required radius, render publication, composite +texture readiness, collision readiness, an impossible/unhydratable claim, and +the final `Ready` result. + +A focused App-layer `WorldRevealLifecycleTelemetry` owns diagnostic reveal +generations. It has no GL calls and no knowledge of `GameWindow`. For each +login or portal generation it records: + +1. begin; +2. destination acquisition; +3. each distinct readiness transition; +4. the first frame where the normal world viewport is actually eligible to + draw; +5. completion or cancellation. + +Making the normal viewport visible before `Ready` is a hard invariant failure, +not a warning. A newer generation retires the older one, so stale asynchronous +completion cannot satisfy the new destination. + +`GameWindow` only supplies facts and invokes the controller at existing +lifecycle seams. It does not gain a new feature body. + +## Production automation seam + +The retained-UI automation runner gains runtime commands through an injected +interface: + +- `wait world-ready [timeout-ms]` waits on canonical lifecycle state instead + of a guessed sleep; +- `wait materialized [timeout-ms]` waits for the authoritative + portal completion count; +- `checkpoint ` emits one JSON record containing reveal state, + landblock/entity/animation and resource-owner counts, managed memory, and + the latest frame-profiler summary; +- `screenshot [timeout-ms]` queues a default-framebuffer capture after + the complete world and retained UI have drawn, then waits for the render + thread to finish the PNG; +- existing `input` commands perform deterministic turns and movement through + `InputDispatcher`. + +All state mutation and GL readback stay on the update/render thread. The +external gate only launches the process, observes files/logs and OS process +metrics, and requests normal `WM_CLOSE` shutdown. + +## Connected route + +The gate runs two capped/uncapped-aware sessions against local ACE: + +1. fresh login → wait for canonical readiness → checkpoint + screenshot; +2. outdoor dense scene → turn → stable checkpoint + screenshot; +3. world-edge scene → turn → stable checkpoint; +4. known dungeon destination → checkpoint + screenshot → local movement; +5. return outdoors → checkpoint + screenshot; +6. revisit the first dense scene → stable resource comparison; +7. graceful close; +8. reconnect the same account → repeat the fresh-login readiness and + screenshot gate → graceful close. + +The existing seven-destination R6 soak remains the longer performance route. +The lifecycle gate is shorter and more diagnostic; it composes with that soak +rather than replacing it. + +## Hard failures + +- world viewport observed before the active reveal snapshot is ready; +- destination never reaches readiness or materialization within the bounded + connected test timeout; +- missing or duplicate reveal/materialization/completion edges; +- missing checkpoint JSON or screenshot, zero-sized/corrupt screenshot; +- absent required destination landblock/EnvCell/collision readiness; +- retained per-owner resources after delete/session teardown in deterministic + tests; +- same-location resource counts or memory/per-frame allocation grow beyond the + route's documented relative tolerance; +- unhandled exception, access violation, OOM, GL/device loss, disconnect, + unexpected WeenieError, nonzero exit, or failure of graceful shutdown. + +Absolute FPS is recorded, never compared across local and RDP sessions. The +gate compares like-for-like samples within one process and reports CPU and GPU +frame time separately. + +## Implementation sequence + +1. Add a structured readiness snapshot and exhaustive barrier tests. +2. Add lifecycle telemetry as a pure App owner with stale-generation, + cancellation, duplicate, and early-visible tests. +3. Add the automation runtime interface, deterministic waits, checkpoints, and + screenshot request/completion protocol. +4. Add the render-thread screenshot owner and structured resource snapshots. +5. Wire the owners at login, F751 portal start, TAS placement/completion, world + draw eligibility, and session teardown. +6. Add the connected route and PowerShell report/gate orchestration. +7. Run capped and uncapped Release gates, fix root causes, and retain artifacts. +8. Extract the now-protected streaming/portal/reveal frame orchestration from + `GameWindow` without changing the accepted lifecycle trace. +9. Run focused tests, full Release build/test, connected gates, graceful-close + verification, and documentation reconciliation. + +## Completion evidence + +- focused App tests for every lifecycle invariant; +- full Release solution build and test suite; +- two-process connected lifecycle report with screenshots and JSONL timeline; +- accepted capped and uncapped same-session metrics; +- `GameWindow` extraction retains the exact accepted lifecycle trace; +- roadmap, issues, architecture, divergence register, memory, `AGENTS.md`, and + `CLAUDE.md` agree on the resulting ownership and remaining visual gates. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 227a5cb3..1ffb0a79 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -148,6 +148,8 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Streaming.StreamingController? _streamingController; private AcDream.App.Streaming.WorldRevealReadinessBarrier? _worldRevealReadiness; + private readonly AcDream.App.Streaming.WorldRevealLifecycleTelemetry _worldRevealTelemetry = + new(message => Console.WriteLine(message)); private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9) private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25) @@ -3615,6 +3617,7 @@ public sealed class GameWindow : IDisposable // login must not inherit the dispatcher's default/previous composite // readiness and expose a terrain-only or partially uploaded world. _worldRevealReadiness?.Begin(); + _worldRevealTelemetry.Begin(AcDream.App.Streaming.WorldRevealKind.Login); // Streaming is normally gated until this point, so the next loaded- // landblock callback will materialize deferred records. Also cover an @@ -7538,6 +7541,7 @@ public sealed class GameWindow : IDisposable _teleportHoldSeconds = 0f; _teleportForced = false; _worldRevealReadiness?.Begin(); + _worldRevealTelemetry.Begin(AcDream.App.Streaming.WorldRevealKind.Portal); if (_streamingController is not null) { _streamingController.PriorityLandblockId = @@ -7561,7 +7565,10 @@ public sealed class GameWindow : IDisposable private void ResetTeleportTransitState(bool clearSession = false) { if (clearSession) + { _teleportTransit.ClearSession(); + _worldRevealTelemetry.Cancel(); + } else _teleportTransit.EndActive(); _pendingTeleportPos = default; @@ -7608,12 +7615,20 @@ public sealed class GameWindow : IDisposable /// loudly rather than holding forever. /// private bool TeleportWorldReady(uint destCell) - => _worldRevealReadiness?.IsReady(destCell) == true; + => EvaluateWorldRevealReadiness(destCell).IsReady; private bool LoginWorldReady() { return TryGetLoginWorldCell(out uint cell) - && _worldRevealReadiness?.IsReady(cell) == true; + && EvaluateWorldRevealReadiness(cell).IsReady; + } + + private AcDream.App.Streaming.WorldRevealReadinessSnapshot EvaluateWorldRevealReadiness( + uint destinationCell) + { + var snapshot = _worldRevealReadiness?.Evaluate(destinationCell) ?? default; + _worldRevealTelemetry.ObserveReadiness(snapshot); + return snapshot; } private bool TryGetLoginWorldCell(out uint cell) @@ -9742,6 +9757,7 @@ public sealed class GameWindow : IDisposable { case AcDream.Core.World.TeleportAnimEvent.Place: PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced); + _worldRevealTelemetry.ObserveMaterialized(); if (_streamingController is not null) { _streamingController.PriorityLandblockId = 0u; @@ -9761,6 +9777,7 @@ public sealed class GameWindow : IDisposable // each portal transition. _liveSession?.SendGameAction( AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); + _worldRevealTelemetry.Complete(); ResetTeleportTransitState(); break; default: @@ -10118,7 +10135,10 @@ public sealed class GameWindow : IDisposable ? loginCell : 0u; if (revealCell != 0) + { _worldRevealReadiness?.Prepare(revealCell); + EvaluateWorldRevealReadiness(revealCell); + } _particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); } if (_frameDiag) @@ -10176,7 +10196,11 @@ public sealed class GameWindow : IDisposable _particleVisibility.BeginFrame(camPos); _terrain?.BeginVisibilityFrame(); if (!IsLiveModeWaitingForLogin) + { _particleVisibility.UseWorldView(); + _worldRevealTelemetry.ObserveWorldViewportVisible(); + _worldRevealTelemetry.Complete(); + } // L.0 Audio tab: push the SettingsVM's live AudioDraft into the // engine each frame, so volume sliders preview audibly while diff --git a/src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs b/src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs new file mode 100644 index 00000000..95e0e018 --- /dev/null +++ b/src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs @@ -0,0 +1,158 @@ +using System.Globalization; + +namespace AcDream.App.Streaming; + +internal enum WorldRevealKind +{ + Login, + Portal, +} + +internal readonly record struct WorldRevealLifecycleSnapshot( + long Generation, + WorldRevealKind Kind, + WorldRevealReadinessSnapshot Readiness, + bool Materialized, + bool Completed, + bool Cancelled, + bool WorldViewportObserved, + int InvariantFailureCount) +{ + public bool IsActive => Generation != 0 && !Cancelled; + public bool IsReady => Readiness.IsReady; +} + +/// +/// Observes logical login/portal reveal generations without owning rendering or +/// changing gameplay state. The owner turns the canonical readiness decision +/// into deterministic lifecycle markers for connected automation and records a +/// hard diagnostic failure if normal world geometry becomes visible early. +/// +internal sealed class WorldRevealLifecycleTelemetry +{ + private readonly Action _log; + private WorldRevealLifecycleSnapshot _snapshot; + private long _nextGeneration; + private int _portalMaterializationCount; + + public WorldRevealLifecycleTelemetry(Action? log = null) + { + _log = log ?? (_ => { }); + } + + public WorldRevealLifecycleSnapshot Snapshot => _snapshot; + public int PortalMaterializationCount => _portalMaterializationCount; + + public long Begin(WorldRevealKind kind) + { + if (_snapshot.Generation != 0 + && !_snapshot.Cancelled + && !_snapshot.WorldViewportObserved) + { + Log("superseded", _snapshot); + } + + long generation = checked(++_nextGeneration); + _snapshot = new WorldRevealLifecycleSnapshot( + generation, + kind, + default, + Materialized: false, + Completed: false, + Cancelled: false, + WorldViewportObserved: false, + InvariantFailureCount: _snapshot.InvariantFailureCount); + Log("begin", _snapshot); + return generation; + } + + public void ObserveReadiness(WorldRevealReadinessSnapshot readiness) + { + if (_snapshot.Generation == 0 || _snapshot.Cancelled) + return; + + if (_snapshot.Readiness == readiness) + return; + + if (_snapshot.Readiness.DestinationCell != 0u + && readiness.DestinationCell != 0u + && _snapshot.Readiness.DestinationCell != readiness.DestinationCell) + { + FailInvariant( + "destination-changed", + $"from=0x{_snapshot.Readiness.DestinationCell:X8} to=0x{readiness.DestinationCell:X8}"); + } + + _snapshot = _snapshot with { Readiness = readiness }; + Log("readiness", _snapshot); + } + + public void ObserveMaterialized() + { + if (_snapshot.Generation == 0 || _snapshot.Cancelled || _snapshot.Materialized) + return; + + _snapshot = _snapshot with { Materialized = true }; + if (_snapshot.Kind == WorldRevealKind.Portal) + _portalMaterializationCount++; + Log("materialized", _snapshot); + } + + public void ObserveWorldViewportVisible() + { + if (_snapshot.Generation == 0 || _snapshot.Cancelled || _snapshot.WorldViewportObserved) + return; + + if (!_snapshot.Readiness.IsReady) + FailInvariant("viewport-before-ready", null); + + _snapshot = _snapshot with { WorldViewportObserved = true }; + Log("world-visible", _snapshot); + } + + public void Complete() + { + if (_snapshot.Generation == 0 || _snapshot.Cancelled || _snapshot.Completed) + return; + + _snapshot = _snapshot with { Completed = true }; + Log("complete", _snapshot); + } + + public void Cancel() + { + if (_snapshot.Generation == 0 || _snapshot.Cancelled) + return; + + _snapshot = _snapshot with { Cancelled = true }; + Log("cancel", _snapshot); + } + + private void FailInvariant(string reason, string? detail) + { + _snapshot = _snapshot with + { + InvariantFailureCount = checked(_snapshot.InvariantFailureCount + 1), + }; + string suffix = string.IsNullOrWhiteSpace(detail) ? string.Empty : $" {detail}"; + _log($"[world-reveal] event=invariant-failure reason={reason}{suffix} {Describe(_snapshot)}"); + } + + private void Log(string eventName, WorldRevealLifecycleSnapshot snapshot) => + _log($"[world-reveal] event={eventName} {Describe(snapshot)}"); + + private static string Describe(WorldRevealLifecycleSnapshot snapshot) + { + WorldRevealReadinessSnapshot ready = snapshot.Readiness; + return string.Create( + CultureInfo.InvariantCulture, + $"generation={snapshot.Generation} kind={snapshot.Kind} " + + $"cell=0x{ready.DestinationCell:X8} indoor={ready.IsIndoor} " + + $"radius={ready.RequiredRenderRadius} unhydratable={ready.IsUnhydratable} " + + $"render={ready.IsRenderNeighborhoodReady} composites={ready.AreCompositeTexturesReady} " + + $"collision={ready.IsCollisionReady} ready={ready.IsReady} " + + $"materialized={snapshot.Materialized} completed={snapshot.Completed} " + + $"cancelled={snapshot.Cancelled} visible={snapshot.WorldViewportObserved} " + + $"failures={snapshot.InvariantFailureCount}"); + } +} diff --git a/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs index 102b88a0..428cf912 100644 --- a/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs +++ b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs @@ -1,5 +1,29 @@ namespace AcDream.App.Streaming; +/// +/// One evaluation of the destination domains guarded by +/// . Keeping the individual facts in +/// the canonical owner lets diagnostics and automation observe readiness +/// without duplicating (and eventually drifting from) the reveal predicate. +/// +internal readonly record struct WorldRevealReadinessSnapshot( + uint DestinationCell, + bool IsIndoor, + bool IsUnhydratable, + int RequiredRenderRadius, + bool IsRenderNeighborhoodReady, + bool AreCompositeTexturesReady, + bool IsCollisionReady) +{ + public bool HasDestination => DestinationCell != 0u; + + public bool IsReady => HasDestination + && (IsUnhydratable + || (IsRenderNeighborhoodReady + && AreCompositeTexturesReady + && IsCollisionReady)); +} + /// /// Joins the render-publication, texture, and collision domains that must be /// complete before the normal world viewport is revealed. Retail keeps @@ -73,20 +97,46 @@ internal sealed class WorldRevealReadinessBarrier /// existing loud forced-placement path can expose the invalid server data. /// public bool IsReady(uint destinationCell) + => Evaluate(destinationCell).IsReady; + + /// + /// Evaluates each readiness domain once and returns the complete decision. + /// Consumers must use this snapshot rather than reimplementing the join. + /// + public WorldRevealReadinessSnapshot Evaluate(uint destinationCell) { if (destinationCell == 0) - return false; - if (_isSpawnClaimUnhydratable(destinationCell)) - return true; + return default; + bool isIndoor = IsIndoor(destinationCell); int radius = RequiredRenderRadius(destinationCell); - if (!_isRenderNeighborhoodReady(destinationCell, radius) - || !_areCompositeTexturesReady()) - return false; + if (_isSpawnClaimUnhydratable(destinationCell)) + { + return new WorldRevealReadinessSnapshot( + destinationCell, + isIndoor, + IsUnhydratable: true, + radius, + IsRenderNeighborhoodReady: false, + AreCompositeTexturesReady: false, + IsCollisionReady: false); + } - return IsIndoor(destinationCell) - ? _isSpawnCellReady(destinationCell) - : _isTerrainNeighborhoodReady(destinationCell, radius); + bool renderReady = _isRenderNeighborhoodReady(destinationCell, radius); + bool compositesReady = renderReady && _areCompositeTexturesReady(); + bool collisionReady = renderReady && compositesReady + && (isIndoor + ? _isSpawnCellReady(destinationCell) + : _isTerrainNeighborhoodReady(destinationCell, radius)); + + return new WorldRevealReadinessSnapshot( + destinationCell, + isIndoor, + IsUnhydratable: false, + radius, + renderReady, + compositesReady, + collisionReady); } private static int RequiredRenderRadius(uint destinationCell) => diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealLifecycleTelemetryTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealLifecycleTelemetryTests.cs new file mode 100644 index 00000000..a318c720 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealLifecycleTelemetryTests.cs @@ -0,0 +1,105 @@ +using AcDream.App.Streaming; + +namespace AcDream.App.Tests.Streaming; + +public sealed class WorldRevealLifecycleTelemetryTests +{ + private static WorldRevealReadinessSnapshot Ready(uint cell = 0x11340021u) => new( + cell, + IsIndoor: false, + IsUnhydratable: false, + RequiredRenderRadius: 1, + IsRenderNeighborhoodReady: true, + AreCompositeTexturesReady: true, + IsCollisionReady: true); + + [Fact] + public void Login_RecordsReadyThenFirstVisibleFrameExactlyOnce() + { + var logs = new List(); + var telemetry = new WorldRevealLifecycleTelemetry(logs.Add); + + long generation = telemetry.Begin(WorldRevealKind.Login); + telemetry.ObserveReadiness(Ready()); + telemetry.ObserveWorldViewportVisible(); + telemetry.ObserveWorldViewportVisible(); + telemetry.Complete(); + + Assert.Equal(generation, telemetry.Snapshot.Generation); + Assert.True(telemetry.Snapshot.IsReady); + Assert.True(telemetry.Snapshot.WorldViewportObserved); + Assert.True(telemetry.Snapshot.Completed); + Assert.Equal(0, telemetry.Snapshot.InvariantFailureCount); + Assert.Single(logs, line => line.Contains("event=world-visible", StringComparison.Ordinal)); + } + + [Fact] + public void EarlyWorldViewport_IsARecordedHardInvariantFailure() + { + var logs = new List(); + var telemetry = new WorldRevealLifecycleTelemetry(logs.Add); + + telemetry.Begin(WorldRevealKind.Login); + telemetry.ObserveReadiness(Ready() with { AreCompositeTexturesReady = false }); + telemetry.ObserveWorldViewportVisible(); + + Assert.Equal(1, telemetry.Snapshot.InvariantFailureCount); + Assert.Contains(logs, line => line.Contains("reason=viewport-before-ready", StringComparison.Ordinal)); + } + + [Fact] + public void NewGeneration_SupersedesOldStateAndRejectsDestinationMutation() + { + var logs = new List(); + var telemetry = new WorldRevealLifecycleTelemetry(logs.Add); + + long first = telemetry.Begin(WorldRevealKind.Login); + telemetry.ObserveReadiness(Ready(0x11340021u)); + long second = telemetry.Begin(WorldRevealKind.Portal); + telemetry.ObserveReadiness(Ready(0x3032001Cu)); + telemetry.ObserveReadiness(Ready(0xC95B0001u)); + + Assert.True(second > first); + Assert.Equal(WorldRevealKind.Portal, telemetry.Snapshot.Kind); + Assert.Equal(0xC95B0001u, telemetry.Snapshot.Readiness.DestinationCell); + Assert.Equal(1, telemetry.Snapshot.InvariantFailureCount); + Assert.Contains(logs, line => line.Contains("event=superseded", StringComparison.Ordinal)); + Assert.Contains(logs, line => line.Contains("reason=destination-changed", StringComparison.Ordinal)); + } + + [Fact] + public void PortalMaterialization_IsCountedOncePerGeneration() + { + var telemetry = new WorldRevealLifecycleTelemetry(); + + telemetry.Begin(WorldRevealKind.Portal); + telemetry.ObserveReadiness(Ready()); + telemetry.ObserveMaterialized(); + telemetry.ObserveMaterialized(); + telemetry.Begin(WorldRevealKind.Portal); + telemetry.ObserveReadiness(Ready(0x3032001Cu)); + telemetry.ObserveMaterialized(); + + Assert.Equal(2, telemetry.PortalMaterializationCount); + } + + [Fact] + public void CancelledGeneration_IgnoresLateAsyncObservations() + { + var telemetry = new WorldRevealLifecycleTelemetry(); + + telemetry.Begin(WorldRevealKind.Portal); + telemetry.Cancel(); + telemetry.ObserveReadiness(Ready()); + telemetry.ObserveMaterialized(); + telemetry.ObserveWorldViewportVisible(); + telemetry.Complete(); + + Assert.True(telemetry.Snapshot.Cancelled); + Assert.False(telemetry.Snapshot.IsReady); + Assert.False(telemetry.Snapshot.Materialized); + Assert.False(telemetry.Snapshot.WorldViewportObserved); + Assert.False(telemetry.Snapshot.Completed); + Assert.Equal(0, telemetry.PortalMaterializationCount); + } +} diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs index 52eb54b9..8ac3e9a0 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealReadinessBarrierTests.cs @@ -121,4 +121,46 @@ public sealed class WorldRevealReadinessBarrierTests Assert.Equal(0, state.Preparations); Assert.Equal(-1, state.RenderRadius); } + + [Fact] + public void Evaluate_ExposesTheCanonicalOutdoorDecisionWithoutRepeatingDomains() + { + const uint outdoorCell = 0x11340021u; + var state = new State + { + RenderReady = true, + CompositeReady = true, + TerrainReady = true, + }; + + WorldRevealReadinessSnapshot snapshot = state.Build().Evaluate(outdoorCell); + + Assert.Equal(outdoorCell, snapshot.DestinationCell); + Assert.False(snapshot.IsIndoor); + Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, snapshot.RequiredRenderRadius); + Assert.True(snapshot.IsRenderNeighborhoodReady); + Assert.True(snapshot.AreCompositeTexturesReady); + Assert.True(snapshot.IsCollisionReady); + Assert.True(snapshot.IsReady); + Assert.Equal(-1, state.PreparedRadius); + } + + [Fact] + public void Evaluate_ShortCircuitsDownstreamDomainsUntilRenderPublication() + { + var state = new State + { + CompositeReady = true, + TerrainReady = true, + SpawnCellReady = true, + }; + + WorldRevealReadinessSnapshot snapshot = state.Build().Evaluate(0x11340100u); + + Assert.False(snapshot.IsRenderNeighborhoodReady); + Assert.False(snapshot.AreCompositeTexturesReady); + Assert.False(snapshot.IsCollisionReady); + Assert.False(snapshot.IsReady); + Assert.Equal(-1, state.TerrainRadius); + } } From 354c2adc2e5602899ef1a2f894c40611597d4bea Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Jul 2026 22:37:16 +0200 Subject: [PATCH 03/98] test(runtime): add deterministic world gate artifacts --- src/AcDream.App/Diagnostics/FrameProfiler.cs | 8 +- .../Diagnostics/FrameScreenshotController.cs | 148 +++++++++++++++++ .../WorldLifecycleAutomationController.cs | 155 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 74 ++++++++- src/AcDream.App/RuntimeOptions.cs | 7 +- src/AcDream.App/UI/RetailUiRuntime.cs | 6 +- .../Testing/RetailUiAutomationScriptRunner.cs | 74 ++++++++- ...WorldLifecycleAutomationControllerTests.cs | 130 +++++++++++++++ .../RuntimeOptionsRetailUiTests.cs | 2 + .../AcDream.App.Tests/RuntimeOptionsTests.cs | 1 + .../UI/RetailUiAutomationProbeTests.cs | 108 ++++++++++++ 11 files changed, 703 insertions(+), 10 deletions(-) create mode 100644 src/AcDream.App/Diagnostics/FrameScreenshotController.cs create mode 100644 src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs create mode 100644 tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs diff --git a/src/AcDream.App/Diagnostics/FrameProfiler.cs b/src/AcDream.App/Diagnostics/FrameProfiler.cs index 4f625bf3..f2fbb600 100644 --- a/src/AcDream.App/Diagnostics/FrameProfiler.cs +++ b/src/AcDream.App/Diagnostics/FrameProfiler.cs @@ -62,6 +62,9 @@ public sealed class FrameProfiler : IDisposable private bool _wbDiagNoticePrinted; private bool _wasEnabled; + /// Most recent immutable report line, for explicit automation checkpoints. + public string? LastReport { get; private set; } + public FrameProfiler() { _stageUs = new FrameStatsBuffer[StageCount]; @@ -146,8 +149,9 @@ public sealed class FrameProfiler : IDisposable int gc0 = GC.CollectionCount(0) - _gc0Base; int gc1 = GC.CollectionCount(1) - _gc1Base; int gc2 = GC.CollectionCount(2) - _gc2Base; - Console.WriteLine(FormatReport(_framesInWindow, _cpuUs, _gpuUs, - gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs)); + LastReport = FormatReport(_framesInWindow, _cpuUs, _gpuUs, + gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs); + Console.WriteLine(LastReport); _lastReportTicks = nowTicks; _gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2; _framesInWindow = 0; diff --git a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs new file mode 100644 index 00000000..e1f4fcc5 --- /dev/null +++ b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs @@ -0,0 +1,148 @@ +using Silk.NET.OpenGL; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace AcDream.App.Diagnostics; + +/// +/// Render-thread owner for diagnostic captures of the complete default +/// framebuffer. Requests may be made while retained UI ticks; capture occurs +/// later in the same frame after world, retained UI, and optional ImGui draw. +/// +internal sealed class FrameScreenshotController +{ + private enum CaptureState + { + Pending, + Complete, + Failed, + } + + private sealed record CaptureStatus(CaptureState State, string? Error = null); + + private readonly Func<(int Width, int Height)> _getSize; + private readonly Func _readRgba; + private readonly string _directory; + private readonly Action _log; + private readonly Queue _pending = new(); + private readonly Dictionary _status = + new(StringComparer.OrdinalIgnoreCase); + + public FrameScreenshotController( + GL gl, + Func<(int Width, int Height)> getSize, + string directory, + Action? log = null) + : this(getSize, (width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log) + { + ArgumentNullException.ThrowIfNull(gl); + } + + internal FrameScreenshotController( + Func<(int Width, int Height)> getSize, + Func readRgba, + string directory, + Action? log = null) + { + _getSize = getSize ?? throw new ArgumentNullException(nameof(getSize)); + _readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba)); + _directory = string.IsNullOrWhiteSpace(directory) + ? throw new ArgumentException("A screenshot directory is required.", nameof(directory)) + : Path.GetFullPath(directory); + _log = log ?? (_ => { }); + } + + public bool TryRequest(string name, out string error) + { + if (!AutomationArtifactName.TryValidate(name, out error)) + return false; + + if (_status.TryGetValue(name, out CaptureStatus? status)) + { + if (status.State != CaptureState.Failed) + return true; + error = status.Error ?? $"screenshot '{name}' failed"; + return false; + } + + _status.Add(name, new CaptureStatus(CaptureState.Pending)); + _pending.Enqueue(name); + _log($"[world-gate] screenshot-request name={name}"); + return true; + } + + public bool IsComplete(string name) => + _status.TryGetValue(name, out CaptureStatus? status) + && status.State == CaptureState.Complete; + + /// Captures at most one queued image on the current GL thread. + public void CapturePending() + { + if (_pending.Count == 0) + return; + + string name = _pending.Dequeue(); + try + { + (int width, int height) = _getSize(); + if (width <= 0 || height <= 0) + throw new InvalidOperationException($"invalid framebuffer size {width}x{height}"); + + byte[] pixels = _readRgba(width, height); + int expected = checked(width * height * 4); + if (pixels.Length != expected) + throw new InvalidOperationException( + $"framebuffer read returned {pixels.Length} bytes; expected {expected}"); + + byte[] flipped = FlipRows(pixels, width, height); + Directory.CreateDirectory(_directory); + string path = Path.Combine(_directory, name + ".png"); + string temporaryPath = path + ".tmp"; + using (Image image = Image.LoadPixelData(flipped, width, height)) + image.SaveAsPng(temporaryPath); + File.Move(temporaryPath, path, overwrite: true); + + _status[name] = new CaptureStatus(CaptureState.Complete); + _log($"[world-gate] screenshot-complete name={name} path={path} size={width}x{height}"); + } + catch (Exception exception) + { + string message = $"screenshot '{name}' failed: {exception.Message}"; + _status[name] = new CaptureStatus(CaptureState.Failed, message); + _log($"[world-gate] screenshot-failed name={name} error={exception.Message}"); + } + } + + internal static byte[] FlipRows(byte[] pixels, int width, int height) + { + int stride = checked(width * 4); + byte[] flipped = new byte[pixels.Length]; + for (int row = 0; row < height; row++) + { + System.Buffer.BlockCopy( + pixels, + row * stride, + flipped, + (height - 1 - row) * stride, + stride); + } + return flipped; + } + + private static unsafe byte[] ReadDefaultFramebuffer(GL gl, int width, int height) + { + byte[] pixels = new byte[checked(width * height * 4)]; + fixed (byte* pointer = pixels) + { + gl.ReadPixels( + 0, + 0, + (uint)width, + (uint)height, + PixelFormat.Rgba, + PixelType.UnsignedByte, + pointer); + } + return pixels; + } +} diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs new file mode 100644 index 00000000..e6712a7f --- /dev/null +++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using AcDream.App.Streaming; +using AcDream.App.UI.Testing; + +namespace AcDream.App.Diagnostics; + +internal static class AutomationArtifactName +{ + public static bool TryValidate(string? name, out string error) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > 80) + { + error = "artifact name must contain 1-80 characters"; + return false; + } + + foreach (char c in name) + { + if (!char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_') + { + error = $"artifact name '{name}' may contain only letters, digits, '-' and '_'"; + return false; + } + } + + error = string.Empty; + return true; + } +} + +internal sealed record WorldLifecycleResourceSnapshot( + int LoadedLandblocks, + int WorldEntities, + int AnimatedEntities, + int VisibleLandblocks, + int TotalLandblocks, + int LiveEntities, + int MaterializedLiveEntities, + int PendingLiveTeardowns, + int PendingLandblockRetirements, + int ParticleEmitters, + int Particles, + int ParticleBindings, + int ParticleOwners, + int EffectOwners, + int LightOwners, + int ScriptOwners, + int ActiveScripts, + int MeshRenderData, + int MeshAtlasArrays, + long MeshEstimatedBytes, + int StagedMeshUploads, + long StagedMeshBytes, + long TrackedGpuBytes, + int TrackedGpuBuffers, + int TrackedGpuTextures, + int OwnedCompositeTextures, + int CompositeTextureOwners, + int ActiveParticleTextures, + int ParticleTextureOwners, + int CompositeWarmupPending, + long ManagedBytes, + long ManagedCommittedBytes, + double Fps, + double FrameMilliseconds, + string? LastFrameProfile); + +internal sealed record WorldLifecycleCheckpoint( + int Sequence, + string Name, + DateTime TimestampUtc, + int ProcessId, + WorldRevealLifecycleSnapshot Reveal, + WorldLifecycleResourceSnapshot Resources); + +/// +/// Diagnostic-only runtime seam used by production retained-UI scripts. It +/// writes one structured checkpoint at explicit script edges and delegates GL +/// capture to the render-thread screenshot owner. +/// +internal sealed class WorldLifecycleAutomationController : IRetailUiAutomationRuntime +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly Func _getReveal; + private readonly Func _getPortalMaterializationCount; + private readonly Func _captureResources; + private readonly FrameScreenshotController _screenshots; + private readonly string _artifactDirectory; + private readonly Action _log; + private int _sequence; + + public WorldLifecycleAutomationController( + Func getReveal, + Func getPortalMaterializationCount, + Func captureResources, + FrameScreenshotController screenshots, + string artifactDirectory, + Action? log = null) + { + _getReveal = getReveal ?? throw new ArgumentNullException(nameof(getReveal)); + _getPortalMaterializationCount = getPortalMaterializationCount + ?? throw new ArgumentNullException(nameof(getPortalMaterializationCount)); + _captureResources = captureResources ?? throw new ArgumentNullException(nameof(captureResources)); + _screenshots = screenshots ?? throw new ArgumentNullException(nameof(screenshots)); + _artifactDirectory = string.IsNullOrWhiteSpace(artifactDirectory) + ? throw new ArgumentException("An automation artifact directory is required.", nameof(artifactDirectory)) + : Path.GetFullPath(artifactDirectory); + _log = log ?? (_ => { }); + } + + public bool IsWorldReady => _getReveal().IsReady; + public bool IsWorldViewportVisible => _getReveal().WorldViewportObserved; + public int PortalMaterializationCount => _getPortalMaterializationCount(); + + public bool TryWriteCheckpoint(string name, out string error) + { + if (!AutomationArtifactName.TryValidate(name, out error)) + return false; + + try + { + Directory.CreateDirectory(_artifactDirectory); + var checkpoint = new WorldLifecycleCheckpoint( + Sequence: checked(++_sequence), + Name: name, + TimestampUtc: DateTime.UtcNow, + ProcessId: Environment.ProcessId, + Reveal: _getReveal(), + Resources: _captureResources()); + string json = JsonSerializer.Serialize(checkpoint, JsonOptions); + string timelinePath = Path.Combine(_artifactDirectory, "world-lifecycle.checkpoints.jsonl"); + File.AppendAllText(timelinePath, json + Environment.NewLine); + File.WriteAllText( + Path.Combine(_artifactDirectory, $"checkpoint-{name}.json"), + json + Environment.NewLine); + _log($"[world-gate] checkpoint name={name} sequence={checkpoint.Sequence} path={timelinePath}"); + error = string.Empty; + return true; + } + catch (Exception exception) + { + error = $"checkpoint '{name}' failed: {exception.Message}"; + return false; + } + } + + public bool TryRequestScreenshot(string name, out string error) => + _screenshots.TryRequest(name, out error); + + public bool IsScreenshotComplete(string name) => _screenshots.IsComplete(name); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1ffb0a79..548eec6e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -134,6 +134,8 @@ public sealed class GameWindow : IDisposable // per OnRender + three stage scopes. All logic lives in // AcDream.App.Diagnostics.FrameProfiler (structure rule 1). private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new(); + private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; + private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; private ResourceShutdownTransaction? _shutdown; private readonly FramePacingController _framePacing = new(); @@ -2275,6 +2277,24 @@ public sealed class GameWindow : IDisposable ScreenSize: () => (_window.Size.X, _window.Size.Y)); void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message); + if (_options.UiProbeEnabled + && _options.AutomationArtifactDirectory is { } artifactDirectory) + { + _frameScreenshots = new AcDream.App.Diagnostics.FrameScreenshotController( + _gl!, + () => (_window!.Size.X, _window.Size.Y), + System.IO.Path.Combine(artifactDirectory, "screenshots"), + UiProbeLog); + _worldLifecycleAutomation = + new AcDream.App.Diagnostics.WorldLifecycleAutomationController( + () => _worldRevealTelemetry.Snapshot, + () => _worldRevealTelemetry.PortalMaterializationCount, + CaptureWorldLifecycleResourceSnapshot, + _frameScreenshots, + artifactDirectory, + UiProbeLog); + } + _retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount( new AcDream.App.UI.RetailUiRuntimeBindings( Host: _uiHost, @@ -2426,7 +2446,8 @@ public sealed class GameWindow : IDisposable UiProbeLog, action => _inputDispatcher?.TryInvokeAutomationAction(action) == true, (action, held) => - _inputDispatcher?.TrySetAutomationActionHeld(action, held) == true))); + _inputDispatcher?.TrySetAutomationActionHeld(action, held) == true, + _worldLifecycleAutomation))); } @@ -11226,6 +11247,11 @@ public sealed class GameWindow : IDisposable } } + // Explicit diagnostic captures are requested by the retained-UI + // automation tick above and executed only after every presentation + // layer has drawn into the back buffer on this render thread. + _frameScreenshots?.CapturePending(); + // Update the window title with performance stats every ~0.5s. _perfAccum += deltaSeconds; _perfFrameCount++; @@ -11235,6 +11261,8 @@ public sealed class GameWindow : IDisposable double fps = _perfFrameCount / _perfAccum; int entityCount = _worldState.Entities.Count; int animatedCount = _animatedEntities.Count; + _lastVisibleLandblocks = visibleLandblocks; + _lastTotalLandblocks = totalLandblocks; // Keep the developer diagnostic title independent from retail's // /framerate switch. That command owns the in-world SmartBox readout; @@ -11346,6 +11374,50 @@ public sealed class GameWindow : IDisposable } } + private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot + CaptureWorldLifecycleResourceSnapshot() + { + var meshManager = _wbMeshAdapter?.MeshManager; + var mesh = meshManager?.Diagnostics ?? default; + GCMemoryInfo memory = GC.GetGCMemoryInfo(); + return new AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot( + LoadedLandblocks: _worldState.LoadedLandblockIds.Count, + WorldEntities: _worldState.Entities.Count, + AnimatedEntities: _animatedEntities.Count, + VisibleLandblocks: _lastVisibleLandblocks, + TotalLandblocks: _lastTotalLandblocks, + LiveEntities: _liveEntities?.Count ?? 0, + MaterializedLiveEntities: _liveEntities?.MaterializedCount ?? 0, + PendingLiveTeardowns: _liveEntities?.PendingTeardownCount ?? 0, + PendingLandblockRetirements: _landblockRetirements?.PendingCount ?? 0, + ParticleEmitters: _particleSystem?.ActiveEmitterCount ?? 0, + Particles: _particleSystem?.ActiveParticleCount ?? 0, + ParticleBindings: _particleSink?.ActiveBindingCount ?? 0, + ParticleOwners: _particleSink?.TrackedOwnerCount ?? 0, + EffectOwners: _entityEffects?.ReadyOwnerCount ?? 0, + LightOwners: _liveEntityLights?.TrackedOwnerCount ?? 0, + ScriptOwners: _scriptRunner?.ActiveOwnerCount ?? 0, + ActiveScripts: _scriptRunner?.ActiveScriptCount ?? 0, + MeshRenderData: mesh.RenderData, + MeshAtlasArrays: mesh.AtlasArrays, + MeshEstimatedBytes: mesh.EstimatedBytes, + StagedMeshUploads: _wbMeshAdapter?.StagedUploadBacklog ?? 0, + StagedMeshBytes: _wbMeshAdapter?.StagedUploadBytes ?? 0, + TrackedGpuBytes: AcDream.App.Rendering.Wb.GpuMemoryTracker.AllocatedBytes, + TrackedGpuBuffers: AcDream.App.Rendering.Wb.GpuMemoryTracker.BufferCount, + TrackedGpuTextures: AcDream.App.Rendering.Wb.GpuMemoryTracker.TextureCount, + OwnedCompositeTextures: _textureCache?.OwnedBindlessTextureCount ?? 0, + CompositeTextureOwners: _textureCache?.TextureOwnerCount ?? 0, + ActiveParticleTextures: _textureCache?.ActiveParticleTextureCount ?? 0, + ParticleTextureOwners: _textureCache?.ParticleTextureOwnerCount ?? 0, + CompositeWarmupPending: _wbDrawDispatcher?.LastCompositeWarmupPendingCount ?? 0, + ManagedBytes: GC.GetTotalMemory(forceFullCollection: false), + ManagedCommittedBytes: memory.TotalCommittedBytes, + Fps: _lastFps, + FrameMilliseconds: _lastFrameMs, + LastFrameProfile: _frameProfiler.LastReport); + } + private bool RunRemoteTeleportHook( uint serverGuid, uint localEntityId, diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index dc482d8c..8fa4ae39 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -44,7 +44,8 @@ public sealed record RuntimeOptions( bool RetailUi, string? AcDir, bool UiProbeDump, - string? UiProbeScript) + string? UiProbeScript, + string? AutomationArtifactDirectory) { /// /// Build options from the process environment. Used by @@ -94,7 +95,9 @@ public sealed record RuntimeOptions( RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")), UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")), - UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT"))); + UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")), + AutomationArtifactDirectory: + NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR"))); } /// True iff live-mode credentials are present and valid for connecting. diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 44ecb911..77075738 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -149,7 +149,8 @@ public sealed record RetailUiProbeBindings( bool DumpOnStart, Action Log, Func PressInput, - Func SetInputHeld); + Func SetInputHeld, + Testing.IRetailUiAutomationRuntime? Runtime = null); public sealed record RetailUiCursorBindings( CursorFeedbackController Feedback, @@ -266,7 +267,8 @@ public sealed class RetailUiRuntime : IDisposable bindings.Chat.CommandBus(), ChatChannelKind.Say), bindings.Probe.PressInput, - bindings.Probe.SetInputHeld); + bindings.Probe.SetInputHeld, + bindings.Probe.Runtime); } } diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs index 8ddf5cf7..9262eb51 100644 --- a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs @@ -7,6 +7,21 @@ using AcDream.UI.Abstractions.Input; namespace AcDream.App.UI.Testing; +/// +/// Narrow bridge from retained-UI scripts to render/world lifecycle +/// diagnostics. Implementations run on the same update/render thread as the +/// script; they must not mutate gameplay state or call GL from another thread. +/// +public interface IRetailUiAutomationRuntime +{ + bool IsWorldReady { get; } + bool IsWorldViewportVisible { get; } + int PortalMaterializationCount { get; } + bool TryWriteCheckpoint(string name, out string error); + bool TryRequestScreenshot(string name, out string error); + bool IsScreenshotComplete(string name); +} + /// /// Tiny one-thread script runner for . /// It is intended for local diagnostic launches, not for gameplay. Commands @@ -20,6 +35,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable private readonly Action? _submitCommand; private readonly Func? _pressInput; private readonly Func? _setInputHeld; + private readonly IRetailUiAutomationRuntime? _runtime; private readonly List _commands = new(); private readonly HashSet _heldInputs = new(); private readonly bool _dumpOnStart; @@ -39,13 +55,15 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable Action? log = null, Action? submitCommand = null, Func? pressInput = null, - Func? setInputHeld = null) + Func? setInputHeld = null, + IRetailUiAutomationRuntime? runtime = null) { _probe = probe ?? throw new ArgumentNullException(nameof(probe)); _log = log ?? (_ => { }); _submitCommand = submitCommand; _pressInput = pressInput; _setInputHeld = setInputHeld; + _runtime = runtime; _dumpOnStart = dumpOnStart; if (!string.IsNullOrWhiteSpace(scriptPath)) @@ -162,6 +180,8 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable "assert" => DoAssert(command), "command" => DoCommand(command), "input" => DoInput(command), + "checkpoint" => DoCheckpoint(command), + "screenshot" => DoScreenshot(command), _ => Stop(command, $"unknown command '{p[0]}'"), }; } @@ -236,7 +256,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable private bool DoWait(ScriptCommand command) { var p = command.Parts; - if (p.Length < 2) return Stop(command, "usage: wait item | wait element | wait ms "); + if (p.Length < 2) return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ..."); string target = p[1].ToLowerInvariant(); if (target == "item") @@ -256,7 +276,30 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable if (target == "ms") return DoSleep(command); - return Stop(command, "usage: wait item | wait element | wait ms "); + if (target == "world-ready") + { + if (_runtime is null) return Stop(command, "world lifecycle automation is unavailable"); + if (_runtime.IsWorldReady) return true; + return WaitOrTimeout(command, TimeoutMs(p, 2, 60000), "world readiness"); + } + + if (target == "world-visible") + { + if (_runtime is null) return Stop(command, "world lifecycle automation is unavailable"); + if (_runtime.IsWorldViewportVisible) return true; + return WaitOrTimeout(command, TimeoutMs(p, 2, 60000), "normal world viewport"); + } + + if (target == "materialized") + { + if (_runtime is null) return Stop(command, "world lifecycle automation is unavailable"); + if (p.Length < 3 || !TryParseInt(p[2], out int occurrence) || occurrence <= 0) + return Stop(command, "usage: wait materialized [timeoutMs]"); + if (_runtime.PortalMaterializationCount >= occurrence) return true; + return WaitOrTimeout(command, TimeoutMs(p, 3, 60000), $"portal materialization {occurrence}"); + } + + return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ..."); } private bool DoSleep(ScriptCommand command) @@ -356,6 +399,31 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable } } + private bool DoCheckpoint(ScriptCommand command) + { + if (command.Parts.Length != 2) + return Stop(command, "usage: checkpoint "); + if (_runtime is null) + return Stop(command, "world lifecycle automation is unavailable"); + return _runtime.TryWriteCheckpoint(command.Parts[1], out string error) + || Stop(command, error); + } + + private bool DoScreenshot(ScriptCommand command) + { + if (command.Parts.Length is < 2 or > 3) + return Stop(command, "usage: screenshot [timeoutMs]"); + if (_runtime is null) + return Stop(command, "world lifecycle automation is unavailable"); + + string name = command.Parts[1]; + if (!_runtime.TryRequestScreenshot(name, out string error)) + return Stop(command, error); + if (_runtime.IsScreenshotComplete(name)) + return true; + return WaitOrTimeout(command, TimeoutMs(command.Parts, 2, 10000), $"screenshot '{name}'"); + } + private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label) { if (!HasExceeded(timeoutMs)) return false; diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs new file mode 100644 index 00000000..72a1842d --- /dev/null +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -0,0 +1,130 @@ +using System.Text.Json; +using AcDream.App.Diagnostics; +using AcDream.App.Streaming; +using SixLabors.ImageSharp; + +namespace AcDream.App.Tests.Diagnostics; + +public sealed class WorldLifecycleAutomationControllerTests +{ + [Fact] + public void ScreenshotCapture_FlipsGlRowsAndWritesACompletePng() + { + string directory = NewDirectory(); + // GL order: bottom row red/green, top row blue/white. + byte[] rgba = + [ + 255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 255, 255, 255, 255, 255, 255, + ]; + var logs = new List(); + var controller = new FrameScreenshotController( + () => (2, 2), + (_, _) => rgba, + directory, + logs.Add); + + try + { + Assert.True(controller.TryRequest("login_stable", out string error), error); + Assert.False(controller.IsComplete("login_stable")); + + controller.CapturePending(); + + Assert.True(controller.IsComplete("login_stable")); + string path = Path.Combine(directory, "login_stable.png"); + using Image image = Image.Load(path); + Assert.Equal(2, image.Width); + Assert.Equal(2, image.Height); + Assert.Contains(logs, line => line.Contains("screenshot-complete", StringComparison.Ordinal)); + + byte[] flipped = FrameScreenshotController.FlipRows(rgba, 2, 2); + Assert.Equal(rgba.AsSpan(8, 8).ToArray(), flipped.AsSpan(0, 8).ToArray()); + Assert.Equal(rgba.AsSpan(0, 8).ToArray(), flipped.AsSpan(8, 8).ToArray()); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Theory] + [InlineData("")] + [InlineData("../escape")] + [InlineData("has spaces")] + [InlineData("name.png")] + public void ArtifactNames_RejectPathsAndAmbiguousCharacters(string name) + { + Assert.False(AutomationArtifactName.TryValidate(name, out string error)); + Assert.NotEmpty(error); + } + + [Fact] + public void Checkpoint_WritesCanonicalRevealAndResourceSnapshot() + { + string directory = NewDirectory(); + var reveal = new WorldRevealLifecycleSnapshot( + Generation: 7, + Kind: WorldRevealKind.Portal, + Readiness: new WorldRevealReadinessSnapshot( + 0x8A020164u, + IsIndoor: true, + IsUnhydratable: false, + RequiredRenderRadius: 0, + IsRenderNeighborhoodReady: true, + AreCompositeTexturesReady: true, + IsCollisionReady: true), + Materialized: true, + Completed: true, + Cancelled: false, + WorldViewportObserved: true, + InvariantFailureCount: 0); + var resources = EmptyResources() with + { + LoadedLandblocks = 1, + WorldEntities = 42, + TrackedGpuBytes = 1234, + }; + var screenshots = new FrameScreenshotController( + () => (1, 1), + (_, _) => [0, 0, 0, 255], + Path.Combine(directory, "screenshots")); + var controller = new WorldLifecycleAutomationController( + () => reveal, + () => 3, + () => resources, + screenshots, + directory); + + try + { + Assert.True(controller.IsWorldReady); + Assert.True(controller.IsWorldViewportVisible); + Assert.Equal(3, controller.PortalMaterializationCount); + Assert.True(controller.TryWriteCheckpoint("dungeon", out string error), error); + + string line = Assert.Single(File.ReadAllLines( + Path.Combine(directory, "world-lifecycle.checkpoints.jsonl"))); + using JsonDocument json = JsonDocument.Parse(line); + Assert.Equal("dungeon", json.RootElement.GetProperty("name").GetString()); + Assert.Equal(7, json.RootElement.GetProperty("reveal").GetProperty("generation").GetInt64()); + Assert.Equal(42, json.RootElement.GetProperty("resources").GetProperty("worldEntities").GetInt32()); + Assert.True(File.Exists(Path.Combine(directory, "checkpoint-dungeon.json"))); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static WorldLifecycleResourceSnapshot EmptyResources() => new( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0L, 0, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0d, 0d, null); + + private static string NewDirectory() + { + string path = Path.Combine(Path.GetTempPath(), "acdream-world-gate-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs index 9d9be640..387a1752 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs @@ -33,12 +33,14 @@ public class RuntimeOptionsRetailUiTests { ["ACDREAM_UI_PROBE_DUMP"] = "1", ["ACDREAM_UI_PROBE_SCRIPT"] = @"C:\tmp\ui-probe.txt", + ["ACDREAM_AUTOMATION_ARTIFACT_DIR"] = @"C:\tmp\world-gate", }; var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); Assert.True(opts.UiProbeDump); Assert.Equal(@"C:\tmp\ui-probe.txt", opts.UiProbeScript); + Assert.Equal(@"C:\tmp\world-gate", opts.AutomationArtifactDirectory); Assert.True(opts.UiProbeEnabled); } } diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index edc446aa..98b177b1 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -41,6 +41,7 @@ public sealed class RuntimeOptionsTests Assert.Null(opts.LegacyStreamRadius); Assert.False(opts.UiProbeDump); Assert.Null(opts.UiProbeScript); + Assert.Null(opts.AutomationArtifactDirectory); Assert.False(opts.UiProbeEnabled); Assert.False(opts.HasLiveCredentials); } diff --git a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs index f9d50060..337f0477 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs @@ -24,6 +24,32 @@ public sealed class RetailUiAutomationProbeTests => LastDrop = (targetList, targetCell, payload); } + private sealed class FakeRuntime : IRetailUiAutomationRuntime + { + public bool IsWorldReady { get; set; } + public bool IsWorldViewportVisible { get; set; } + public int PortalMaterializationCount { get; set; } + public List Checkpoints { get; } = new(); + public HashSet ScreenshotRequests { get; } = new(); + public HashSet CompletedScreenshots { get; } = new(); + + public bool TryWriteCheckpoint(string name, out string error) + { + Checkpoints.Add(name); + error = string.Empty; + return true; + } + + public bool TryRequestScreenshot(string name, out string error) + { + ScreenshotRequests.Add(name); + error = string.Empty; + return true; + } + + public bool IsScreenshotComplete(string name) => CompletedScreenshots.Contains(name); + } + private static (UiRoot root, UiItemList source, UiItemList target, SpyHandler handler, ClientObjectTable objects) RootWithTwoItemLists() { @@ -527,4 +553,86 @@ public sealed class RetailUiAutomationProbeTests File.Delete(path); } } + + [Fact] + public void ScriptRunner_worldLifecycleCommands_waitForCanonicalRuntimeState() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + var runtime = new FakeRuntime(); + var probe = new RetailUiAutomationProbe(root, objects); + string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt"); + File.WriteAllLines(path, + [ + "wait world-ready 1000", + "wait world-visible 1000", + "wait materialized 2 1000", + "checkpoint stable", + "screenshot stable 1000", + ]); + + try + { + using var runner = new RetailUiAutomationScriptRunner( + probe, + path, + dumpOnStart: false, + runtime: runtime); + + runner.Tick(0d); + Assert.False(runner.Completed); + + runtime.IsWorldReady = true; + runner.Tick(0.001d); + Assert.False(runner.Completed); + + runtime.IsWorldViewportVisible = true; + runtime.PortalMaterializationCount = 2; + runner.Tick(0.001d); + + Assert.Equal(["stable"], runtime.Checkpoints); + Assert.Contains("stable", runtime.ScreenshotRequests); + Assert.False(runner.Completed); + + runtime.CompletedScreenshots.Add("stable"); + runner.Tick(0.001d); + + Assert.True(runner.Completed); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ScriptRunner_worldReadyTimeout_stopsWithActionableLine() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + var runtime = new FakeRuntime(); + var probe = new RetailUiAutomationProbe(root, objects); + var logs = new List(); + string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt"); + File.WriteAllText(path, "wait world-ready 10"); + + try + { + using var runner = new RetailUiAutomationScriptRunner( + probe, + path, + dumpOnStart: false, + log: logs.Add, + runtime: runtime); + + runner.Tick(0d); + runner.Tick(0.011d); + + Assert.True(runner.Completed); + Assert.Contains(logs, line => + line.Contains("timed out waiting for world readiness", StringComparison.Ordinal)); + } + finally + { + File.Delete(path); + } + } } From b03371c03df08895b76d30ffe2cc4d6ace91218f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Jul 2026 22:39:34 +0200 Subject: [PATCH 04/98] test(runtime): add connected lifecycle gate --- tools/connected-world-lifecycle.route.txt | 67 ++++ tools/connected-world-reconnect.route.txt | 12 + tools/run-connected-world-lifecycle-gate.ps1 | 335 +++++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 tools/connected-world-lifecycle.route.txt create mode 100644 tools/connected-world-reconnect.route.txt create mode 100644 tools/run-connected-world-lifecycle-gate.ps1 diff --git a/tools/connected-world-lifecycle.route.txt b/tools/connected-world-lifecycle.route.txt new file mode 100644 index 00000000..72f019a7 --- /dev/null +++ b/tools/connected-world-lifecycle.route.txt @@ -0,0 +1,67 @@ +# Connected capped lifecycle route. Readiness and screenshots are driven by +# canonical runtime state; sleeps are only stationary sampling windows. + +wait world-ready 90000 +wait world-visible 30000 +sleep 30000 +checkpoint capped_login +screenshot capped_login 15000 + +# Dense outdoor island baseline. +command /teleloc 0x09040008 11.4 188.6 87.705 +wait materialized 1 90000 +wait world-visible 30000 +sleep 12000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 12000 +checkpoint aerlinthe_first +screenshot aerlinthe_first 15000 + +# World-edge streaming transition. +command /teleloc 0xF6820033 145.7 49.855 58.010 +wait materialized 2 90000 +wait world-visible 30000 +sleep 12000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 12000 +checkpoint rynthid + +# Facility Hub indoor cell: one-landblock collapse + exact EnvCell collision. +command /teleloc 0x8A020164 70.35 -40.66 -5.9 +wait materialized 3 90000 +wait world-visible 30000 +sleep 12000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 12000 +checkpoint facility_hub +screenshot facility_hub 15000 + +# Dungeon exit back to a normal outdoor near ring. +command /teleloc 0xA9B40019 84.0 7.1 94.005 +wait materialized 4 90000 +wait world-visible 30000 +sleep 12000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 12000 +checkpoint holtburg_after_dungeon +screenshot holtburg_after_dungeon 15000 + +# Same-location owner/resource comparison after outdoor/edge/dungeon churn. +command /teleloc 0x09040008 11.4 188.6 87.705 +wait materialized 5 90000 +wait world-visible 30000 +sleep 12000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 30000 +checkpoint aerlinthe_revisit +screenshot aerlinthe_revisit 15000 diff --git a/tools/connected-world-reconnect.route.txt b/tools/connected-world-reconnect.route.txt new file mode 100644 index 00000000..32f7dbf2 --- /dev/null +++ b/tools/connected-world-reconnect.route.txt @@ -0,0 +1,12 @@ +# Fresh-process reconnect gate. This session is uncapped so the same artifact +# records renderer throughput independently from the capped correctness run. + +wait world-ready 90000 +wait world-visible 30000 +sleep 30000 +input down MovementTurnRight +sleep 10000 +input up MovementTurnRight +sleep 10000 +checkpoint uncapped_reconnect +screenshot uncapped_reconnect 15000 diff --git a/tools/run-connected-world-lifecycle-gate.ps1 b/tools/run-connected-world-lifecycle-gate.ps1 new file mode 100644 index 00000000..861bd45b --- /dev/null +++ b/tools/run-connected-world-lifecycle-gate.ps1 @@ -0,0 +1,335 @@ +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [string]$Account = $env:ACDREAM_TEST_USER, + [string]$Password = $env:ACDREAM_TEST_PASS, + [switch]$SkipBuild, + [int]$SessionTimeoutSeconds = 420 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($Account)) { $Account = 'testaccount' } +if ([string]::IsNullOrWhiteSpace($Password)) { $Password = 'testpassword' } + +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$root = Join-Path $Repository "logs\connected-world-gate-$stamp" +$null = New-Item -ItemType Directory -Force -Path $root +$reportPath = Join-Path $root 'report.json' +$exe = Join-Path $Repository 'src\AcDream.App\bin\Release\net10.0\AcDream.App.exe' +$failures = [System.Collections.Generic.List[string]]::new() +$warnings = [System.Collections.Generic.List[string]]::new() +$sessions = [System.Collections.Generic.List[object]]::new() +$startedUtc = [DateTime]::UtcNow + +function Get-PatternCount([string]$Path, [string]$Pattern) { + if (-not (Test-Path -LiteralPath $Path)) { return 0 } + return @(Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue | + Select-String -SimpleMatch $Pattern).Count +} + +function Wait-ForPattern( + [Diagnostics.Process]$Client, + [string]$Path, + [string]$Pattern, + [int]$TimeoutSeconds) +{ + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ([DateTime]::UtcNow -lt $deadline) { + $Client.Refresh() + if ($Client.HasExited) { + throw "client exited with code $($Client.ExitCode) while waiting for '$Pattern'" + } + if ((Get-PatternCount $Path $Pattern) -gt 0) { return } + Start-Sleep -Milliseconds 250 + } + throw "timed out after $TimeoutSeconds seconds waiting for '$Pattern'" +} + +function Close-ClientGracefully([Diagnostics.Process]$Client) { + $Client.Refresh() + if ($Client.HasExited) { return $true } + if (-not $Client.CloseMainWindow()) { return $false } + if (-not $Client.WaitForExit(30000)) { return $false } + $Client.WaitForExit() + return $true +} + +function Test-Png([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $info = Get-Item -LiteralPath $Path + if ($info.Length -lt 1024) { return $false } + $bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 8) { return $false } + $signature = @(137, 80, 78, 71, 13, 10, 26, 10) + for ($i = 0; $i -lt $signature.Count; $i++) { + if ($bytes[$i] -ne $signature[$i]) { return $false } + } + return $true +} + +function Add-LogFailures([string]$Label, [string]$Stdout, [string]$Stderr) { + $fatalPatterns = @( + 'event=invariant-failure', + 'Unhandled exception', + 'AccessViolation', + 'OutOfMemoryException', + 'WeenieError', + 'device removed', + 'GPU reset', + 'live: disconnected', + 'screenshot-failed' + ) + foreach ($pattern in $fatalPatterns) { + $count = (Get-PatternCount $Stdout $pattern) + (Get-PatternCount $Stderr $pattern) + if ($count -gt 0) { $failures.Add("${Label}: '$pattern' appeared $count time(s)") } + } + + $missingLandblocks = Get-PatternCount $Stdout 'LandblockLoader.Load returned null' + if ($missingLandblocks -gt 0) { + $warnings.Add("${Label}: $missingLandblocks expected world-edge landblock miss(es)") + } +} + +function Read-Checkpoints([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { return @() } + return @(Get-Content -LiteralPath $Path | ForEach-Object { $_ | ConvertFrom-Json }) +} + +function Validate-Checkpoint([string]$SessionLabel, [object]$Checkpoint) { + $name = $Checkpoint.name + $reveal = $Checkpoint.reveal + $resources = $Checkpoint.resources + if (-not $reveal.readiness.isReady) { + $failures.Add("${SessionLabel}/${name}: reveal was not ready") + } + if (-not $reveal.worldViewportObserved) { + $failures.Add("${SessionLabel}/${name}: normal world viewport was never observed") + } + if ($reveal.invariantFailureCount -ne 0) { + $failures.Add("${SessionLabel}/${name}: reveal has $($reveal.invariantFailureCount) invariant failure(s)") + } + if (-not $reveal.readiness.isUnhydratable) { + if (-not $reveal.readiness.isRenderNeighborhoodReady) { + $failures.Add("${SessionLabel}/${name}: render neighborhood was not ready") + } + if (-not $reveal.readiness.areCompositeTexturesReady) { + $failures.Add("${SessionLabel}/${name}: composite textures were not ready") + } + if (-not $reveal.readiness.isCollisionReady) { + $failures.Add("${SessionLabel}/${name}: collision was not ready") + } + } + if ($resources.pendingLiveTeardowns -ne 0) { + $failures.Add("${SessionLabel}/${name}: $($resources.pendingLiveTeardowns) live teardown(s) pending") + } + if ($resources.pendingLandblockRetirements -ne 0) { + $failures.Add("${SessionLabel}/${name}: $($resources.pendingLandblockRetirements) landblock retirement(s) pending") + } + if ($resources.stagedMeshUploads -ne 0) { + $failures.Add("${SessionLabel}/${name}: $($resources.stagedMeshUploads) staged mesh upload(s) remain at stable checkpoint") + } + if ($resources.compositeWarmupPending -ne 0) { + $failures.Add("${SessionLabel}/${name}: $($resources.compositeWarmupPending) composite warmup item(s) remain") + } + if ($resources.loadedLandblocks -le 0 -or $resources.worldEntities -le 0) { + $failures.Add("${SessionLabel}/${name}: world ownership is empty at a visible checkpoint") + } + if ($null -eq $resources.lastFrameProfile) { + $failures.Add("${SessionLabel}/${name}: no frame-profiler sample was available") + } +} + +function Invoke-Session( + [string]$Label, + [string]$RoutePath, + [bool]$Uncapped, + [string[]]$ExpectedCheckpoints, + [string[]]$ExpectedScreenshots) +{ + $sessionDir = Join-Path $root $Label + $artifactDir = Join-Path $sessionDir 'artifacts' + $null = New-Item -ItemType Directory -Force -Path $artifactDir + $stdout = Join-Path $sessionDir 'stdout.log' + $stderr = Join-Path $sessionDir 'stderr.log' + $timeline = Join-Path $artifactDir 'world-lifecycle.checkpoints.jsonl' + $client = $null + $graceful = $false + $exitCode = $null + $elapsed = [Diagnostics.Stopwatch]::StartNew() + + $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" + $env:ACDREAM_LIVE = '1' + $env:ACDREAM_TEST_HOST = '127.0.0.1' + $env:ACDREAM_TEST_PORT = '9000' + $env:ACDREAM_TEST_USER = $Account + $env:ACDREAM_TEST_PASS = $Password + $env:ACDREAM_RETAIL_UI = '1' + $env:ACDREAM_FRAME_PROF = '1' + $env:ACDREAM_UNCAPPED_RENDER = if ($Uncapped) { '1' } else { $null } + $env:ACDREAM_DEVTOOLS = '0' + $env:ACDREAM_UI_PROBE_DUMP = '0' + $env:ACDREAM_UI_PROBE_SCRIPT = $RoutePath + $env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $artifactDir + $env:ACDREAM_DUMP_MOVE_TRUTH = $null + $env:ACDREAM_WB_DIAG = $null + + try { + $client = Start-Process -FilePath $exe -WorkingDirectory $Repository ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru + Wait-ForPattern $client $stdout '[UI-PROBE] UI probe script complete' $SessionTimeoutSeconds + + $client.Refresh() + $processSample = [pscustomobject][ordered]@{ + WorkingSetMiB = [Math]::Round($client.WorkingSet64 / 1MB, 1) + PrivateMiB = [Math]::Round($client.PrivateMemorySize64 / 1MB, 1) + HandleCount = $client.HandleCount + ThreadCount = $client.Threads.Count + WindowTitle = $client.MainWindowTitle + } + + $checkpoints = Read-Checkpoints $timeline + if ($checkpoints.Count -ne $ExpectedCheckpoints.Count) { + $failures.Add("${Label}: expected $($ExpectedCheckpoints.Count) checkpoints, found $($checkpoints.Count)") + } + foreach ($name in $ExpectedCheckpoints) { + $matches = @($checkpoints | Where-Object { $_.name -eq $name }) + if ($matches.Count -ne 1) { + $failures.Add("${Label}: expected one checkpoint '$name', found $($matches.Count)") + } + } + foreach ($checkpoint in $checkpoints) { Validate-Checkpoint $Label $checkpoint } + + foreach ($name in $ExpectedScreenshots) { + $png = Join-Path $artifactDir "screenshots\$name.png" + if (-not (Test-Png $png)) { $failures.Add("${Label}: missing or invalid screenshot '$png'") } + } + + Add-LogFailures $Label $stdout $stderr + $graceful = Close-ClientGracefully $client + $client.Refresh() + if ($client.HasExited) { $exitCode = [int]$client.ExitCode } + if (-not $graceful) { $failures.Add("${Label}: client did not close through WM_CLOSE") } + if ($null -ne $exitCode -and $exitCode -ne 0) { + $failures.Add("${Label}: client exited with code $exitCode") + } + + $session = [pscustomobject][ordered]@{ + Label = $Label + Uncapped = $Uncapped + ElapsedSeconds = [Math]::Round($elapsed.Elapsed.TotalSeconds, 3) + GracefulExit = $graceful + ExitCode = $exitCode + Process = $processSample + Checkpoints = @($checkpoints) + Stdout = $stdout + Stderr = $stderr + ArtifactDirectory = $artifactDir + } + $sessions.Add($session) + return $session + } + catch { + $failures.Add("${Label}: $($_.Exception.Message)") + return $null + } + finally { + if ($null -ne $client) { + $client.Refresh() + if (-not $client.HasExited) { + $graceful = Close-ClientGracefully $client + if (-not $graceful -and -not $client.HasExited) { + $failures.Add("${Label}: required forced termination after WM_CLOSE timeout") + Stop-Process -Id $client.Id -Force + $client.WaitForExit(10000) + } + } + $client.Dispose() + } + } +} + +function Add-SameLocationGates([object]$CappedSession) { + if ($null -eq $CappedSession) { return } + $first = @($CappedSession.Checkpoints | Where-Object { $_.name -eq 'aerlinthe_first' }) | Select-Object -First 1 + $revisit = @($CappedSession.Checkpoints | Where-Object { $_.name -eq 'aerlinthe_revisit' }) | Select-Object -First 1 + if ($null -eq $first -or $null -eq $revisit) { return } + + $managedLimit = [Math]::Max(256MB, [double]$first.resources.managedBytes * 0.40) + $gpuLimit = [Math]::Max(512MB, [double]$first.resources.trackedGpuBytes * 0.50) + if (($revisit.resources.managedBytes - $first.resources.managedBytes) -gt $managedLimit) { + $failures.Add("Aerlinthe revisit: managed memory grew beyond the connected gate tolerance") + } + if (($revisit.resources.trackedGpuBytes - $first.resources.trackedGpuBytes) -gt $gpuLimit) { + $failures.Add("Aerlinthe revisit: tracked GPU memory grew beyond the connected gate tolerance") + } + + foreach ($property in @('particleOwners', 'effectOwners', 'lightOwners', 'scriptOwners', 'compositeTextureOwners', 'particleTextureOwners')) { + $before = [double]$first.resources.$property + $after = [double]$revisit.resources.$property + $limit = [Math]::Max(64.0, $before * 0.50) + if (($after - $before) -gt $limit) { + $failures.Add("Aerlinthe revisit: owner '$property' grew $before -> $after") + } + } +} + +if (@(Get-Process -Name AcDream.App -ErrorAction SilentlyContinue).Count -gt 0) { + throw 'an AcDream.App client is already running; close it gracefully before the gate' +} +if (@(Get-NetUDPEndpoint -LocalPort 9000 -ErrorAction SilentlyContinue).Count -eq 0) { + throw 'local ACE is not listening on UDP port 9000' +} + +if (-not $SkipBuild) { + & dotnet build (Join-Path $Repository 'AcDream.slnx') -c Release --no-restore + if ($LASTEXITCODE -ne 0) { throw "Release build failed with exit code $LASTEXITCODE" } +} +if (-not (Test-Path -LiteralPath $exe)) { throw "client executable not found: $exe" } + +$capped = Invoke-Session ` + 'capped' ` + (Join-Path $Repository 'tools\connected-world-lifecycle.route.txt') ` + $false ` + @('capped_login', 'aerlinthe_first', 'rynthid', 'facility_hub', 'holtburg_after_dungeon', 'aerlinthe_revisit') ` + @('capped_login', 'aerlinthe_first', 'facility_hub', 'holtburg_after_dungeon', 'aerlinthe_revisit') + +Add-SameLocationGates $capped + +# The second process is both the session-teardown/reconnect gate and the +# uncapped renderer sample. A short pause lets ACE finish releasing the first +# session before the same account logs in again. +Start-Sleep -Seconds 3 +$null = Invoke-Session ` + 'uncapped-reconnect' ` + (Join-Path $Repository 'tools\connected-world-reconnect.route.txt') ` + $true ` + @('uncapped_reconnect') ` + @('uncapped_reconnect') + +$report = [pscustomobject][ordered]@{ + Passed = $failures.Count -eq 0 + StartedUtc = $startedUtc.ToString('O') + FinishedUtc = [DateTime]::UtcNow.ToString('O') + Commit = (& git -C $Repository rev-parse HEAD).Trim() + SourceStatus = @(& git -C $Repository status --short) + SessionName = $env:SESSIONNAME + VideoControllers = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | + ForEach-Object { [pscustomobject]@{ + Name = $_.Name + DriverVersion = $_.DriverVersion + AdapterRam = $_.AdapterRAM + } }) + Failures = @($failures) + Warnings = @($warnings) + Sessions = @($sessions) +} +$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8 + +Write-Output "REPORT=$reportPath" +Write-Output "RESULT=$(if ($report.Passed) { 'PASS' } else { 'FAIL' })" +foreach ($failure in $failures) { Write-Output "FAILURE=$failure" } +foreach ($warning in $warnings) { Write-Output "WARNING=$warning" } + +if ($failures.Count -gt 0) { exit 1 } From 68578fa5fa2e991ec32e1096cf8d23b49148386a Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Jul 2026 23:31:23 +0200 Subject: [PATCH 05/98] fix(net): honor retail graceful logout handshake Send the active character id, drain until the authoritative server confirmation, then emit retail's zero-sequence connection disconnect with the negotiated receiver iteration. The connected gate now waits for ACE to remove the exact UDP session before reconnecting, eliminating fixed-delay races. --- .../retail-divergence-register.md | 1 + .../2026-05-10-phase-m-opcode-matrix.md | 2 +- ...07-20-retail-graceful-logout-pseudocode.md | 70 +++++++++ .../deepdives/r08-network-protocol-atlas.md | 7 +- .../Messages/CharacterLogOff.cs | 38 +++++ .../Packets/TransportDisconnect.cs | 31 ++++ src/AcDream.Core.Net/WorldSession.cs | 133 ++++++++++++++---- .../Messages/CharacterLogOffTests.cs | 36 +++++ .../Packets/TransportDisconnectTests.cs | 26 ++++ tools/run-connected-world-lifecycle-gate.ps1 | 69 ++++++++- 10 files changed, 372 insertions(+), 41 deletions(-) create mode 100644 docs/research/2026-07-20-retail-graceful-logout-pseudocode.md create mode 100644 src/AcDream.Core.Net/Messages/CharacterLogOff.cs create mode 100644 src/AcDream.Core.Net/Packets/TransportDisconnect.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CharacterLogOffTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Packets/TransportDisconnectTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 231a8856..eff9cfe8 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -102,6 +102,7 @@ accepted-divergence entries (#96, #49, #50). | AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.App/Input/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-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 | Native-window close performs retail's complete character-logoff handshake and transport disconnect, then exits the process instead of retaining the authenticated logon connection and returning to character selection. acdream has one active `ReceiverData` equivalent, so `ClientNet::LogOffServer`'s per-receiver disconnect loop sends one header. | `src/AcDream.Core.Net/WorldSession.cs` (`Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | The application currently auto-selects one character and has no character-selection presentation/state owner to receive the returned authenticated session. Close must still release ACE immediately and in retail order. | Closing is correct, but an eventual in-client "log off character" action cannot reuse this process-exit path; it needs an App/session transition that retains the socket after server `0xF653` rather than calling `Dispose`. | `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | --- diff --git a/docs/research/2026-05-10-phase-m-opcode-matrix.md b/docs/research/2026-05-10-phase-m-opcode-matrix.md index a375c230..98310284 100644 --- a/docs/research/2026-05-10-phase-m-opcode-matrix.md +++ b/docs/research/2026-05-10-phase-m-opcode-matrix.md @@ -168,7 +168,7 @@ In-scope: 51. Implemented in acdream: 21. Phase M target delta: 30. | 0xEA60 | inbound | AdminEnvirons | `CPlayerSystem::Handle_Admin__Environs` | – | B | P+W | P+W | Fog presets / sound cues | | 0xF625 | inbound | ObjDescEvent | `SmartBox::HandleObjDescEvent` | PB | B | P+W | P+W | Per-entity appearance update | | 0xF643 | inbound | CharacterCreateResponse | – | PB | B | – | –defer:char-creation | Char-creation flow not yet built | -| 0xF653 | outbound | CharacterLogOff | – | PB | P | B | PB+W | Sent on Dispose; ACE accepts | +| 0xF653 | both | CharacterLogOff | – | PB | P | B | PB+W | Request carries active character GUID; opcode-only server confirmation gates transport disconnect | | 0xF655 | both | CharacterDelete | – | PB | P | – | –defer:char-mgmt | Char-management UI deferred | | 0xF656 | outbound | CharacterCreate | – | PB | P | – | –defer:char-creation | Char-creation flow not yet built | | 0xF657 | outbound | CharacterEnterWorld | `CM_Login::SendNotice_BeginEnterWorld` [^m-2] | PB | P | B | PB+W | Built; sent during handshake | diff --git a/docs/research/2026-07-20-retail-graceful-logout-pseudocode.md b/docs/research/2026-07-20-retail-graceful-logout-pseudocode.md new file mode 100644 index 00000000..363acd7a --- /dev/null +++ b/docs/research/2026-07-20-retail-graceful-logout-pseudocode.md @@ -0,0 +1,70 @@ +# Retail graceful character logout + +## Oracle + +- `Proto_UI::LogOffCharacter` `0x00546A20` +- `CPlayerSystem::RequestLogOff` `0x00562DD0` +- inbound login-message dispatch at `0x0055C963` +- `CPlayerSystem::ExecuteLogOff` `0x0055D780` +- `ClientNet::ExitWorldDisconnect` `0x00541E00` +- `ClientNet::LogOffServer` `0x00543EF0` +- `SharedNet::SendOptionalHeader` `0x00543160` +- `CPlayerSystem::LogOffCharacter` `0x00563520` + +Cross-checks: + +- ACE `CharacterHandler.CharacterLogOff` begins asynchronous player removal. +- ACE `Session.SendFinalLogOffMessages` sends the opcode-only `CharacterLogOff` + confirmation only after the player no longer owns a landblock, then returns + the session to `AuthConnected`. +- Holtburger currently sends opcode-only and disconnects immediately. Its own + comment records that AC normally waits for the server response. That shortcut + is not the retail oracle and was the source of acdream's reconnect race. + +## Pseudocode + +```text +LogOffCharacter(force): + save player module + if force: + ExecuteLogOff() + return + if no interaction is pending: + RequestLogOff() + else: + print "Logging off..." + remember that logout must begin after the interaction + +RequestLogOff(): + print "Logging off..." + send_to_logon([0xF653, active_player_id]) + logOffRequested = true + logOffRequestTime = now + 3 seconds + if player is PK: + logOffRequestTime += 20 seconds + +on inbound login message 0xF653: + ExecuteLogOff() + +ExecuteLogOff(): + clear player-login state + for every live ReceiverData connection: + send a cleartext, empty DISCONNECT optional header + use sequence 0 plus that receiver's network id and iteration + disconnect the world connection + clear the active player id +``` + +## acdream close-only adaptation + +acdream does not yet return to character selection when its native window is +closed. It retains retail's important ordering: send the eight-byte request, +continue receiving until the server's opcode-only confirmation arrives, then +send the transport `DISCONNECT` and release the socket. World callbacks are not +dispatched while shutdown drains the raw receive queue because their App owners +may already be tearing down. A bounded 35-second wait covers retail's additional +20-second PK logout delay and is only a transport safety +limit; the normal path advances on the authoritative confirmation, not elapsed +time. The connected gate separately observes ACE accepting the transport header +before opening a replacement process; ACE removes the authenticated session on +its world-manager tick, after the UDP sender has already completed. diff --git a/docs/research/deepdives/r08-network-protocol-atlas.md b/docs/research/deepdives/r08-network-protocol-atlas.md index 951ee108..6755ea65 100644 --- a/docs/research/deepdives/r08-network-protocol-atlas.md +++ b/docs/research/deepdives/r08-network-protocol-atlas.md @@ -181,7 +181,7 @@ is the opcode itself, followed immediately by the payload. | 0xF619 | PositionAndMovement | GM | S→C | unhandled | P4 | Ghost opcode — declared but never fired by ACE. | | 0xF625 | ObjDescEvent | GM | S→C | unhandled | P1 | `u32 guid, ObjectDescription` — full re-send of visual description (body parts, textures, palettes). Critical for seeing other players' gear changes. | | 0xF643 | CharacterCreateResponse / CharacterRestoreResponse | GM | S→C | unhandled | P0+ | `u32 responseCode` — login-phase, only relevant after char-create. Same opcode, two semantics (disambiguated by session state). | -| 0xF653 | CharacterLogOff | GM | bi | partial | P0 | No payload. Client sends before Disconnect to release the character lock immediately. acdream sends it from `Dispose`. | +| 0xF653 | CharacterLogOff | GM | bi | parsed | P0 | Client request is opcode + active character GUID; server confirmation is opcode-only. acdream waits for confirmation before transport Disconnect. | | 0xF655 | CharacterDelete | GM | bi | unhandled | P4 | `u32 slot` — char-select-screen deletion. | | 0xF656 | CharacterCreate | GM | C→S | unhandled | P4 | Full character-creation blob — heritage, gender, starting town, appearance. | | 0xF657 | CharacterEnterWorld | GM | C→S | done | P0 | `u32 characterGuid, string16L account`. Built by `Messages/CharacterEnterWorld.cs`. | @@ -859,8 +859,9 @@ All the P3/P4 tails. Ship when we need them, not before. - **0x00A3/0x00A4 FellowshipQuit/Dismiss** — same pairing; C→S in GameAction, S→C in GameEvent. - **0xF653 CharacterLogOff** — bi-directional, same opcode, no - envelope. Client sends to request logout; server sends the echo - before Disconnect. + envelope. Retail's client request carries the active character GUID; + the server confirmation is opcode-only. The client waits for that + confirmation before disconnecting the world transport. - **0x01A8 MagicRemoveSpell** — C→S is GameAction, S→C is GameEvent. - **0xF7DE TurbineChat** — bi-directional GameMessage. Blob layout includes a `ChatNetworkBlobType` discriminant (EVENT_BINARY=1, diff --git a/src/AcDream.Core.Net/Messages/CharacterLogOff.cs b/src/AcDream.Core.Net/Messages/CharacterLogOff.cs new file mode 100644 index 00000000..d92a50e0 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharacterLogOff.cs @@ -0,0 +1,38 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Messages; + +/// +/// Retail character-logoff request and server confirmation (opcode +/// 0xF653). +/// +/// +/// Retail Proto_UI::LogOffCharacter at 0x00546A20 sends the +/// opcode followed by the active character id. The server confirmation uses +/// the same opcode with no trailing payload. CPlayerSystem::RequestLogOff +/// at 0x00562DD0 waits for that confirmation; its inbound dispatch calls +/// CPlayerSystem::ExecuteLogOff at 0x0055D780, which only then +/// disconnects the world connection. +/// +public static class CharacterLogOff +{ + public const uint Opcode = 0xF653u; + + /// Builds retail's eight-byte client request. + public static byte[] BuildRequestBody(uint characterId) + { + var writer = new PacketWriter(8); + writer.WriteUInt32(Opcode); + writer.WriteUInt32(characterId); + return writer.ToArray(); + } + + /// + /// Returns whether a complete game-message body is the server's logoff + /// confirmation. ACE emits the canonical four-byte opcode-only form. + /// + public static bool IsConfirmation(ReadOnlySpan body) => + body.Length >= sizeof(uint) && + BinaryPrimitives.ReadUInt32LittleEndian(body) == Opcode; +} diff --git a/src/AcDream.Core.Net/Packets/TransportDisconnect.cs b/src/AcDream.Core.Net/Packets/TransportDisconnect.cs new file mode 100644 index 00000000..42e13623 --- /dev/null +++ b/src/AcDream.Core.Net/Packets/TransportDisconnect.cs @@ -0,0 +1,31 @@ +namespace AcDream.Core.Net.Packets; + +/// +/// Builds retail's connection-level disconnect packet. +/// +/// +/// ClientNet::LogOffServer at 0x00543EF0 creates the +/// 0x8000 optional header and sends it through +/// SharedNet::SendOptionalHeader at 0x00543160. That path +/// zero-initializes the packet sequence and copies the receiver's network id +/// and iteration into the fixed header. The disconnect is cleartext and has +/// no body. +/// +public static class TransportDisconnect +{ + public static byte[] Build(ushort networkId, ushort iteration) + { + var header = new PacketHeader + { + Sequence = 0, + Flags = PacketHeaderFlags.Disconnect, + Id = networkId, + Iteration = iteration, + }; + + return PacketCodec.Encode( + header, + ReadOnlySpan.Empty, + outboundIsaac: null); + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index b5e27ad8..fa9a3366 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -28,10 +28,9 @@ namespace AcDream.Core.Net; /// /// /// -/// Not yet provided (deferred): ACK pump, retransmit handling, -/// delete-object processing, position updates, chat, disconnect detection. -/// The current client is one-shot — connect, enter the world, stream -/// events for a few seconds, let the test harness tear it down. +/// Still deferred: retransmit handling and unsolicited-disconnect +/// recovery. ACKs, world updates, chat, and retail-ordered graceful logout +/// are live. /// /// public sealed class WorldSession : IDisposable @@ -627,6 +626,7 @@ public sealed class WorldSession : IDisposable private IsaacRandom? _inboundIsaac; private IsaacRandom? _outboundIsaac; private ushort _sessionClientId; + private ushort _sessionIteration; private uint _clientPacketSequence; private uint _fragmentSequence = 1; @@ -639,6 +639,9 @@ public sealed class WorldSession : IDisposable private ushort _serverControlSequence; private ushort _teleportSequence; private ushort _forcePositionSequence; + private uint _activeCharacterId; + private int _characterLogOffConfirmed; + private int _disposeStarted; // Phase A.3: background receive thread buffers raw UDP datagrams into // a channel so the render thread never blocks on socket I/O. @@ -739,6 +742,10 @@ public sealed class WorldSession : IDisposable _inboundIsaac = new IsaacRandom(serverSeedBytes); _outboundIsaac = new IsaacRandom(clientSeedBytes); _sessionClientId = (ushort)opt.ConnectRequestClientId; + // SharedNet::SendOptionalHeader @ 0x00543160 copies this ReceiverData + // generation into connection-level control packets, including the + // final disconnect. ACE currently emits iteration 1. + _sessionIteration = cr.Header.Iteration; _clientPacketSequence = 2; byte[] crBody = new byte[8]; @@ -772,6 +779,7 @@ public sealed class WorldSession : IDisposable var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); var chosen = Characters.Characters[characterIndex]; + _activeCharacterId = chosen.Id; Transition(State.EnteringWorld); SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody()); @@ -910,7 +918,10 @@ public sealed class WorldSession : IDisposable return true; } - private void ProcessDatagram(byte[] bytes, List? opcodesOut = null) + private void ProcessDatagram( + byte[] bytes, + List? opcodesOut = null, + bool dispatchWorldEvents = true) { var dec = PacketCodec.TryDecode(bytes, _inboundIsaac); if (!dec.IsOk) return; @@ -954,6 +965,19 @@ public sealed class WorldSession : IDisposable uint op = BinaryPrimitives.ReadUInt32LittleEndian(body); opcodesOut?.Add(op); + // Retail waits for the server's opcode-only CharacterLogOff echo + // before tearing down the world connection. Record it even while + // Dispose is draining the raw receive queue without dispatching + // world callbacks to owners that are already shutting down. + if (CharacterLogOff.IsConfirmation(body)) + { + Interlocked.Exchange(ref _characterLogOffConfirmed, 1); + continue; + } + + if (!dispatchWorldEvents) + continue; + if (op == CharacterList.Opcode && Characters is null) { try { Characters = CharacterList.Parse(body); } @@ -1915,41 +1939,48 @@ public sealed class WorldSession : IDisposable } /// - /// Graceful shutdown: tell the server we're leaving so it releases the - /// character lock immediately instead of waiting 60s for the session to - /// time out. Pattern from - /// references/holtburger/crates/holtburger-core/src/client/commands.rs - /// lines 879-892: send CharacterLogOff game message (opcode - /// 0xF653, no payload) then send a bare DISCONNECT control - /// packet (header flag 0x8000, no payload). + /// Graceful shutdown: request character logoff, wait for the server's + /// authoritative 0xF653 confirmation, and only then disconnect the + /// transport. This is retail's CPlayerSystem::RequestLogOff to + /// inbound confirmation to ExecuteLogOff ordering. It prevents a + /// replacement session from racing the old character's asynchronous + /// removal on ACE. /// public void Dispose() { + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + return; + if (CurrentState == State.InWorld) { try { - // Tell ACE "player is leaving the world" so it cleans up - // the character immediately. - var logoff = new Packets.PacketWriter(8); - logoff.WriteUInt32(0xF653u); // CharacterLogOff opcode - SendGameMessage(logoff.ToArray()); + // Proto_UI::LogOffCharacter @ 0x00546A20 sends the active id. + Interlocked.Exchange(ref _characterLogOffConfirmed, 0); + SendGameMessage(CharacterLogOff.BuildRequestBody(_activeCharacterId)); + Console.WriteLine( + $"[session] graceful logout requested character=0x{_activeCharacterId:X8}"); - // Tell the transport layer "close this session." - var disconnectHeader = new PacketHeader - { - Sequence = _clientPacketSequence++, - Flags = PacketHeaderFlags.Disconnect, - Id = _sessionClientId, - }; - byte[] disconnectPacket = PacketCodec.Encode( - disconnectHeader, ReadOnlySpan.Empty, outboundIsaac: null); - _net.Send(disconnectPacket); + if (WaitForCharacterLogOffConfirmation(TimeSpan.FromSeconds(35))) + Console.WriteLine("[session] graceful logout confirmed"); + else + Console.Error.WriteLine( + "[session] graceful logout confirmation timed out; disconnecting transport"); } - catch + catch (Exception error) { - // Best-effort — if the socket is already dead, eat the - // exception and let Dispose finish cleaning up. + Console.Error.WriteLine( + $"[session] graceful logout failed: {error.Message}"); + } + + try + { + SendTransportDisconnect(); + } + catch (Exception error) + { + Console.Error.WriteLine( + $"[session] transport disconnect failed: {error.Message}"); } } @@ -1961,5 +1992,47 @@ public sealed class WorldSession : IDisposable _netCancel.Dispose(); _net.Dispose(); + Transition(State.Disconnected); + } + + private bool WaitForCharacterLogOffConfirmation(TimeSpan timeout) + { + using var timeoutSource = new CancellationTokenSource(timeout); + + try + { + while (Volatile.Read(ref _characterLogOffConfirmed) == 0) + { + while (_inboundQueue.Reader.TryRead(out byte[]? bytes)) + { + ProcessDatagram(bytes, dispatchWorldEvents: false); + if (Volatile.Read(ref _characterLogOffConfirmed) != 0) + return true; + } + + _inboundQueue.Reader.WaitToReadAsync(timeoutSource.Token) + .AsTask() + .GetAwaiter() + .GetResult(); + } + } + catch (OperationCanceledException) + { + return false; + } + catch (ChannelClosedException) + { + return false; + } + + return true; + } + + private void SendTransportDisconnect() + { + byte[] disconnectPacket = TransportDisconnect.Build( + _sessionClientId, + _sessionIteration); + _net.Send(disconnectPacket); } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterLogOffTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterLogOffTests.cs new file mode 100644 index 00000000..41af606c --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterLogOffTests.cs @@ -0,0 +1,36 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class CharacterLogOffTests +{ + [Fact] + public void BuildRequestBody_MatchesRetailOpcodeThenCharacterId() + { + byte[] body = CharacterLogOff.BuildRequestBody(0x5000000Au); + + Assert.Equal(8, body.Length); + Assert.Equal( + CharacterLogOff.Opcode, + BinaryPrimitives.ReadUInt32LittleEndian(body)); + Assert.Equal( + 0x5000000Au, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); + } + + [Fact] + public void IsConfirmation_AcceptsServerOpcodeOnlyBody() + { + byte[] body = BitConverter.GetBytes(CharacterLogOff.Opcode); + + Assert.True(CharacterLogOff.IsConfirmation(body)); + } + + [Fact] + public void IsConfirmation_RejectsTruncatedOrDifferentMessage() + { + Assert.False(CharacterLogOff.IsConfirmation([0x53, 0xF6, 0x00])); + Assert.False(CharacterLogOff.IsConfirmation(BitConverter.GetBytes(0xF654u))); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Packets/TransportDisconnectTests.cs b/tests/AcDream.Core.Net.Tests/Packets/TransportDisconnectTests.cs new file mode 100644 index 00000000..52c71fbf --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Packets/TransportDisconnectTests.cs @@ -0,0 +1,26 @@ +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Tests.Packets; + +public sealed class TransportDisconnectTests +{ + [Fact] + public void Build_MatchesRetailOptionalHeaderShape() + { + byte[] datagram = TransportDisconnect.Build(0x1234, 0x0001); + + Assert.Equal(PacketHeader.Size, datagram.Length); + + PacketHeader header = PacketHeader.Unpack(datagram); + Assert.Equal(0u, header.Sequence); + Assert.Equal(PacketHeaderFlags.Disconnect, header.Flags); + Assert.Equal((ushort)0x1234, header.Id); + Assert.Equal((ushort)0x0001, header.Iteration); + Assert.Equal((ushort)0, header.DataSize); + + PacketCodec.PacketDecodeResult decoded = PacketCodec.TryDecode( + datagram, + inboundIsaac: null); + Assert.True(decoded.IsOk); + } +} diff --git a/tools/run-connected-world-lifecycle-gate.ps1 b/tools/run-connected-world-lifecycle-gate.ps1 index 861bd45b..f4360265 100644 --- a/tools/run-connected-world-lifecycle-gate.ps1 +++ b/tools/run-connected-world-lifecycle-gate.ps1 @@ -3,6 +3,7 @@ param( [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, [string]$Account = $env:ACDREAM_TEST_USER, [string]$Password = $env:ACDREAM_TEST_PASS, + [string]$AceLogPath = 'C:\ACE\Server\ACE_Log.txt', [switch]$SkipBuild, [int]$SessionTimeoutSeconds = 420 ) @@ -47,11 +48,44 @@ function Wait-ForPattern( throw "timed out after $TimeoutSeconds seconds waiting for '$Pattern'" } +function Wait-ForFileAppendPattern( + [string]$Path, + [long]$StartOffset, + [string]$Pattern, + [int]$TimeoutSeconds) +{ + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ([DateTime]::UtcNow -lt $deadline) { + if (Test-Path -LiteralPath $Path) { + $stream = [System.IO.File]::Open( + $Path, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::ReadWrite) + try { + if ($stream.Length -gt $StartOffset) { + $null = $stream.Seek($StartOffset, [System.IO.SeekOrigin]::Begin) + $reader = [System.IO.StreamReader]::new($stream) + try { $appended = $reader.ReadToEnd() } + finally { $reader.Dispose() } + if ([Text.RegularExpressions.Regex]::IsMatch( + $appended, + $Pattern, + [Text.RegularExpressions.RegexOptions]::CultureInvariant)) { return } + } + } + finally { $stream.Dispose() } + } + Start-Sleep -Milliseconds 100 + } + throw "timed out after $TimeoutSeconds seconds waiting for ACE log '$Pattern'" +} + function Close-ClientGracefully([Diagnostics.Process]$Client) { $Client.Refresh() if ($Client.HasExited) { return $true } if (-not $Client.CloseMainWindow()) { return $false } - if (-not $Client.WaitForExit(30000)) { return $false } + if (-not $Client.WaitForExit(45000)) { return $false } $Client.WaitForExit() return $true } @@ -79,7 +113,10 @@ function Add-LogFailures([string]$Label, [string]$Stdout, [string]$Stderr) { 'device removed', 'GPU reset', 'live: disconnected', - 'screenshot-failed' + 'screenshot-failed', + 'graceful logout confirmation timed out', + 'graceful logout failed', + 'transport disconnect failed' ) foreach ($pattern in $fatalPatterns) { $count = (Get-PatternCount $Stdout $pattern) + (Get-PatternCount $Stderr $pattern) @@ -155,9 +192,11 @@ function Invoke-Session( $stderr = Join-Path $sessionDir 'stderr.log' $timeline = Join-Path $artifactDir 'world-lifecycle.checkpoints.jsonl' $client = $null + $clientPort = $null $graceful = $false $exitCode = $null $elapsed = [Diagnostics.Stopwatch]::StartNew() + $aceLogOffset = (Get-Item -LiteralPath $AceLogPath).Length $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" $env:ACDREAM_LIVE = '1' @@ -180,6 +219,12 @@ function Invoke-Session( -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru Wait-ForPattern $client $stdout '[UI-PROBE] UI probe script complete' $SessionTimeoutSeconds + $clientPort = @(Get-NetUDPEndpoint -OwningProcess $client.Id -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty LocalPort) + if ($clientPort.Count -ne 1) { + throw "could not resolve the client's UDP endpoint for ACE disconnect verification" + } + $client.Refresh() $processSample = [pscustomobject][ordered]@{ WorkingSetMiB = [Math]::Round($client.WorkingSet64 / 1MB, 1) @@ -206,7 +251,6 @@ function Invoke-Session( if (-not (Test-Png $png)) { $failures.Add("${Label}: missing or invalid screenshot '$png'") } } - Add-LogFailures $Label $stdout $stderr $graceful = Close-ClientGracefully $client $client.Refresh() if ($client.HasExited) { $exitCode = [int]$client.ExitCode } @@ -214,6 +258,15 @@ function Invoke-Session( if ($null -ne $exitCode -and $exitCode -ne 0) { $failures.Add("${Label}: client exited with code $exitCode") } + Add-LogFailures $Label $stdout $stderr + if ((Get-PatternCount $stdout '[session] graceful logout confirmed') -ne 1) { + $failures.Add("${Label}: server did not authoritatively confirm graceful character logout") + } + Wait-ForFileAppendPattern ` + $AceLogPath ` + $aceLogOffset ` + "Session .*\\127\.0\.0\.1:$clientPort dropped\..*Reason: PacketHeader Disconnect" ` + 15 $session = [pscustomobject][ordered]@{ Label = $Label @@ -281,6 +334,9 @@ if (@(Get-Process -Name AcDream.App -ErrorAction SilentlyContinue).Count -gt 0) if (@(Get-NetUDPEndpoint -LocalPort 9000 -ErrorAction SilentlyContinue).Count -eq 0) { throw 'local ACE is not listening on UDP port 9000' } +if (-not (Test-Path -LiteralPath $AceLogPath)) { + throw "ACE log was not found: $AceLogPath" +} if (-not $SkipBuild) { & dotnet build (Join-Path $Repository 'AcDream.slnx') -c Release --no-restore @@ -297,10 +353,9 @@ $capped = Invoke-Session ` Add-SameLocationGates $capped -# The second process is both the session-teardown/reconnect gate and the -# uncapped renderer sample. A short pause lets ACE finish releasing the first -# session before the same account logs in again. -Start-Sleep -Seconds 3 +# The second process starts as soon as ACE records accepting the first +# process's transport Disconnect. No elapsed-time settle delay hides a +# shutdown race. $null = Invoke-Session ` 'uncapped-reconnect' ` (Join-Path $Repository 'tools\connected-world-reconnect.route.txt') ` From a4ef57885c9d5baff6cb86279a993f1e0a199666 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Jul 2026 23:33:50 +0200 Subject: [PATCH 06/98] refactor(streaming): centralize world reveal lifetime Compose destination readiness and lifecycle telemetry behind one App-layer coordinator so login and portal begin, preparation, materialization, visibility, completion, and cancellation cannot drift apart in GameWindow wiring. --- docs/architecture/acdream-architecture.md | 21 ++-- src/AcDream.App/Rendering/GameWindow.cs | 36 +++--- .../Streaming/WorldRevealCoordinator.cs | 69 +++++++++++ .../Streaming/WorldRevealCoordinatorTests.cs | 117 ++++++++++++++++++ 4 files changed, 213 insertions(+), 30 deletions(-) create mode 100644 src/AcDream.App/Streaming/WorldRevealCoordinator.cs create mode 100644 tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4476e988..ad765447 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -239,7 +239,8 @@ src/ Streaming/ StreamingController.cs -> done GpuWorldState.cs -> done - WorldRevealReadinessBarrier.cs -> shared login/portal reveal owner + WorldRevealCoordinator.cs -> shared login/portal lifetime owner + WorldRevealReadinessBarrier.cs -> canonical destination predicate Input/ PlayerMovementController.cs -> active movement driver Plugins/ @@ -708,14 +709,16 @@ partially hydrated cell membership or shell placement to the renderer. ### World-reveal readiness ownership -`WorldRevealReadinessBarrier` is the single App-layer owner of the edge that -allows normal world geometry to become visible at initial login or after a -portal. It joins `StreamingController`/`GpuWorldState` static-mesh publication, -`WbDrawDispatcher` composite-texture warmup, and `PhysicsEngine` destination -residency. Login's sky-only gate and portal-space transit consume the same -predicate; they differ only in presentation. Composite preparation advances -after `WbMeshAdapter.Tick` on the render thread, never from the streaming -worker. This is acdream's asynchronous equivalent of retail +`WorldRevealCoordinator` is the single App-layer owner of each login or portal +reveal lifetime. It composes the canonical `WorldRevealReadinessBarrier` with +generation-scoped lifecycle observation, so begin, preparation, readiness, +materialization, world visibility, completion, and cancellation cannot be +wired independently. The barrier joins `StreamingController`/`GpuWorldState` +static-mesh publication, `WbDrawDispatcher` composite-texture warmup, and +`PhysicsEngine` destination residency. Login's sky-only gate and portal-space +transit consume the same predicate; they differ only in presentation. +Composite preparation advances after `WbMeshAdapter.Tick` on the render +thread, never from the streaming worker. This is acdream's asynchronous equivalent of retail `SmartBox::UseTime` holding position completion while `CellManager::blocking_for_cells` is set. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 548eec6e..281e6978 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -149,9 +149,7 @@ public sealed class GameWindow : IDisposable private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements; private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Streaming.StreamingController? _streamingController; - private AcDream.App.Streaming.WorldRevealReadinessBarrier? _worldRevealReadiness; - private readonly AcDream.App.Streaming.WorldRevealLifecycleTelemetry _worldRevealTelemetry = - new(message => Console.WriteLine(message)); + private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9) private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25) @@ -2287,8 +2285,8 @@ public sealed class GameWindow : IDisposable UiProbeLog); _worldLifecycleAutomation = new AcDream.App.Diagnostics.WorldLifecycleAutomationController( - () => _worldRevealTelemetry.Snapshot, - () => _worldRevealTelemetry.PortalMaterializationCount, + () => _worldReveal?.Snapshot ?? default, + () => _worldReveal?.PortalMaterializationCount ?? 0, CaptureWorldLifecycleResourceSnapshot, _frameScreenshots, artifactDirectory, @@ -2867,7 +2865,7 @@ public sealed class GameWindow : IDisposable retirementCoordinator: _landblockRetirements); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; - _worldRevealReadiness = new AcDream.App.Streaming.WorldRevealReadinessBarrier( + _worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator( isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident, isSpawnCellReady: _physicsEngine.IsSpawnCellReady, isTerrainNeighborhoodReady: _physicsEngine.IsNeighborhoodTerrainResident, @@ -2880,7 +2878,8 @@ public sealed class GameWindow : IDisposable radius), invalidateCompositeTextures: () => _wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(), - isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable); + isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable, + log: message => Console.WriteLine(message)); // Phase 4.7: optional live-mode startup. Connect to the ACE server, // enter the world as the first character on the account, and stream @@ -3637,8 +3636,7 @@ public sealed class GameWindow : IDisposable // the same shared reveal lifetime used by portal arrivals: initial // login must not inherit the dispatcher's default/previous composite // readiness and expose a terrain-only or partially uploaded world. - _worldRevealReadiness?.Begin(); - _worldRevealTelemetry.Begin(AcDream.App.Streaming.WorldRevealKind.Login); + _worldReveal?.Begin(AcDream.App.Streaming.WorldRevealKind.Login); // Streaming is normally gated until this point, so the next loaded- // landblock callback will materialize deferred records. Also cover an @@ -7561,8 +7559,7 @@ public sealed class GameWindow : IDisposable _pendingTeleportCell = p.LandblockId; _teleportHoldSeconds = 0f; _teleportForced = false; - _worldRevealReadiness?.Begin(); - _worldRevealTelemetry.Begin(AcDream.App.Streaming.WorldRevealKind.Portal); + _worldReveal?.Begin(AcDream.App.Streaming.WorldRevealKind.Portal); if (_streamingController is not null) { _streamingController.PriorityLandblockId = @@ -7588,7 +7585,7 @@ public sealed class GameWindow : IDisposable if (clearSession) { _teleportTransit.ClearSession(); - _worldRevealTelemetry.Cancel(); + _worldReveal?.Cancel(); } else _teleportTransit.EndActive(); @@ -7647,9 +7644,7 @@ public sealed class GameWindow : IDisposable private AcDream.App.Streaming.WorldRevealReadinessSnapshot EvaluateWorldRevealReadiness( uint destinationCell) { - var snapshot = _worldRevealReadiness?.Evaluate(destinationCell) ?? default; - _worldRevealTelemetry.ObserveReadiness(snapshot); - return snapshot; + return _worldReveal?.Evaluate(destinationCell) ?? default; } private bool TryGetLoginWorldCell(out uint cell) @@ -9778,7 +9773,7 @@ public sealed class GameWindow : IDisposable { case AcDream.Core.World.TeleportAnimEvent.Place: PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced); - _worldRevealTelemetry.ObserveMaterialized(); + _worldReveal?.ObserveMaterialized(); if (_streamingController is not null) { _streamingController.PriorityLandblockId = 0u; @@ -9798,7 +9793,7 @@ public sealed class GameWindow : IDisposable // each portal transition. _liveSession?.SendGameAction( AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); - _worldRevealTelemetry.Complete(); + _worldReveal?.Complete(); ResetTeleportTransitState(); break; default: @@ -10157,8 +10152,7 @@ public sealed class GameWindow : IDisposable : 0u; if (revealCell != 0) { - _worldRevealReadiness?.Prepare(revealCell); - EvaluateWorldRevealReadiness(revealCell); + _worldReveal?.PrepareAndEvaluate(revealCell); } _particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); } @@ -10219,8 +10213,8 @@ public sealed class GameWindow : IDisposable if (!IsLiveModeWaitingForLogin) { _particleVisibility.UseWorldView(); - _worldRevealTelemetry.ObserveWorldViewportVisible(); - _worldRevealTelemetry.Complete(); + _worldReveal?.ObserveWorldViewportVisible(); + _worldReveal?.Complete(); } // L.0 Audio tab: push the SettingsVM's live AudioDraft into the diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs new file mode 100644 index 00000000..555e1feb --- /dev/null +++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs @@ -0,0 +1,69 @@ +namespace AcDream.App.Streaming; + +/// +/// Owns one login/portal reveal lifetime across readiness and lifecycle +/// diagnostics. This keeps the destination barrier and its observations on a +/// single seam so callers cannot begin, prepare, or evaluate one owner while +/// forgetting the other. +/// +internal sealed class WorldRevealCoordinator +{ + private readonly WorldRevealReadinessBarrier _readiness; + private readonly WorldRevealLifecycleTelemetry _lifecycle; + + public WorldRevealCoordinator( + Func isRenderNeighborhoodReady, + Func isSpawnCellReady, + Func isTerrainNeighborhoodReady, + Func areCompositeTexturesReady, + Action prepareCompositeTextures, + Action invalidateCompositeTextures, + Func isSpawnClaimUnhydratable, + Action? log = null) + { + _readiness = new WorldRevealReadinessBarrier( + isRenderNeighborhoodReady, + isSpawnCellReady, + isTerrainNeighborhoodReady, + areCompositeTexturesReady, + prepareCompositeTextures, + invalidateCompositeTextures, + isSpawnClaimUnhydratable); + _lifecycle = new WorldRevealLifecycleTelemetry(log); + } + + public WorldRevealLifecycleSnapshot Snapshot => _lifecycle.Snapshot; + public int PortalMaterializationCount => _lifecycle.PortalMaterializationCount; + + public long Begin(WorldRevealKind kind) + { + _readiness.Begin(); + return _lifecycle.Begin(kind); + } + + /// + /// Advances render-thread preparation and publishes the resulting + /// canonical readiness snapshot to lifecycle diagnostics. + /// + public WorldRevealReadinessSnapshot PrepareAndEvaluate(uint destinationCell) + { + _readiness.Prepare(destinationCell); + return Evaluate(destinationCell); + } + + public WorldRevealReadinessSnapshot Evaluate(uint destinationCell) + { + WorldRevealReadinessSnapshot snapshot = _readiness.Evaluate(destinationCell); + _lifecycle.ObserveReadiness(snapshot); + return snapshot; + } + + public void ObserveMaterialized() => _lifecycle.ObserveMaterialized(); + + public void ObserveWorldViewportVisible() => + _lifecycle.ObserveWorldViewportVisible(); + + public void Complete() => _lifecycle.Complete(); + + public void Cancel() => _lifecycle.Cancel(); +} diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs new file mode 100644 index 00000000..8ddc0791 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs @@ -0,0 +1,117 @@ +using AcDream.App.Streaming; + +namespace AcDream.App.Tests.Streaming; + +public sealed class WorldRevealCoordinatorTests +{ + private sealed class State + { + public bool RenderReady { get; set; } + public bool CollisionReady { get; set; } + public bool CompositesReady { get; set; } + public int InvalidateCount { get; private set; } + public int PrepareCount { get; private set; } + + public WorldRevealCoordinator Build(List? logs = null) => new( + isRenderNeighborhoodReady: (_, _) => RenderReady, + isSpawnCellReady: _ => CollisionReady, + isTerrainNeighborhoodReady: (_, _) => CollisionReady, + areCompositeTexturesReady: () => CompositesReady, + prepareCompositeTextures: (_, _) => PrepareCount++, + invalidateCompositeTextures: () => + { + InvalidateCount++; + CompositesReady = false; + }, + isSpawnClaimUnhydratable: _ => false, + log: logs is null ? null : new Action(logs.Add)); + } + + [Fact] + public void Begin_InvalidatesPriorDestinationAndStartsOneGeneration() + { + var state = new State { CompositesReady = true }; + WorldRevealCoordinator coordinator = state.Build(); + + long generation = coordinator.Begin(WorldRevealKind.Login); + + Assert.Equal(1, generation); + Assert.Equal(1, state.InvalidateCount); + Assert.False(state.CompositesReady); + Assert.Equal(WorldRevealKind.Login, coordinator.Snapshot.Kind); + } + + [Fact] + public void PrepareAndEvaluate_UsesOneCanonicalSnapshotForLifecycle() + { + const uint outdoorCell = 0x11340021u; + var state = new State + { + RenderReady = true, + CollisionReady = true, + }; + WorldRevealCoordinator coordinator = state.Build(); + coordinator.Begin(WorldRevealKind.Login); + + WorldRevealReadinessSnapshot first = coordinator.PrepareAndEvaluate(outdoorCell); + state.CompositesReady = true; + WorldRevealReadinessSnapshot ready = coordinator.PrepareAndEvaluate(outdoorCell); + + Assert.False(first.IsReady); + Assert.True(ready.IsReady); + Assert.Equal(2, state.PrepareCount); + Assert.Equal(ready, coordinator.Snapshot.Readiness); + } + + [Fact] + public void PortalLifecycle_CountsMaterializationAndCompletesVisibleGeneration() + { + const uint outdoorCell = 0x3032001Cu; + var logs = new List(); + var state = new State + { + RenderReady = true, + CollisionReady = true, + CompositesReady = true, + }; + WorldRevealCoordinator coordinator = state.Build(logs); + coordinator.Begin(WorldRevealKind.Portal); + state.CompositesReady = true; + + Assert.True(coordinator.Evaluate(outdoorCell).IsReady); + coordinator.ObserveMaterialized(); + coordinator.ObserveMaterialized(); + coordinator.ObserveWorldViewportVisible(); + coordinator.Complete(); + + Assert.Equal(1, coordinator.PortalMaterializationCount); + Assert.True(coordinator.Snapshot.Materialized); + Assert.True(coordinator.Snapshot.WorldViewportObserved); + Assert.True(coordinator.Snapshot.Completed); + Assert.Equal(0, coordinator.Snapshot.InvariantFailureCount); + Assert.Single(logs, line => line.Contains("event=materialized", StringComparison.Ordinal)); + } + + [Fact] + public void Cancel_PreventsLateDestinationWorkFromMutatingLifecycle() + { + var state = new State + { + RenderReady = true, + CollisionReady = true, + CompositesReady = true, + }; + WorldRevealCoordinator coordinator = state.Build(); + coordinator.Begin(WorldRevealKind.Portal); + coordinator.Cancel(); + + coordinator.PrepareAndEvaluate(0x11340021u); + coordinator.ObserveMaterialized(); + coordinator.ObserveWorldViewportVisible(); + coordinator.Complete(); + + Assert.True(coordinator.Snapshot.Cancelled); + Assert.Equal(default, coordinator.Snapshot.Readiness); + Assert.Equal(0, coordinator.PortalMaterializationCount); + } +} From f7b09617c5f8589424e2cba1a27e7576459c8918 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Jul 2026 23:54:58 +0200 Subject: [PATCH 07/98] fix(test): preserve singleton lifecycle samples Keep one-item endpoint and checkpoint results as arrays under PowerShell strict mode so the uncapped reconnect session is validated by the same gate as the multi-checkpoint capped route. --- tools/run-connected-world-lifecycle-gate.ps1 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/run-connected-world-lifecycle-gate.ps1 b/tools/run-connected-world-lifecycle-gate.ps1 index f4360265..f0c599ef 100644 --- a/tools/run-connected-world-lifecycle-gate.ps1 +++ b/tools/run-connected-world-lifecycle-gate.ps1 @@ -219,11 +219,12 @@ function Invoke-Session( -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru Wait-ForPattern $client $stdout '[UI-PROBE] UI probe script complete' $SessionTimeoutSeconds - $clientPort = @(Get-NetUDPEndpoint -OwningProcess $client.Id -ErrorAction SilentlyContinue | + $clientPorts = @(Get-NetUDPEndpoint -OwningProcess $client.Id -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty LocalPort) - if ($clientPort.Count -ne 1) { + if ($clientPorts.Count -ne 1) { throw "could not resolve the client's UDP endpoint for ACE disconnect verification" } + $clientPort = [int]$clientPorts[0] $client.Refresh() $processSample = [pscustomobject][ordered]@{ @@ -234,7 +235,7 @@ function Invoke-Session( WindowTitle = $client.MainWindowTitle } - $checkpoints = Read-Checkpoints $timeline + $checkpoints = @(Read-Checkpoints $timeline) if ($checkpoints.Count -ne $ExpectedCheckpoints.Count) { $failures.Add("${Label}: expected $($ExpectedCheckpoints.Count) checkpoints, found $($checkpoints.Count)") } From e02acb870cfee886c5d8c2d88601287c14e9cec5 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 00:00:31 +0200 Subject: [PATCH 08/98] docs(runtime): close deterministic world lifecycle gate Record the passing capped and uncapped connected evidence, coordinator ownership, graceful reconnect contract, current test baseline, and the remaining human-only visual gates. Keep AGENTS.md synchronized with CLAUDE.md and distill the durable reveal-lifecycle lesson into project memory. --- AGENTS.md | 31 ++++++----- CLAUDE.md | 31 ++++++----- docs/ISSUES.md | 12 +++-- docs/architecture/acdream-architecture.md | 10 ++++ .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 17 +++++-- docs/plans/2026-05-12-milestones.md | 16 +++--- ...26-07-20-automated-world-lifecycle-gate.md | 51 ++++++++++++++++--- 8 files changed, 121 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 80d040ca..696a0cfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,19 +108,24 @@ movement queries. ## Current state -**M3 — Cast a spell ACTIVE; automated implementation complete 2026-07-15.** Retail cast intent, -component/target gates, live enchantment wire, Magic-scoped input, spell bar, spellbook, component -book, effect indicators, shared main-panel switching, and positive/negative effects UI are implemented over the Step-9 projectile/DAT-effect -foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete; -three independent conformance/architecture/adversarial re-reviews are clean, the Release build succeeds -(the clean documentation audit exposes 17 pre-existing test-project warnings, #228), and the full suite -is 6,457 passed / 5 skipped. Its unattended seven-destination -connected input/portal/performance rebaseline also passes without an interactive desktop. Remaining -ownership cleanup and registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6 -locomotion/collision/projectile/teleport visual gate, then the final two-client -portal-out/materialization observer gate. **Carried:** #153, #116, R6 residuals, and the deferred -Modern Pipeline track (MP1b+). -Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below. +**M3 — Cast a spell ACTIVE; automated implementation and connected +single-client gates complete.** Retail cast intent, component/target gates, +live enchantment wire, Magic-scoped input, spell bar, spellbook, component +book, effect indicators, shared main-panel switching, and positive/negative +effects UI are implemented over the Step-9 projectile/DAT-effect foundation. +The R6 complete-root-Frame/object-workset cutover and its unattended +seven-destination input/portal/performance rebaseline pass. The 2026-07-20 +deterministic world-lifecycle gate additionally protects fresh login, +outdoor/world-edge/dungeon travel, same-location revisit, exact graceful +disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. +Release builds; 6,480 tests pass / 5 skip (17 tracked warnings, #228). +**Next:** the R6 locomotion/collision/projectile/teleport local visual +rebaseline when a local view is available, then the final two-client +portal-out/materialization observer gate. **Carried:** #153, #116, remaining +R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's +lifestone/particle alpha visual gate. +Start magic work at `claude-memory/project_magic_ui_and_casting.md`; start +render/streaming work at `claude-memory/project_render_pipeline_digest.md`. Documentation entry point: [`docs/README.md`](docs/README.md). diff --git a/CLAUDE.md b/CLAUDE.md index 9e6adcd8..4be3b817 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,19 +106,24 @@ movement queries. ## Current state -**M3 — Cast a spell ACTIVE; automated implementation complete 2026-07-15.** Retail cast intent, -component/target gates, live enchantment wire, Magic-scoped input, spell bar, spellbook, component -book, effect indicators, shared main-panel switching, and positive/negative effects UI are implemented over the Step-9 projectile/DAT-effect -foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete; -three independent conformance/architecture/adversarial re-reviews are clean, the Release build succeeds -(the clean documentation audit exposes 17 pre-existing test-project warnings, #228), and the full suite -is 6,457 passed / 5 skipped. Its unattended seven-destination -connected input/portal/performance rebaseline also passes without an interactive desktop. Remaining -ownership cleanup and registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6 -locomotion/collision/projectile/teleport visual gate, then the final two-client -portal-out/materialization observer gate. **Carried:** #153, #116, R6 residuals, and the deferred -Modern Pipeline track (MP1b+). -Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below. +**M3 — Cast a spell ACTIVE; automated implementation and connected +single-client gates complete.** Retail cast intent, component/target gates, +live enchantment wire, Magic-scoped input, spell bar, spellbook, component +book, effect indicators, shared main-panel switching, and positive/negative +effects UI are implemented over the Step-9 projectile/DAT-effect foundation. +The R6 complete-root-Frame/object-workset cutover and its unattended +seven-destination input/portal/performance rebaseline pass. The 2026-07-20 +deterministic world-lifecycle gate additionally protects fresh login, +outdoor/world-edge/dungeon travel, same-location revisit, exact graceful +disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. +Release builds; 6,480 tests pass / 5 skip (17 tracked warnings, #228). +**Next:** the R6 locomotion/collision/projectile/teleport local visual +rebaseline when a local view is available, then the final two-client +portal-out/materialization observer gate. **Carried:** #153, #116, remaining +R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's +lifestone/particle alpha visual gate. +Start magic work at `claude-memory/project_magic_ui_and_casting.md`; start +render/streaming work at `claude-memory/project_render_pipeline_digest.md`. Documentation entry point: [`docs/README.md`](docs/README.md). diff --git a/docs/ISSUES.md b/docs/ISSUES.md index a87fdd92..cb57ba89 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -98,7 +98,7 @@ production runtime failure. **Acceptance:** A non-incremental Release solution build reports zero warnings and zero errors without suppressing analyzers or weakening nullable checking; -the full 6,457-pass / 5-skip suite remains green. +the full 6,480-pass / 5-skip suite remains green. --- @@ -7445,8 +7445,14 @@ equivalent of retail `SmartBox::UseTime` keeping position completion behind `CellManager::blocking_for_cells`. Both paths invalidate destination texture readiness, prepare composites after mesh publication, and require the same render-plus-collision predicate. Focused login/barrier tests and the complete -Release suite pass; capped connected visual verification remains the final -gate. +Release suite pass. The user confirmed the capped first-login reveal, and the +follow-up deterministic connected gate now repeats fresh login plus five +portal/dungeon transitions, returns to the same outdoor location, closes with +the retail character-logoff/transport-disconnect handshake, and reconnects in +a fresh uncapped process. All six saved PNGs show complete geometry; all +structured checkpoints report ready render/composite/collision domains and +zero reveal invariant failures or pending teardown/upload/warmup work. See +`docs/plans/2026-07-20-automated-world-lifecycle-gate.md`. **Research:** `docs/research/2026-07-16-portal-completion-pseudocode.md` §2. diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index ad765447..18dbf3d6 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -722,6 +722,16 @@ thread, never from the streaming worker. This is acdream's asynchronous equivale `SmartBox::UseTime` holding position completion while `CellManager::blocking_for_cells` is set. +`WorldRevealLifecycleTelemetry` is an observation seam owned by the +coordinator, not a second lifecycle state machine. It records generation-scoped +edges and rejects a normal-world viewport observed before canonical readiness. +`WorldLifecycleAutomationController` consumes only coordinator snapshots plus +resource counters; `FrameScreenshotController` performs the requested +default-framebuffer readback after world and retained UI draw on the render +thread. The external connected gate launches normal Release processes and +observes these artifacts, but never mutates streaming, physics, GL, or entity +state from a worker thread. + ### Teleport cell identity at the streaming boundary The network and physics layers carry retail's complete `Position`: an diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index eff9cfe8..a1c6c21b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -67,7 +67,7 @@ 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 priority 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 forced-placement path. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (login auto-entry + TAS transit tick); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `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 an empty 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, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | +| 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 priority 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 forced-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (owner wiring + draw eligibility); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `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 an empty 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, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `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) | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index e2cdd444..5dc4c21a 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-20. **M3 active; automated implementation and connected single-client magic/portal gates complete.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels with authored rows and selected-spell details, the complete seven-control gameplay indicator strip and its Character Information/Link/Vitae detail pages, and shared `gmPanelUI` geometry/switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The next gate is a local visual rebaseline of the new R6 object-frame path; the final M3 requirement is the two-client portal-out/materialization observer gate. Issue #225's lifestone/particle alpha comparison is a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-20. **M3 active; automated implementation and connected single-client magic/portal gates complete.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels with authored rows and selected-spell details, the complete seven-control gameplay indicator strip and its Character Information/Link/Vitae detail pages, and shared `gmPanelUI` geometry/switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. Fresh-login and portal world reveal are additionally protected by a deterministic capped/uncapped connected lifecycle gate with screenshots, resource snapshots, exact graceful disconnect, and fresh-process reconnect. The next gate is a local visual rebaseline of the new R6 object-frame path; the final M3 requirement is the two-client portal-out/materialization observer gate. Issue #225's lifestone/particle alpha comparison is 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. --- @@ -34,9 +34,14 @@ active enchantment, then recall through the DAT-driven portal presentation. effects, spellbook/component-book filtering and deletion, favorite spell bar interaction, recall, portal-space presentation, and retained indicator pages. -4. **Pending local visual gate:** locomotion, collision, projectile, and +4. **Done (automated connected gate):** one canonical world-reveal lifecycle + now covers fresh login, outdoor/world-edge/dungeon transitions, + same-location revisit, graceful logout, and uncapped fresh-process + reconnect. The gate records structured readiness/resource checkpoints and + full-frame PNGs, and rejects early world visibility or stranded owners. +5. **Pending local visual gate:** locomotion, collision, projectile, and teleport comparison after the R6 complete-root-Frame/object-workset cutover. -5. **Pending two-client visual gate:** portal-out/materialization observer +6. **Pending two-client visual gate:** portal-out/materialization observer presentation before M3 closes, including #218's finished Hidden silhouette, fully rendered destination reveal, and no indoor stop-time position snap. @@ -567,6 +572,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **Portal/resource lifetime correction 2026-07-18; connected stress gate passed, final visual gate pending.** `LiveEntityLivenessController` ports retail's 25-second leave-visibility destruction queue over canonical live records, cancels expiry for spatially resident or owned objects, and uses holtburger's conservative 384-unit envelope where ACE supplies no client-visible PVS leave event. The broader integration balances generation-scoped appearance/live/landblock/effect/UI/portal owners, bounds DAT/decoded/standalone/composite/atlas/mesh residency, performs incremental mesh-arena migration, and defers GL deletion or slot reuse through three frames of GPU fences. Render/UI scratch storage is reused with hysteretic trimming. No visual range or quality setting was reduced. The final seven-destination connected route cut peak working/private memory from 2,954.5/3,502.3 MiB to 1,493.4/1,969.5 MiB, returned Caul to 1,030.6/1,638.2 MiB, held 125–153 FPS locally through the final dwell, and produced no WER/driver reset. AP-69 is narrowed rather than retired because exact ObjCell PVS and explicit preview lifetimes remain. - **Render-thread CPU and high-refresh pacing correction 2026-07-19; automated/review/connected gates passed, final translucency visual gate pending.** Software pacing now uses one reusable Windows high-resolution waitable timer instead of scheduler-quantized `Thread.Sleep`; the permanent profiler attributes that wait as `pace`. Wb keeps exact stable far-to-near alpha order while using a radix key, avoids immediate SSBO packing for deferred transparent objects, packs each eight-light set, caches immutable selection descriptors, and retires historical material groups only after one complete unused frame across every landscape/dynamics/paperdoll draw scope. A fixed dense-Caul sample improved from roughly 9–12 ms CPU to 5.3–6.2 ms without reducing view distance, particles, textures, streaming radius, or other quality. The corrected post-review uncapped route waited for every materialization and held a full visibility sweep through one dedicated profiler window at all seven destinations. Returned dense Caul (about 21,024 visible entities) measured 3.0/4.9 ms CPU p50/p95 and 2.0/3.4 ms GPU while turning, then 3.8/5.0 ms CPU and 2.6/3.2 ms GPU stationary (241 FPS title sample). First/return Caul memory was 939.8/1,438.3 and 1,034.8/1,630.5 MiB working/private; the highest stationary sample was 1,065.0/1,660.6 MiB in Holtburg, and the client exited gracefully. With pacing disabled (`pace=0`) and title samples reaching 844 FPS, RDP did not refresh-cap engine throughput. UI automation now uses a command-local compensated clock, preserving sub-millisecond frame deltas and exact long-duration sleep/timeout boundaries. +- **World-reveal lifecycle gate and graceful reconnect shipped 2026-07-20.** `WorldRevealCoordinator` is now the single App owner for login/portal begin, canonical render+composite+collision readiness, materialization, normal-world visibility, completion, and cancellation. Diagnostic telemetry observes that owner by generation; it does not duplicate the readiness formula. Retained automation can wait on semantic lifecycle edges, write JSON checkpoints, and capture the complete framebuffer after world and UI draw. The connected Release gate covers login → Aerlinthe → Rynthid → Facility Hub → Holtburg → Aerlinthe, closes through retail's `0xF653` character-logoff plus transport `Disconnect`, proves ACE accepted the exact client endpoint, then performs an uncapped fresh-process reconnect. The final run passed all checkpoints and six PNGs with zero reveal invariant failures or pending teardown/upload/warmup work; the reconnect sampled 172.7 FPS / 5.79 ms on this host. Release build and 6,480 tests pass / 5 skip. See `docs/plans/2026-07-20-automated-world-lifecycle-gate.md`. **Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation. @@ -920,7 +926,8 @@ diagnostic scaffolding, not yet the final collision system. PositionManager facade remains the final R6 ownership cleanup. Final 2026-07-20 gate: three independent retail/architecture/adversarial re-reviews are clean; Release builds successfully with 17 known test-project - warnings tracked by #228; the repository suite is 6,457 passed / 5 skipped. + warnings tracked by #228; the repository suite is 6,480 passed / 5 skipped + after the world-reveal gate additions. The unattended connected Release rebaseline also passes: seven portal materializations across Caul, Sawato, Rynthid, Aerlinthe, Holtburg, and same-location revisits; production-dispatcher turn, @@ -1446,7 +1453,7 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen. ## Cross-cutting work tracked in parallel -- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,457 passed / 5 intentional skips (2026-07-20). +- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,480 passed / 5 intentional skips (2026-07-20). - **Memory files.** Live subsystem state and digests are indexed by `claude-memory/MEMORY.md`; stable engineering references remain under `memory/`. Canonical program status stays in milestones/roadmap/issues. - **`CLAUDE.md` discipline.** Check all four references (ACE, ACViewer, WorldBuilder, Chorizite) before committing to an approach. WorldBuilder is the closest stack match and should be checked first. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 2ae4b3fa..a24c15fe 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -9,17 +9,21 @@ foundation is hardened through Step 9. Spellbook/component-book filtering, favorite spell bar, connected casting/enchantment effects, and portal-space presentation have passed their single-client visual gates. The corrective bounded render/resource-lifetime integration passed its seven-destination connected -stress gate without a crash or cumulative collapse. The remaining M3 visual -requirement is the final two-client portal-out/materialize observer gate. The -new R6 object-update cutover has passed its unattended connected input/portal/ -performance rebaseline; its locomotion/collision/projectile/teleport visual -gate remains for the next local viewing session before that observer gate. +stress gate without a crash or cumulative collapse. A second deterministic +world-lifecycle gate now protects fresh login, outdoor/world-edge/dungeon +travel, same-location revisit, exact graceful disconnect, and an uncapped +fresh-process reconnect with canonical readiness snapshots and saved PNG/JSON +artifacts. The remaining M3 visual requirement is the final two-client +portal-out/materialize observer gate. The new R6 object-update cutover has +passed its unattended connected input/portal/performance rebaseline; its +locomotion/collision/projectile/teleport visual gate remains for the next local +viewing session before that observer gate. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus registered TS-50/TS-51 timing residuals (the complete-root-Frame/object-workset cutover is automated-test complete 2026-07-20; three independent re-reviews are clean, Release builds with 17 known test-project warnings tracked by #228, and -6,457 tests pass / 5 skip), and +6,480 tests pass / 5 skip after the world-reveal gate), and the deferred Modern Pipeline track (MP1b+). The separate #225 shared-alpha implementation has passed connected lifetime/performance gates and awaits only its lifestone/particle visual comparison. diff --git a/docs/plans/2026-07-20-automated-world-lifecycle-gate.md b/docs/plans/2026-07-20-automated-world-lifecycle-gate.md index 0f04441b..8abeafd2 100644 --- a/docs/plans/2026-07-20-automated-world-lifecycle-gate.md +++ b/docs/plans/2026-07-20-automated-world-lifecycle-gate.md @@ -1,6 +1,6 @@ # Automated world-lifecycle gate -**Status:** In progress (2026-07-20) +**Status:** Complete (2026-07-20) **Owner:** M3 stabilization / R6 rendering-lifecycle rebaseline **Depends on:** `WorldRevealReadinessBarrier`, retained-UI automation, `FrameProfiler`, connected local ACE @@ -133,10 +133,45 @@ frame time separately. ## Completion evidence -- focused App tests for every lifecycle invariant; -- full Release solution build and test suite; -- two-process connected lifecycle report with screenshots and JSONL timeline; -- accepted capped and uncapped same-session metrics; -- `GameWindow` extraction retains the exact accepted lifecycle trace; -- roadmap, issues, architecture, divergence register, memory, `AGENTS.md`, and - `CLAUDE.md` agree on the resulting ownership and remaining visual gates. +- Focused App tests cover readiness composition, stale generations, + cancellation, duplicate lifecycle edges, early-visible rejection, artifact + capture, automation timeouts, and resource snapshots. +- `dotnet build AcDream.slnx -c Release` succeeds. The complete repository + suite is 6,480 passed / 5 skipped; the 17 existing test-project warnings + remain tracked by issue #228. +- The final two-process connected report passed with no failures. The capped + process completed fresh login, Aerlinthe, Rynthid, Facility Hub, Holtburg, + and an Aerlinthe revisit in 244.424 seconds. The uncapped process then + reconnected the same account and completed in 61.717 seconds. +- Both processes closed through `WM_CLOSE`, received the authoritative + character-logoff confirmation, exited with code 0, and were matched to + ACE's exact client UDP endpoint and `PacketHeader Disconnect` record. +- Every checkpoint had zero pending live teardowns, landblock retirements, + staged mesh uploads, composite warmups, and reveal invariant failures. +- All six PNG artifacts were valid and inspected. Login, indoor dungeon, + dungeon exit, Aerlinthe revisit, and reconnect showed complete world + geometry rather than a grey or partially revealed destination. +- The uncapped reconnect sampled 172.7 FPS / 5.79 ms at the checkpoint; its + latest profiler window measured 5.7 ms CPU p50, 6.1 ms p95 and 5.7 ms GPU + p50, 6.0 ms p95 with zero pacing time. This is machine-specific throughput + evidence, not a cross-display FPS requirement. +- The Aerlinthe revisit retained balanced owners and stayed inside the + documented relative memory/resource tolerances. One circuit is not claimed + as a permanent memory plateau proof; the longer seven-destination R6 soak + remains the endurance gate. +- `WorldRevealCoordinator` now owns the protected lifecycle outside + `GameWindow`; the accepted trace did not change after extraction. + +## Landed commits + +- `db45b81f` — expose canonical readiness snapshots and lifecycle telemetry; +- `354c2adc` — add deterministic checkpoints and framebuffer screenshots; +- `b03371c0` — add the connected lifecycle/reconnect gate; +- `68578fa5` — port retail graceful character logout and transport disconnect; +- `a4ef5788` — centralize the reveal lifetime in `WorldRevealCoordinator`; +- `f7b09617` — preserve singleton endpoint/checkpoint collections in the gate. + +The remaining visual work is deliberately outside this gate: the R6 +locomotion/collision/projectile/teleport retail comparison, the two-client +portal-out/materialization observer comparison, and issue #225's translucent +lifestone/particle ordering comparison. From f4cf802330a65bdc364bbd5b26d5b5e73380abd6 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 06:24:05 +0200 Subject: [PATCH 09/98] fix(ui): bind radar to streamed world state Resolve the live-entity spatial broadphase at snapshot time so retained UI construction cannot capture GameWindow's empty bootstrap GpuWorldState. Add a replacement-owner regression test covering the exact compass-without-blips failure. Co-authored-by: OpenAI Codex --- src/AcDream.App/Rendering/GameWindow.cs | 2 +- src/AcDream.App/Streaming/GpuWorldState.cs | 3 +- .../UI/Layout/RadarSnapshotProvider.cs | 15 ++-- .../World/ILiveEntitySpatialQuery.cs | 16 +++++ .../UI/Layout/RadarSnapshotProviderTests.cs | 71 ++++++++++++++++--- 5 files changed, 90 insertions(+), 17 deletions(-) create mode 100644 src/AcDream.App/World/ILiveEntitySpatialQuery.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 281e6978..9c8685e9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2264,7 +2264,7 @@ public sealed class GameWindow : IDisposable coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar, uiLocked: () => _persistedGameplay.LockUI, playerEntities: () => _entitiesByServerGuid, - copySpatialCandidates: _worldState.CopyLiveEntitiesNearLandblock); + spatialQuery: () => _worldState); var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); _retailChatVm = retailChatVm; AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index f1e004b6..23a05e2f 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Numerics; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; +using AcDream.App.World; using AcDream.Core.World; namespace AcDream.App.Streaming; @@ -39,7 +40,7 @@ namespace AcDream.App.Streaming; /// Threading: not thread-safe. All calls must happen on the render thread. /// /// -public sealed class GpuWorldState +public sealed class GpuWorldState : ILiveEntitySpatialQuery { private readonly LandblockSpawnAdapter? _wbSpawnAdapter; private readonly EntityScriptActivator? _entityScriptActivator; diff --git a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs index a4d714d1..96cafad2 100644 --- a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs +++ b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.World; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Physics.Motion; @@ -29,7 +30,7 @@ public sealed class RadarSnapshotProvider private readonly Func _coordinatesOnRadar; private readonly Func _uiLocked; private readonly Func? _relationshipFor; - private readonly Action>>? _copySpatialCandidates; + private readonly Func? _spatialQuery; private readonly List> _candidateScratch = new(); public RadarSnapshotProvider( @@ -44,7 +45,7 @@ public sealed class RadarSnapshotProvider Func uiLocked, Func? relationshipFor = null, Func>? playerEntities = null, - Action>>? copySpatialCandidates = null) + Func? spatialQuery = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities)); @@ -57,7 +58,7 @@ public sealed class RadarSnapshotProvider _coordinatesOnRadar = coordinatesOnRadar ?? throw new ArgumentNullException(nameof(coordinatesOnRadar)); _uiLocked = uiLocked ?? throw new ArgumentNullException(nameof(uiLocked)); _relationshipFor = relationshipFor; - _copySpatialCandidates = copySpatialCandidates; + _spatialQuery = spatialQuery; } public UiRadarSnapshot BuildSnapshot() @@ -90,12 +91,16 @@ public sealed class RadarSnapshotProvider float range = RetailRadar.GetRangeMeters(isOutside); _candidateScratch.Clear(); - if (_copySpatialCandidates is not null) + // Retained UI mounts before the production GpuWorldState. Resolve the + // spatial owner per snapshot just like the live dictionaries above; + // retaining a method group here binds forever to the empty bootstrap + // state and produces a working compass with no object blips. + if (_spatialQuery?.Invoke() is { } spatialQuery) { // Retail radar is at most 75 metres while a landblock is 192 // metres wide. The current block plus its eight neighbours is a // complete broadphase (the cheap spatial pre-filter). - _copySpatialCandidates(playerCellId, 1, _candidateScratch); + spatialQuery.CopyLiveEntitiesNearLandblock(playerCellId, 1, _candidateScratch); } else { diff --git a/src/AcDream.App/World/ILiveEntitySpatialQuery.cs b/src/AcDream.App/World/ILiveEntitySpatialQuery.cs new file mode 100644 index 00000000..1b587267 --- /dev/null +++ b/src/AcDream.App/World/ILiveEntitySpatialQuery.cs @@ -0,0 +1,16 @@ +using AcDream.Core.World; + +namespace AcDream.App.World; + +/// +/// Read-only spatial broadphase for live server-object projections. +/// Consumers resolve this owner late because GameWindow replaces its +/// empty bootstrap world state after retained UI construction. +/// +public interface ILiveEntitySpatialQuery +{ + void CopyLiveEntitiesNearLandblock( + uint centerCellOrLandblockId, + int landblockRadius, + List> destination); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs index 16f5957c..393c682a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; +using AcDream.App.World; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; @@ -210,8 +211,8 @@ public sealed class RadarSnapshotProviderTests [unrelated] = Entity(unrelated, new Vector3(6f, 0f, 0f), Quaternion.Identity), }; var spawns = entities.Keys.ToDictionary(guid => guid, Spawn); - uint copiedCell = 0; - int copiedRadius = -1; + var spatialQuery = new RecordingSpatialQuery( + new KeyValuePair(nearby, entities[nearby])); var provider = new RadarSnapshotProvider( objects, () => entities, @@ -222,20 +223,70 @@ public sealed class RadarSnapshotProviderTests selectedGuid: () => null, coordinatesOnRadar: () => true, uiLocked: () => false, - copySpatialCandidates: (cell, radius, destination) => - { - copiedCell = cell; - copiedRadius = radius; - destination.Add(new KeyValuePair(nearby, entities[nearby])); - }); + spatialQuery: () => spatialQuery); UiRadarSnapshot snapshot = provider.BuildSnapshot(); - Assert.Equal(0xA9B40001u, copiedCell); - Assert.Equal(1, copiedRadius); + Assert.Equal(0xA9B40001u, spatialQuery.CopiedCell); + Assert.Equal(1, spatialQuery.CopiedRadius); Assert.Equal(nearby, Assert.Single(snapshot.Blips).ObjectId); } + [Fact] + public void BuildSnapshot_ResolvesSpatialOwnerAfterBootstrapReplacement() + { + const uint player = 50u; + const uint monster = 51u; + var objects = new ClientObjectTable(); + objects.Ingest(Weenie(player, "Player", ItemType.Creature)); + objects.Ingest(Weenie(monster, "Drudge", ItemType.Creature) with + { + RadarBehavior = (byte)RadarBehavior.ShowAlways, + }); + var entities = new Dictionary + { + [player] = Entity(player, Vector3.Zero, Quaternion.Identity), + [monster] = Entity(monster, new Vector3(5f, 0f, 0f), Quaternion.Identity), + }; + var spawns = entities.Keys.ToDictionary(guid => guid, Spawn); + ILiveEntitySpatialQuery currentSpatialQuery = new RecordingSpatialQuery(); + var provider = new RadarSnapshotProvider( + objects, + () => entities, + () => spawns, + playerGuid: () => player, + playerYawRadians: () => 0f, + playerCellId: () => 0xA9B40001u, + selectedGuid: () => null, + coordinatesOnRadar: () => true, + uiLocked: () => false, + spatialQuery: () => currentSpatialQuery); + + Assert.Empty(provider.BuildSnapshot().Blips); + + currentSpatialQuery = new RecordingSpatialQuery( + new KeyValuePair(monster, entities[monster])); + + Assert.Equal(monster, Assert.Single(provider.BuildSnapshot().Blips).ObjectId); + } + + private sealed class RecordingSpatialQuery( + params KeyValuePair[] candidates) : ILiveEntitySpatialQuery + { + public uint CopiedCell { get; private set; } + public int CopiedRadius { get; private set; } = -1; + + public void CopyLiveEntitiesNearLandblock( + uint centerCellOrLandblockId, + int landblockRadius, + List> destination) + { + CopiedCell = centerCellOrLandblockId; + CopiedRadius = landblockRadius; + destination.AddRange(candidates); + } + } + private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new() { Id = guid, From cf50ee3d500d4e39ad16896edd9e242854a3ff9f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 06:44:29 +0200 Subject: [PATCH 10/98] docs(architecture): reconcile GameWindow extraction program Close the completed M3 and portal observer gates, make the behavior-preserving GameWindow ownership campaign the explicit M4 prerequisite, and replace the stale extraction sketch with an implementation-audited sequence. Co-authored-by: OpenAI Codex --- AGENTS.md | 29 +- CLAUDE.md | 29 +- docs/ISSUES.md | 17 +- docs/README.md | 19 +- docs/architecture/acdream-architecture.md | 16 +- docs/architecture/code-structure.md | 353 +++++++++++++--------- docs/plans/2026-04-11-roadmap.md | 44 ++- docs/plans/2026-05-12-milestones.md | 67 ++-- 8 files changed, 354 insertions(+), 220 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 696a0cfe..ccad370b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,23 +108,26 @@ movement queries. ## Current state -**M3 — Cast a spell ACTIVE; automated implementation and connected -single-client gates complete.** Retail cast intent, component/target gates, -live enchantment wire, Magic-scoped input, spell bar, spellbook, component -book, effect indicators, shared main-panel switching, and positive/negative -effects UI are implemented over the Step-9 projectile/DAT-effect foundation. -The R6 complete-root-Frame/object-workset cutover and its unattended -seven-destination input/portal/performance rebaseline pass. The 2026-07-20 -deterministic world-lifecycle gate additionally protects fresh login, +**M3 — Cast a spell LANDED 2026-07-21. M4 — Live in the world NEXT.** Retail +casting, spellbook/component-book UI, active effects, recall/portal-space +presentation, the R6 locomotion/collision/projectile/teleport/radar rebaseline, +and the final two-client portal-out/materialization observer flow are +user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 6,480 tests pass / 5 skip (17 tracked warnings, #228). -**Next:** the R6 locomotion/collision/projectile/teleport local visual -rebaseline when a local view is available, then the final two-client -portal-out/materialization observer gate. **Carried:** #153, #116, remaining +Release builds; 6,481 tests pass / 5 skip (17 tracked warnings, #228). + +**Active prerequisite before new M4 subsystem work:** complete the +behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. The +next slice finishes selection/interaction ownership; later slices finish +animation presentation, live-session and live-entity App integration, +landblock presentation, update/render orchestration, and composition cleanup. +See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. -Start magic work at `claude-memory/project_magic_ui_and_casting.md`; start + +Start structural work at `docs/architecture/code-structure.md`; start +magic follow-up at `claude-memory/project_magic_ui_and_casting.md`; start render/streaming work at `claude-memory/project_render_pipeline_digest.md`. Documentation entry point: [`docs/README.md`](docs/README.md). diff --git a/CLAUDE.md b/CLAUDE.md index 4be3b817..cd79a560 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,23 +106,26 @@ movement queries. ## Current state -**M3 — Cast a spell ACTIVE; automated implementation and connected -single-client gates complete.** Retail cast intent, component/target gates, -live enchantment wire, Magic-scoped input, spell bar, spellbook, component -book, effect indicators, shared main-panel switching, and positive/negative -effects UI are implemented over the Step-9 projectile/DAT-effect foundation. -The R6 complete-root-Frame/object-workset cutover and its unattended -seven-destination input/portal/performance rebaseline pass. The 2026-07-20 -deterministic world-lifecycle gate additionally protects fresh login, +**M3 — Cast a spell LANDED 2026-07-21. M4 — Live in the world NEXT.** Retail +casting, spellbook/component-book UI, active effects, recall/portal-space +presentation, the R6 locomotion/collision/projectile/teleport/radar rebaseline, +and the final two-client portal-out/materialization observer flow are +user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 6,480 tests pass / 5 skip (17 tracked warnings, #228). -**Next:** the R6 locomotion/collision/projectile/teleport local visual -rebaseline when a local view is available, then the final two-client -portal-out/materialization observer gate. **Carried:** #153, #116, remaining +Release builds; 6,481 tests pass / 5 skip (17 tracked warnings, #228). + +**Active prerequisite before new M4 subsystem work:** complete the +behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. The +next slice finishes selection/interaction ownership; later slices finish +animation presentation, live-session and live-entity App integration, +landblock presentation, update/render orchestration, and composition cleanup. +See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. -Start magic work at `claude-memory/project_magic_ui_and_casting.md`; start + +Start structural work at `docs/architecture/code-structure.md`; start +magic follow-up at `claude-memory/project_magic_ui_and_casting.md`; start render/streaming work at `claude-memory/project_render_pipeline_digest.md`. Documentation entry point: [`docs/README.md`](docs/README.md). diff --git a/docs/ISSUES.md b/docs/ISSUES.md index cb57ba89..6f504034 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,12 +24,12 @@ 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. -## Current queue — 2026-07-20 +## Current queue — 2026-07-21 -- **Next visual gate:** R6 locomotion/collision/projectile/teleport comparison - after the complete-root-Frame/object-workset cutover. -- **M3 closing gate:** `#218`, final two-client portal-out/materialization and - indoor observer comparison. +- **Active structural work:** `GameWindow` decomposition, beginning with the + selection/interaction ownership slice in + [`docs/architecture/code-structure.md`](architecture/code-structure.md). + This is the behavior-preserving prerequisite before new M4 feature bodies. - **Separate rendering gate:** `#225`, lifestone/particle alpha ordering. Its connected performance, lifetime, and unattended portal routes pass. - **Carried behavior debt:** `#153` far-teleport unstreamed-edge arrival and @@ -98,7 +98,7 @@ production runtime failure. **Acceptance:** A non-incremental Release solution build reports zero warnings and zero errors without suppressing analyzers or weakening nullable checking; -the full 6,480-pass / 5-skip suite remains green. +the full 6,481-pass / 5-skip suite remains green. --- @@ -565,7 +565,7 @@ Passed 2026-07-17: “Yes works.” ## #218 — Portal silhouette pose, destination reveal, and indoor observer snap -**Status:** IN-PROGRESS — root fixes implemented 2026-07-16; pending connected visual gate +**Status:** DONE — 2026-07-21, final two-client observer gate user-confirmed **Severity:** HIGH **Filed:** 2026-07-16 **Component:** portal VFX / streaming / physics / outbound movement @@ -635,6 +635,9 @@ pose; outdoor exits reveal only fully rendered terrain/scenery; and a retail observer sees acdream run and stop repeatedly inside a dungeon without any snap back to the entrance. +**Gate:** Passed 2026-07-21. The user confirmed the final two-client +portal-out/materialization and indoor observer comparison on the R6 baseline. + --- ## #217 — Character windows did not receive live 64-bit experience updates diff --git a/docs/README.md b/docs/README.md index 2ae73f6b..049cac76 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,23 +4,26 @@ This page is the entry point for project documentation. It distinguishes current sources of truth from implementation history so an old plan or issue banner cannot silently override the current program state. -## Current snapshot — 2026-07-20 +## Current snapshot — 2026-07-21 -- **Active milestone:** M3, “Cast a spell.” The automated implementation and - connected single-client magic/portal presentation gates are complete. -- **Next visual gates:** the R6 locomotion/collision/projectile/teleport retail - comparison, then the final two-client portal-out/materialization observer - comparison (`#218`). +- **Milestone state:** M3, “Cast a spell,” landed 2026-07-21. M4, “Live in the + world,” is next. +- **Active program:** behavior-preserving `GameWindow` decomposition before + new M4 subsystem work. The reconciled ownership ledger and slice order are + in [`architecture/code-structure.md`](architecture/code-structure.md); the + next slice finishes selection/interaction ownership. +- **Completed gates:** R6 locomotion/collision/projectile/teleport/radar and + the final two-client portal-out/materialization observer comparison (`#218`). - **Separate visual verification:** the shared-alpha lifestone/particle result in `#225`; its connected resource-lifetime and performance routes pass. - **Carried behavior debt:** `#153` (far teleport onto an unstreamed edge), `#116` (two narrowed slide-response cases), and divergence rows TS-50/TS-51. - **Deferred engineering tracks:** Modern Pipeline MP1b+ and Linux/headless - automation (Track LH). Neither is part of the active M3 gate. + automation (Track LH). Neither is part of the active structural campaign. - **Divergence audit:** 178 active rows — IA 17, AD 37, AP 85, TS 34, and UN 5 — plus the retained retired TS-37 history note. - **Latest automated baseline:** Release build succeeds with 17 known - test-project warnings (`#228`); 6,457 tests passed and 5 intentionally + test-project warnings (`#228`); 6,481 tests passed and 5 intentionally skipped. The unattended connected R6 route completed seven portal materializations, production input exercises, and a graceful close. See diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 18dbf3d6..4c3b6449 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -829,12 +829,18 @@ Current movement/collision ownership: - **G.3 is shipped**: dungeon streaming, portal-space presentation, collision, lighting, doors, and the full round-trip gate landed. The separate far- teleport unstreamed-edge residual is issue #153, not unfinished G.3. -- **M3** owns current connected magic/portal acceptance; Track LH is the future - presentation-independent Linux/headless extraction. +- **M3 landed 2026-07-21** with connected magic/portal acceptance and the final + two-client observer gate; Track LH is the future presentation-independent + Linux/headless extraction. +- **The behavior-preserving thin-`GameWindow` ownership campaign is the active + prerequisite before new M4 subsystem bodies.** Its reconciled ledger and + sequence live in `docs/architecture/code-structure.md`. This decision does + not reclassify the remaining L.2 conformance work or Track LH. -The GameEntity / thin GameWindow refactor remains a valid target architecture, -but it is not a prerequisite for L.2. Do not resurrect old R1-R8 phase numbers -for new work; add or update roadmap phases instead. +Full `GameEntity` type aggregation remains a separate, higher-risk target after +the thin-`GameWindow` campaign. The current campaign reuses canonical +`LiveEntityRuntime` rather than introducing another entity owner. Do not +resurrect old R1-R8 phase numbers for new work; update the roadmap instead. --- diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index b6bd3835..3c8fa7b7 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -1,11 +1,12 @@ # acdream — code structure & extraction sequence -**Status:** Living document. Created 2026-05-16 as the companion to the -"Code Structure Rules" section in `CLAUDE.md`. +**Status:** Living document. Created 2026-05-16; implementation reconciliation +completed 2026-07-21. This is the active structural program before new M4 +subsystems enter the App layer. **Purpose:** Describe the desired structural state of the App layer, explain the rules we've adopted, and lay out the safe extraction -sequence from today's reality (one 14,557-line `GameWindow.cs` at the -2026-07-20 audit) to the +sequence from today's reality (one 15,723-line `GameWindow.cs` at the +2026-07-21 audit) to the target (thin `GameWindow`, small focused collaborators). **Companion to:** [`acdream-architecture.md`](acdream-architecture.md) (the layered architecture) and @@ -21,30 +22,37 @@ layer is wire-compatible, the UI has a stable contract, plugins load. The structural debt is concentrated in **one file**: ``` -src/AcDream.App/Rendering/GameWindow.cs 14,557 lines (2026-07-20) +src/AcDream.App/Rendering/GameWindow.cs 15,723 lines (2026-07-21) + 258 fields / ~259 methods ``` `GameWindow` is the single object that: - Owns the GL context, the window, input, and shaders. - Reads ~40 different environment variables across its lifetime. -- Hosts the live network session (`WorldSession`) and the offline - pre-login state. -- Owns parallel dictionaries for entity lookup (`_entitiesByServerGuid`, - the per-landblock entity lists in `GpuWorldState`, plus the player - controller's own state). -- Drives selection / interaction (`WorldPicker`, `SendUse`, - `SendPickUp`). +- Still owns live-session bootstrap, character entry, roughly 440 lines of + event subscription, and most App-side packet handlers even though + `LiveSessionController` owns the socket/session shell. +- Owns the adapters that hydrate canonical `LiveEntityRuntime` records into + animation, collision, rendering, and DAT-backed appearance resources. +- Still drives selection / interaction (`WorldPicker`, target classification, + `SendUse`, `SendPickUp`, auto-walk deferral) despite the shipped item and + combat controllers. - Drives per-frame render orchestration (sky → terrain → opaque mesh → transparent mesh → particles → debug lines → UI). +- Builds and applies streamed landblock presentation (DAT decode, scenery, + EnvCells, mesh publication, collision, and retirement) around the shipped + `StreamingController` and `GpuWorldState` owners. - Wires up every plugin hook sink, every diagnostic, every panel. -Almost every M1 / M2 bug touches this file. Every new feature adds a -field plus a method plus a wiring call. It is not getting better on its -own. +The extracted controllers are valuable and tested, but line count alone proves +that creating collaborators has not yet made the window thin. A collaborator +is a completed extraction only when it owns the state and behavior body; +wrapping a `GameWindow` method in a delegate preserves ordering but remains a +partial extraction. -The fix is **not** "rewrite `GameWindow` in one pass" — that's a -high-risk change that would block M2. The fix is to **extract one +The fix is **not** "rewrite `GameWindow` in one pass" — that is a +high-risk change. The fix is to **extract one collaborator at a time**, verify behavior is unchanged, ship, and move on. This document defines that sequence. @@ -167,6 +175,7 @@ src/AcDream.App/ │ ├── GameWindow.cs # thin: GL/window lifecycle + delegates per-frame to RenderFrameOrchestrator │ ├── RenderFrameOrchestrator.cs # per-frame draw order (sky → terrain → opaque → trans → particles → debug → UI) │ ├── LiveEntityAnimationScheduler.cs # shipped: ordinary live-object update workset +│ ├── LiveEntityAnimationPresenter.cs # final part-pose/mesh/effect composition after scheduler output │ ├── RetailStaticAnimatingObjectScheduler.cs # shipped: separate static-animation workset │ ├── StaticLiveRootCommitter.cs # live static root → pose + collision boundary │ ├── TerrainModernRenderer.cs # (already exists) @@ -176,11 +185,13 @@ src/AcDream.App/ │ ├── Wb/ # WB seam + EnvCellLandblockBuild transaction │ └── Vfx/ # (already exists) ├── Net/ -│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect +│ ├── LiveSessionController.cs # owns complete WorldSession connect/enter/logout/reconnect lifecycle +│ └── LiveSessionEventRouter.cs # typed subscriptions → focused domain handlers ├── Physics/ │ ├── ProjectileController.cs # canonical live-record projectile orchestration │ ├── RemotePhysicsUpdater.cs # ordinary/Hidden remote narrow-tick integration │ ├── LiveEntityOrdinaryPhysicsUpdater.cs # manager-less canonical body Transition path +│ ├── LiveEntityNetworkUpdateController.cs # Position/Vector/State/Motion App integration │ ├── LiveEntityShadowPublisher.cs # authoritative exact-owner/residency collision gate │ ├── RemoteInboundMotionDispatcher.cs # shared animated/headless UpdateMotion funnel │ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership @@ -189,13 +200,18 @@ src/AcDream.App/ ├── World/ │ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots │ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation +│ ├── LiveEntityHydrationController.cs # spawn/appearance/parent/delete resource integration │ ├── RetailInboundEventDispatcher.cs # update-thread packet/frame FIFO barrier +│ ├── RetailLiveFrameCoordinator.cs # shipped: object/network/command/reconcile phase order │ ├── LiveEntityPresentationController.cs # ordered Hidden/NoDraw/effect/collision side effects │ ├── LiveEntityTeardown.cs # failure-isolated multi-owner lifecycle drain │ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations ├── Interaction/ -│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch -├── Streaming/ # LandblockStreamer + immutable LandblockBuild completion +│ ├── WorldSelectionQuery.cs # read-only picking/classification/description queries +│ └── SelectionInteractionController.cs # owns selection intents, Use/PickUp, auto-walk deferral +├── Streaming/ +│ ├── LandblockPresentationPipeline.cs # DAT build/apply/retire transaction and resource publication +│ └── ... # shipped streamer/world-state/reveal owners ├── Input/ # (already exists) ├── Audio/ # (already exists) └── Plugins/ # (already exists) @@ -206,22 +222,27 @@ What `GameWindow` keeps: - `IWindow` / `GL` / `IInputContext` lifecycle (constructor + `OnLoad` + `Run` + `OnClosing`). - `RuntimeOptions` reference (the typed startup config). -- One field per collaborator (`_liveSessionController`, - `_liveEntityRuntime`, `_selectionInteraction`, - `_renderFrameOrchestrator`). +- GL resource construction and top-level collaborator composition. Construction + is allowed here; feature algorithms and mutable subsystem state are not. +- One field per top-level collaborator (`_liveSessionController`, + `_liveEntityRuntime`, `_selectionInteraction`, `_streamingPresentation`, + `_liveObjectFrame`, `_renderFrameOrchestrator`). - The Silk.NET event-handler stubs that delegate to collaborators. What `GameWindow` loses: -- The 7 startup-time env var fields → moved into `RuntimeOptions`. -- `TryStartLiveSession` + the post-login network drain → moved into - `LiveSessionController`. -- `_entitiesByServerGuid` + per-entity dictionaries + ServerGuid↔Id - translation → moved into `LiveEntityRuntime`. -- `WorldPicker` + selection-driven `SendUse` / `SendPickUp` orchestration → moved - into `SelectionInteractionController`; Core `SelectionState` remains the - already-shipped session owner and is injected into that controller. -- Per-frame draw orchestration → moved into `RenderFrameOrchestrator`. +- Live connect/enter/logout and event-subscription bodies → completed inside + `LiveSessionController` / `LiveSessionEventRouter`. +- Live-object hydration adapters and final animated-part presentation → focused + world/render owners over canonical `LiveEntityRuntime` records. +- `WorldPicker`, target queries, and selection-driven Use/PickUp/auto-walk → + `WorldSelectionQuery` + `SelectionInteractionController`. Core + `SelectionState` remains the injected session owner. +- Landblock DAT build/apply/retirement presentation → + `LandblockPresentationPipeline`; `StreamingController` remains the residency + scheduler and `GpuWorldState` remains the spatial registry. +- Per-frame draw orchestration and its frame-local scratch state → + `RenderFrameOrchestrator`. The eventual `GameEntity` aggregation (target state described in `acdream-architecture.md` §"GameEntity: The Unified Entity") happens @@ -247,10 +268,14 @@ allocators fail fast instead of wrapping into another landblock's namespace. All other non-Parent packet families still need the future general queue tracked by divergence AD-32. -The per-frame object body is no longer an animation-dictionary loop inside -`GameWindow`. `LiveEntityAnimationScheduler` snapshots canonical spatial root -records and advances the incarnation-stable object clock, PartArray, hooks, -one selected movement owner, and manager tail in retail order. Manager-less +The per-frame object scheduler is extracted, but final animated-part +presentation is not. `LiveEntityAnimationScheduler` snapshots canonical spatial +root records and advances the incarnation-stable object clock, PartArray, +hooks, one selected movement owner, and manager tail in retail order. The +remaining `GameWindow.TickAnimations` loop still composes part transforms, +drawable `MeshRef`s, and effect poses from the scheduler output; moving that +body into `LiveEntityAnimationPresenter` is an explicit pending slice. +Manager-less bodies delegate their candidate/Transition/cell/shadow commit to `LiveEntityOrdinaryPhysicsUpdater`; retained projectile bodies and remote MovementManagers remain mutually exclusive movement owners. Static animation @@ -336,145 +361,194 @@ strand teleport, movement, shadow, light, or GUID-scoped state. --- -## 4. Extraction sequence — safest first +## 4. Reconciled ownership ledger — 2026-07-21 -Each step is **one PR-sized refactor**. Each must build clean, all -tests pass, and visual verification at Holtburg looks identical to -the previous step. Don't bundle two steps. +This section replaces the original six-step sketch. That sketch correctly +identified the target but overstated some extractions and omitted large bodies +added during M2/M3. The current audit is based on implementation ownership, not +whether a class with the planned name exists. -### Step 1 — `RuntimeOptions` — SHIPPED (`eda936dc`) +### 4.1 What counts as extracted -**Scope:** Replace startup-time env var reads with a typed options -object built once in `Program.cs`. +An extraction is **complete** only when: -**Behavior change:** None. Same env vars, same defaults, same effects. +1. the collaborator owns the mutable state and behavior body; +2. `GameWindow` constructs it and delegates through a narrow method; +3. the collaborator does not call back into arbitrary `GameWindow` methods; +4. focused tests exercise the collaborator without constructing a Silk window; +5. the accepted connected/visual behavior is unchanged. -**Risk:** Low. Mechanical substitution at ~10-15 call sites in -`GameWindow.cs` + one constructor signature change. +A class that stores delegates back to substantial `GameWindow` methods is a +useful ordering seam, but its ownership status is **partial**. -**Test:** Unit tests for `RuntimeOptions.FromEnvironment` parsing (the -new `tests/AcDream.App.Tests/` project). +### 4.2 Current implementation truth -**Verification:** `dotnet build` + `dotnet test` green. Visual launch -verifies live mode + dat dir resolution still work. +| Area | Status | Current truth | +|---|---|---| +| Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. | +| Network session | **Partial** | `LiveSessionController` owns endpoint resolution, `WorldSession` construction, ticking, and disposal (`0b25df53`). `TryStartLiveSession`, character choice/entry, session clearing, about 440 lines of subscriptions, and most App packet handlers remain in `GameWindow`. | +| Live identity/lifetime | **Complete core owner** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, logical/spatial lifetime, and teardown. `GameWindow` still owns large hydration and wire-to-presentation adapter bodies. | +| Inbound/object-frame order | **Partial** | `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveEntityAnimationScheduler`, static scheduler, remote/projectile updaters, and teleport owners are shipped. `AdvanceLiveObjectRuntimeCore`, final part/mesh/effect composition, and several integration tails remain in `GameWindow`. | +| World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. | +| Retained gameplay UI | **Mostly complete feature ownership** | `RetailUiRuntime` and focused panel/controllers own layout and behavior. `GameWindow.OnLoad` still performs substantial service composition, which is allowed until the final composition cleanup. | +| Selection/interaction | **Partial** | `ItemInteractionController`, `CombatTargetController`, and `CombatAttackController` own focused rules. `GameWindow` still owns picking, target queries, selection intent, Use/PickUp sends, range/auto-walk deferral, and related input cases. | +| Landblock presentation | **Not extracted** | Residency scheduling is extracted, but DAT build, scenery/EnvCell construction, apply, collision/resource publication, and retirement glue remain a large `GameWindow` body. | +| Render-frame orchestration | **Not extracted** | `OnRender` plus its portal/PView/alpha/particle helpers still own the draw graph and frame-local scratch state. There is no `RenderFrameOrchestrator` yet. | +| Unified `GameEntity` | **Deferred target** | `LiveEntityRuntime` already supplies the critical canonical owner. Full type aggregation is a separate high-risk migration and is not required to make `GameWindow` thin. | -### Step 2 — `LiveSessionController` — SHIPPED (`0b25df53`) +### 4.3 Revised extraction sequence -**Scope:** Extract `TryStartLiveSession` + the WorldSession ownership + -the post-EnterWorld drain (`OnLiveStateUpdated`, `OnLiveEntityDeleted`, -etc.) into a controller class. +Every numbered slice is behavior-preserving and independently committed. A +slice does not include feature work or opportunistic gameplay fixes. -**Behavior change:** None. Same wire behavior, same handshake. +#### Gate 0 — deterministic baseline — COMPLETE -**Risk:** Medium. WorldSession lifecycle is load-bearing — every -session-state crash would surface here. The change is a class -extraction with the same event subscriptions, not a rewrite. +The Release suite, connected R6 soak, world-lifecycle screenshots/checkpoints, +graceful reconnect, local locomotion/collision/projectile/teleport gate, and +two-client portal observer gate form the pre-refactor baseline. Each later +slice runs the subset capable of detecting its risk; render/session slices run +the complete connected lifecycle gate. -**Test:** Existing `AcDream.Core.Net.Tests` already cover the wire -layer. The controller itself gets a smoke test that verifies it can be -constructed without a live socket (offline mode). +#### Slice 1 — finish selection/interaction ownership — NEXT -**Verification:** Visual login + Holtburg traversal + door interaction -identical to pre-extraction. +Split the old Step 4 into three reviewable commits: -### Step 3 — `LiveEntityRuntime` — SHIPPED 2026-07-14 +1. `WorldSelectionQuery` receives camera/scene/live-object read seams and owns + picking, target classification, selection bounds, closest-target lookup, + names, and descriptions. It cannot mutate selection or send packets. +2. `SelectionInteractionController` owns selection intents, double-click Use, + Use/PickUp packet requests, range decisions, speculative facing, and the + pending auto-walk action. It composes the existing item/combat controllers + rather than duplicating their rules. +3. Selection/Use/PickUp/combat-target `InputAction` cases delegate to the new + controller; the old methods and fields are deleted from `GameWindow`. -**Shipped scope:** One `LiveEntityRecord` per server-object incarnation now owns -ServerGuid↔local-ID translation, accepted state, runtime components, parent -relations, logical resource activation, exact teardown, and spatial projection -state. `RegisterLiveEntity`, `RebucketLiveEntity`, and `UnregisterLiveEntity` -make the lifetime boundary explicit. Landblock unload/reload moves the same -`WorldEntity`; it cannot reconstruct from a stale CreateObject or replay setup -scripts. Equipped children use an attached projection and never enter the -top-level target/radar/status view. Canonical materialized lookup remains -available while a projection is pending; the separate visible view is the only -surface radar, picking, status, and targeting consume. Pickup/parent leave-world -clears cell membership and pauses root movement/animation without destroying the -retained owners. Top-level spawn publication is one-shot per incarnation, so -leave/re-entry restores presentation without duplicating plugin event replay. +Tests cover read-only query classification separately from stateful intents, +including direct use, distant auto-walk completion, corpse/container opening, +pickup placement, hostile-only targeting, Hidden objects, and same-GUID reuse. +The connected gate is select → use door/NPC → distant corpse → loot/pickup → +combat target. This is the safest next slice because its outbound boundaries +are typed and existing controllers already hold most retail rules. -**Remaining target:** the player-specific controller is still a separate -aggregation. The focused effect queue is shipped; AD-32 now tracks only the -future non-effect, non-Parent packet queue. +#### Slice 2 — finish live animation presentation -**Behavior change:** Spatial withdrawal and re-entry now preserve logical -identity and active resources instead of replaying create-time effects. +Move `TickAnimations`, final rigid/visual part composition, effect-pose +publication, and motion-done binding into `LiveEntityAnimationPresenter`. +`LiveEntityAnimationScheduler` remains the time/movement owner; the presenter +only consumes one scheduler result and publishes the final draw/effect pose. +Move the surviving motion diagnostics with this owner instead of adding more +environment reads to `GameWindow`. -**Risk:** Medium-high. Entity lookup is in every hot path. The change -is structural (one owner instead of three) but the lookup semantics -must be byte-identical. +Tests pin legacy and sequencer paths, Hidden/static eligibility, ObjScale versus +rigid effect poses, part availability, same-frame hook pose publication, and +incarnation replacement. Run the R6 object-frame and projectile/effect suites +plus the connected locomotion/projectile gate. -**Test:** `LiveEntityRuntimeTests` cover duplicate CreateObject, generation -replacement, appearance mutation, loaded/pending rebucketing, attached -projection, pickup leave/re-entry, canonical-versus-visible lookup, resource -rollback, GUID reuse, and idempotent session teardown. +#### Slice 3 — complete live-session ownership -**Verification:** Walk Holtburg, click NPC, open door, pick up item. -All four M1 demo targets must still work. +Expand `LiveSessionController` to own connect, character-list validation, +character selection, EnterWorld, graceful character logout, reconnect, and +session-scoped disposal. Add `LiveSessionEventRouter` for typed subscriptions; +it routes events to focused domain handlers and owns unsubscription, but owns +no entity or UI state. -### Step 4 — `SelectionInteractionController` +Move `TryStartLiveSession`, `ClearInboundEntityState`, and +`WireLiveSessionEvents` out of `GameWindow`. Do not create a giant context bag +or a second world-state owner. The connected gate must prove login, fresh world +reveal, command/chat, portal, exact `0xF653` logout, ACE endpoint disconnect, +and fresh-process reconnect. -**Scope:** Extract `WorldPicker`, the Core `SelectionState` consumers, `SendUse`, -`SendPickUp`, and the `InputAction.Select*` / `UseSelected` / -`SelectionPickUp` switch cases into one controller. Depends on Step 3 -(uses `LiveEntityRuntime`). +#### Slice 4 — extract live-entity App integration -**Behavior change:** None. +This is two owners, not one replacement god object: -**Risk:** Low-medium. Selection state is local to interactions; the -network outbound side is well-defined (`InteractRequests.BuildUse` / -`BuildPickUp`). +- `LiveEntityHydrationController` owns CreateObject/ObjDesc/Parent/Pickup/Delete + integration, DAT-backed appearance hydration, resource registration, and + exact teardown callbacks over `LiveEntityRuntime`. +- `LiveEntityNetworkUpdateController` owns App routing for accepted + Position/Vector/State/Movement updates into the existing remote physics, + motion, projectile, presentation, and teleport controllers. -**Test:** Selection state machine tests in `tests/AcDream.App.Tests/`. +Neither may own a GUID dictionary. Both resolve the current incarnation only +through `LiveEntityRuntime` and must preserve the inbound FIFO/authority-token +rules. Run the complete live-entity stress suite, R6 connected route, inventory +equip/parenting, death/corpse, and portal gates. -**Verification:** Click-to-select, double-click-to-Use, F-key pickup -all still work. +#### Slice 5 — extract landblock presentation -### Step 5 — `RenderFrameOrchestrator` +Create `LandblockPresentationPipeline` around the existing immutable +`LandblockBuild` transaction. It owns DAT build context, scenery/EnvCell +construction, mesh/collision/light publication, demotion/full-retirement, and +the single-reader DAT lock contract. `StreamingController` continues deciding +what is resident; `GpuWorldState` continues owning spatial buckets. -**Scope:** Extract the per-frame draw sequence (sky → terrain → -opaque mesh → translucent mesh → particles → debug → UI) into a -dedicated orchestrator that `GameWindow.OnRender` delegates to. +Tests pin loaded/pending/demoted/unloaded symmetry, stale build generations, +resource pin/release balance, collision footprints across landblock seams, and +first-login bootstrap replacement. Run the deterministic world-lifecycle gate +and the seven-destination resource soak. -**Behavior change:** None. Same draw order, same GL state. +#### Slice 6 — extract update-frame orchestration -**Risk:** Medium. GL state management is touchy; the orchestrator -must hand the GL context to the same renderers in the same order with -the same per-pass state setup. +After the stateful bodies above have owners, make the update path a real +orchestrator instead of delegates back into the window. It owns the fixed +retail phase order: input/local object → ordinary/static objects → hooks/ +particles/scripts → inbound network → command interpreter → non-advancing +spatial reconcile → streaming/UI updates. `GameWindow.OnUpdate` becomes a +short time/input handoff. -**Test:** Visual verification only. Render orchestration is hard to -unit-test without a GL context. +Frame-order tests and the existing R6 gate must produce the same lifecycle and +movement traces before and after extraction. -**Verification:** Holtburg at radius 4, radius 8, radius 12 looks -identical across all four quality presets. +#### Slice 7 — extract `RenderFrameOrchestrator` -### Step 6 — `GameEntity` aggregation (the big one) +Move the complete draw graph and its reusable frame-local scratch state into a +GL-owning App collaborator. Preserve the exact modern pipeline order, clip +routing, PView flood, landscape/opaque/shared-alpha flush boundaries, +particles, debug draw, paperdoll, retained UI, and frame fences. Do not pass a +hundred individual delegates or let the orchestrator reach back into +`GameWindow`; inject a small immutable service set plus explicit per-frame +input. -**Scope:** Consolidate `WorldEntity` + `AnimatedEntity` + the per-entity -state in `LiveEntityRuntime` into one `GameEntity` class (the target -described in `acdream-architecture.md`). Every entity in the world — -player, NPC, monster, door, item — becomes a single `GameEntity`. +Automated acceptance uses framebuffer artifacts and render/resource +checkpoints. Visual acceptance compares outdoor, building, dungeon, portal +exit, translucent lifestone/particles, and UI at the same camera positions. -**Behavior change:** None at the wire / visual level; substantial at -the call-site level (everyone moves to the new entity API). +#### Slice 8 — composition and shutdown cleanup -**Risk:** High. Touches every system that reads entity state. +Keep GL/window construction in `GameWindow.OnLoad`, but group creation into +small composition functions and delete feature state left behind by prior +slices. `OnClosing` delegates to the existing retryable shutdown transaction. +Silk callbacks become narrow calls into the input, update, render, resize, +focus, and shutdown owners. -**Test:** All existing tests + the new `AcDream.App.Tests` suite. Visual -verification at every M1 / M2 scenario. +### 4.4 Exit criteria -**Verification:** Full M2 demo loop (equip sword, kill drudge, pick up -loot, open inventory) works identically. +The campaign is complete when: + +- `GameWindow` contains no AC gameplay algorithm, entity scan, packet builder, + DAT landblock builder, animation-part composer, or draw-graph body; +- `OnUpdate`, `OnRender`, `OnInputAction`, and live-session callbacks are short + delegation methods; +- every extracted owner has App-layer tests and symmetric teardown; +- the Release suite and connected lifecycle/resource gates stay green after + every slice; +- the final local visual matrix passes unchanged. + +Line count is a progress signal, not the acceptance test. The expected result +is below roughly 5,000 lines, but ownership and dependency direction decide +completion. Full `GameEntity` type aggregation is evaluated only afterwards as +a separate migration; it is not folded into this campaign. --- ## 5. Rules of the road during the extraction -1. **One step at a time.** A PR that ships Step 1 ships only Step 1. - Bundling steps makes failures hard to isolate. -2. **Behavior preservation is the acceptance criterion.** Every step +1. **One slice at a time.** Each commit ships one ownership boundary or + mechanical call-site cutover. Bundling slices makes failures hard to + isolate. +2. **Behavior preservation is the acceptance criterion.** Every slice must build clean, all tests pass, and visual verification at the - appropriate M1 / M2 scenarios must succeed. We're moving code, not + appropriate accepted milestone scenarios must succeed. We're moving code, not changing it. 3. **No new features during an extraction step.** If you spot a real bug while extracting, file it in `docs/ISSUES.md` and address it in @@ -484,9 +558,15 @@ loot, open inventory) works identically. moves to a new owner, the diagnostic flag inside it can move to a diagnostic class as part of the same commit. We do not do a bulk diagnostic-cleanup pass. -5. **Update this document when the plan changes.** If Step 3 turns out - to need a different shape than described above, update §4 in the +5. **Update this document when the plan changes.** If a slice turns out + to need a different ownership shape than described above, update §4 in the same session you discover the divergence. +6. **No façade-only completion claims.** A delegate from a new class back to a + substantial `GameWindow` method is a useful intermediate seam, not a + completed extraction. +7. **No duplicate ownership.** New collaborators query `LiveEntityRuntime`, + `GpuWorldState`, `SelectionState`, or the relevant existing owner. They do + not create replacement GUID, visibility, session, or resource maps. --- @@ -494,8 +574,9 @@ loot, open inventory) works identically. - **Not a full rewrite plan.** The point is the *opposite* — small steps, verified at each boundary. -- **Not blocking M2.** Step 1 is small enough to ship without - disrupting M2 work. Later steps interleave with M2 / M3 phases as - the corresponding code paths come into focus. +- **Not a feature phase.** Following the 2026-07-21 user decision, this + behavior-preserving campaign is the structural prerequisite before new M4 + subsystem bodies are added. Severe regressions remain fixable in separate + commits; ordinary feature work waits. - **Not a substitute for the milestones / roadmap.** Those drive the feature work. This drives the structural work that runs underneath. diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 5dc4c21a..2b122386 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,11 +1,32 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-07-20. **M3 active; automated implementation and connected single-client magic/portal gates complete.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels with authored rows and selected-spell details, the complete seven-control gameplay indicator strip and its Character Information/Link/Vitae detail pages, and shared `gmPanelUI` geometry/switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. Fresh-login and portal world reveal are additionally protected by a deterministic capped/uncapped connected lifecycle gate with screenshots, resource snapshots, exact graceful disconnect, and fresh-process reconnect. The next gate is a local visual rebaseline of the new R6 object-frame path; the final M3 requirement is the two-client portal-out/materialization observer gate. Issue #225's lifestone/particle alpha comparison is a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-21. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); new M4 quest/emote/character-creation feature bodies wait until that campaign is complete. 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. --- -## Current program: M3 — Cast a spell +## Current program: `GameWindow` structural stabilization (M4 prerequisite) + +**Outcome:** `GameWindow` becomes a thin Silk.NET composition/lifecycle shell +without changing accepted gameplay or rendering behavior. Focused App owners +take responsibility for selection, animation presentation, live-session and +live-entity integration, landblock presentation, and update/render frame +orchestration. + +**Current sequence:** The implementation audit, exact ownership ledger, slice +boundaries, tests, connected gates, and exit criteria live in +[`docs/architecture/code-structure.md`](../architecture/code-structure.md) §4. +The next slice is selection/interaction: extract the read-only world query, +move stateful Use/PickUp/auto-walk intent into one controller, then cut the +related input cases over and delete the old `GameWindow` bodies. + +This is a behavior-preserving structural program. Severe regressions still get +root-cause fixes in separate commits; ordinary feature work resumes with M4 +after the campaign exit criteria pass. + +--- + +## Completed program: M3 — Cast a spell (landed 2026-07-21) **Outcome:** Cast a projectile spell at a hostile target, see the complete turn/windup/release/projectile/impact lifecycle, self-cast a buff and see its @@ -39,14 +60,14 @@ active enchantment, then recall through the DAT-driven portal presentation. same-location revisit, graceful logout, and uncapped fresh-process reconnect. The gate records structured readiness/resource checkpoints and full-frame PNGs, and rejects early world visibility or stranded owners. -5. **Pending local visual gate:** locomotion, collision, projectile, and - teleport comparison after the R6 complete-root-Frame/object-workset cutover. -6. **Pending two-client visual gate:** portal-out/materialization observer - presentation before M3 closes, including #218's finished Hidden silhouette, - fully rendered destination reveal, and no indoor stop-time position snap. +5. **Done (connected user gate):** locomotion, collision, projectile, teleport, + and radar comparison after the R6 complete-root-Frame/object-workset cutover. +6. **Done (two-client user gate):** portal-out/materialization observer + presentation, including #218's finished Hidden silhouette, fully rendered + destination reveal, and no indoor stop-time position snap. -Opportunistic UI/physics fixes are no longer the work-order driver. They enter -the active slice only when they block this demo or represent a severe regression. +M3 is closed. Its detailed ledger remains here as the landing record; the +structural program above is the only current work-order driver before M4. --- @@ -937,8 +958,7 @@ diagnostic scaffolding, not yet the final collision system. and 0.2 -> 0.3 ms at Sawato. ACE generator population changes are reported rather than treated as client leaks. Absolute FPS/GPU timing remains session/display-path evidence, not an RDP-stable gate. The connected visual - locomotion/collision/projectile/teleport comparison remains for the next - local viewing session. + locomotion/collision/projectile/teleport/radar comparison passed 2026-07-21. - **L.2g — Inbound motion interpretation (remote-entity CMotionInterp funnel).** ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL remote entities (players, NPCs, monsters — one funnel, user-approved): @@ -1453,7 +1473,7 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen. ## Cross-cutting work tracked in parallel -- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,480 passed / 5 intentional skips (2026-07-20). +- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,481 passed / 5 intentional skips (2026-07-21). - **Memory files.** Live subsystem state and digests are indexed by `claude-memory/MEMORY.md`; stable engineering references remain under `memory/`. Canonical program status stays in milestones/roadmap/issues. - **`CLAUDE.md` discipline.** Check all four references (ACE, ACViewer, WorldBuilder, Chorizite) before committing to an approach. WorldBuilder is the closest stack match and should be checked first. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index a24c15fe..1819bd27 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -2,28 +2,28 @@ **Status:** Living document. Created 2026-05-12. **Sits above:** [`docs/plans/2026-04-11-roadmap.md`](2026-04-11-roadmap.md) (the strategic phase index). -**Currently working toward:** **M3 — "Cast a spell." M2 — "Kill a drudge" -LANDED 2026-07-15.** The complete connected melee/missile, death/loot, -inventory, and item-giving demo is user-gated. The shared projectile/effect -foundation is hardened through Step 9. Spellbook/component-book filtering, -favorite spell bar, connected casting/enchantment effects, and portal-space -presentation have passed their single-client visual gates. The corrective bounded -render/resource-lifetime integration passed its seven-destination connected -stress gate without a crash or cumulative collapse. A second deterministic -world-lifecycle gate now protects fresh login, outdoor/world-edge/dungeon -travel, same-location revisit, exact graceful disconnect, and an uncapped -fresh-process reconnect with canonical readiness snapshots and saved PNG/JSON -artifacts. The remaining M3 visual requirement is the final two-client -portal-out/materialize observer gate. The new R6 object-update cutover has -passed its unattended connected input/portal/performance rebaseline; its -locomotion/collision/projectile/teleport visual gate remains for the next local -viewing session before that observer gate. +**Currently working toward:** **M4 — "Live in the world." M3 — "Cast a +spell" LANDED 2026-07-21.** Projectile/self-buff casting, protection effects, +spellbook/component-book filtering and deletion, favorite spell bars, active +effects, recall, portal-space presentation, and the complete two-client +portal-out/materialization observer flow are user-gated. The R6 locomotion, +collision, projectile, teleport, and radar rebaseline also passes. Deterministic +world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon +travel, same-location revisit, exact graceful disconnect, and uncapped +fresh-process reconnect with canonical JSON/PNG evidence. + +Before new M4 quest/emote/character-creation subsystem bodies enter the App +layer, the active structural prerequisite is the behavior-preserving +`GameWindow` decomposition in +[`docs/architecture/code-structure.md`](../architecture/code-structure.md). +The 2026-07-21 audit found 15,723 lines, 258 fields, and about 259 methods; +the next slice finishes selection/interaction ownership. + Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus registered TS-50/TS-51 timing residuals (the complete-root-Frame/object-workset -cutover is automated-test complete 2026-07-20; three independent re-reviews are -clean, Release builds with 17 known test-project warnings tracked by #228, and -6,480 tests pass / 5 skip after the world-reveal gate), and +cutover is automated- and visual-gate complete; Release builds with 17 known +test-project warnings tracked by #228, and 6,481 tests pass / 5 skip), and the deferred Modern Pipeline track (MP1b+). The separate #225 shared-alpha implementation has passed connected lifetime/performance gates and awaits only its lifestone/particle visual comparison. @@ -662,8 +662,8 @@ include dungeons. publishes outdoor landcells independently of surviving object meshes. Terrain/entity streaming distance is unchanged; the user-requested default Extended setting doubles only particle - range, while Retail remains available as the exact cutoff. The - final two-client visual gate is still pending. + range, while Retail remains available as the exact cutoff. The final + two-client visual gate passed 2026-07-21. - **L.1c local attack receive path (implemented and live-gated 2026-07-15)** — local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted funnel and action-stamp gate, so ACE's server-selected melee/missile action @@ -713,7 +713,17 @@ a game. --- -### M3 — "Cast a spell" — 🔵 ACTIVE (started 2026-07-15) +### M3 — "Cast a spell" — ✅ LANDED 2026-07-21 + +**Landing writeup:** Mages now work end to end. A connected player can manage +the retail spellbook/component book and favorite bars, cast projectile and +self-targeted spells with the correct components/target gates, see traveling +missiles and DAT-driven protection/impact effects, inspect live enchantments, +and recall or portal through the retail tunnel/reveal lifecycle. The final +two-client observer gate confirms departure disappearance, destination +materialization, finished pose, rendered destination, and indoor movement +without snap-back. The R6 local locomotion/collision/projectile/teleport and +radar checks pass on the same Release baseline. **Demo scenario:** Cast Flame Bolt at a drudge. Watch the cast animation, the projectile, the impact. Self-cast a buff (Strength Self). See the @@ -740,9 +750,9 @@ The strip now uses retail DAT states for link quality, Helpful/Harmful effects, Vitae, burden, mini-game availability, and end-character-session; live link age, player load, and enchantment registries drive their retail thresholds and the burden/effect/logout actions use the shared panel/dialog owners. Main-panel -visibility now flows through retail's one-active `gmPanelUI` owner. M3 remains -active only for the connected single-client magic -UI/casting visual gate and the final two-client portal observer gate. +visibility now flows through retail's one-active `gmPanelUI` owner. The +connected single-client magic UI/casting gates and final two-client portal +observer gate passed before landing. The 2026-07-17 indicator follow-up also mounts authored Character Information, Link Status, Vitae, and Mini Game detail roots as children of that same main-panel owner. Helpful, Harmful, Character Information, Link, and Vitae @@ -795,7 +805,12 @@ from M3 = retail-equivalent gameplay loop for melee and casters. --- -### M4 — "Live in the world" — 🔵 (~6–10 weeks) +### M4 — "Live in the world" — 🟡 NEXT (~6–10 weeks) + +**Entry condition:** complete the behavior-preserving `GameWindow` +decomposition campaign before adding H.3/H.4/L.1e feature bodies. This protects +M4 from deepening the App-layer composition debt; it does not change M4's demo +scope. **Demo scenario:** Create a fresh character from scratch (no ACE admin). Spawn. Talk to an NPC. Accept a quest. Walk to a dungeon entrance. Portal From c271383714667ac604f11310c14ce4ebebe7f361 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 06:59:56 +0200 Subject: [PATCH 11/98] docs(interaction): plan GameWindow selection slice Define the read-only query and stateful interaction ownership boundaries, pin retail ordering and lifecycle invariants, and isolate the GUID-incarnation and pending-pickup defects as #230 and #231 before the mechanical cutover. Co-authored-by: OpenAI Codex --- docs/ISSUES.md | 69 ++++ docs/architecture/code-structure.md | 3 + ...amewindow-slice-1-selection-interaction.md | 331 ++++++++++++++++++ 3 files changed, 403 insertions(+) create mode 100644 docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6f504034..dbe75065 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -67,6 +67,75 @@ Copy this block when adding a new issue: --- +## #231 — F-key pickup omits retail pending destination-slot presentation + +**Status:** IN-PROGRESS — isolated as Slice 1 corrective commit +**Severity:** MEDIUM +**Filed:** 2026-07-21 +**Component:** selection / inventory / retained UI + +**Description:** World double-click loot reserves its destination cell with the +gray pending mesh before the request, but `SelectionPickUp` queues `SendPickUp` +directly and skips that presentation. The item can therefore settle into the +backpack without the same immediate first-slot reservation retail shows. + +**Root cause / status:** Retail `CPlayerSystem::PlaceInBackpack @ 0x0055D8C0` +always sends `CM_Item::SendNotice_ShowPendingInPlayer` before +`AttemptToPlaceInContainer`, including the F-key action from +`CPlayerSystem::OnAction @ 0x00561964`. acdream's double-click path enters +`ItemInteractionController` and raises `PendingBackpackPlacementRequested`; +the F-key switch case bypasses that owner. + +**Files:** `src/AcDream.App/Rendering/GameWindow.cs`; +`src/AcDream.App/UI/ItemInteractionController.cs`; +future `src/AcDream.App/Interaction/SelectionInteractionController.cs`. + +**Research:** +[`docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md`](plans/2026-07-21-gamewindow-slice-1-selection-interaction.md). + +**Acceptance:** F-key pickup validates the target, publishes the exact +destination/placement pending reservation, then sends one authoritative request. +Server confirmation settles it; server failure removes it without speculative +ownership mutation. + +--- + +## #230 — Selection hits and deferred actions can cross live GUID incarnations + +**Status:** IN-PROGRESS — isolated as Slice 1 prerequisite hardening +**Severity:** HIGH +**Filed:** 2026-07-21 +**Component:** selection / live entity lifetime / interaction + +**Description:** The completed render-selection frame and a pending close-range +Use/PickUp store only the server GUID. If the object hides, is deleted, or is +replaced with the same GUID before the next pick/completion, old presentation or +movement state can resolve to the replacement incarnation. Session reset also +leaves item target mode and the render-selection pulse/frame outside one atomic +interaction reset. + +**Root cause / status:** `RetailSelectionPart`/`RetailSelectionHit`, click +lighting, and `PendingPostArrivalAction` lack the canonical local-entity/record +identity owned by `LiveEntityRuntime`. Teardown's GUID-only replacement guard +correctly protects new selection, but necessarily skips clearing a GUID-only +pending action. `CombatTargetController` also treats SessionReset like an +ordinary clear and can re-acquire before live records drain. + +**Files:** `src/AcDream.Core/Selection/RetailSelectionMesh.cs`; +`src/AcDream.App/Rendering/Selection/`; +`src/AcDream.App/Rendering/GameWindow.cs`; +`src/AcDream.App/Combat/CombatTargetController.cs`. + +**Research:** +[`docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md`](plans/2026-07-21-gamewindow-slice-1-selection-interaction.md). + +**Acceptance:** Hidden/pending/deleted/replaced objects reject stale render hits; +old click lighting never colors a replacement; natural MoveTo completion sends +only for the captured current visible incarnation; session reset clears the +complete interaction lifetime and never auto-targets. + +--- + ## #228 — Clean Release build emits 17 test-project warnings **Status:** OPEN diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 3c8fa7b7..5f1d379f 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -411,6 +411,9 @@ the complete connected lifecycle gate. #### Slice 1 — finish selection/interaction ownership — NEXT +Detailed execution plan: +[`docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md`](../plans/2026-07-21-gamewindow-slice-1-selection-interaction.md). + Split the old Step 4 into three reviewable commits: 1. `WorldSelectionQuery` receives camera/scene/live-object read seams and owns diff --git a/docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md b/docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md new file mode 100644 index 00000000..8a4629da --- /dev/null +++ b/docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md @@ -0,0 +1,331 @@ +# GameWindow Slice 1 — selection and interaction ownership + +**Status:** Approved for execution 2026-07-21. +**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 1. +**Baseline:** `cf50ee3d`; `GameWindow.cs` is 15,723 lines before this slice. +**Behavior rule:** Existing retail behavior moves unchanged. Any defect found by +the extraction audit is fixed in a separately identified commit before the +mechanical cutover. + +## 1. Outcome + +`GameWindow` stops owning world selection and selection-driven interaction. +After this slice it retains only composition and narrow lifecycle calls: + +- construct the query/controller; +- feed the completed render-selection frame; +- drain outbound interactions at the existing retail frame boundary; +- forward input actions and MoveTo completion; +- forward entity hidden/removal and session-reset edges. + +Two App owners replace the current body: + +1. `WorldSelectionQuery` owns read-only world selection, eligibility, + classification, naming, closest-hostile lookup, target bounds, and range + queries over canonical live state. +2. `SelectionInteractionController` owns selection intents, click routing, + Use/PickUp ordering, speculative facing/movement, deferred close-range + requests, and the input-action boundary. + +Core `SelectionState` remains the sole selected-object truth. +`ItemInteractionController` remains the sole retail ItemHolder policy owner. +`LiveEntityRuntime` remains the sole GUID/incarnation/visibility owner. + +## 2. Retail and project oracles + +No AC algorithm is invented in this slice. Move the existing ports with these +references attached: + +- [`2026-07-17-retail-world-selection-pseudocode.md`](../research/2026-07-17-retail-world-selection-pseudocode.md) + — render-coupled part selection, selection sphere, vivid target, click pulse. +- [`2026-07-10-retail-toolbar-interaction-pseudocode.md`](../research/2026-07-10-retail-toolbar-interaction-pseudocode.md) + — target-mode precedence, ItemHolder Use, selection notices. +- [`2026-07-17-retail-external-container-looting-pseudocode.md`](../research/2026-07-17-retail-external-container-looting-pseudocode.md) + — corpse/container open, range, pending loot placement. +- [`2026-07-12-death-and-auto-target-pseudocode.md`](../research/2026-07-12-death-and-auto-target-pseudocode.md) + — synchronous selection clear and reentrant Auto Target. +- `Render::GfxObjUnderSelectionRay @ 0x0054C740`. +- `ACCWeenieObject::SetSelectedObject @ 0x0058C2E0`. +- `CPlayerSystem::OnAction @ 0x00561890`. +- `CPlayerSystem::PlaceInBackpack @ 0x0055D8C0`. +- `ItemHolder::UseObject @ 0x00588A80`. +- `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0`. + +Existing registered adaptations remain adaptations; this structural slice does +not silently “improve” them: + +- IA-19: automatic acquisition is narrowed to hostile non-player monsters. +- AD-11: ACE null-useability fallback. +- AD-27: natural `MoveToComplete` is the client completion seam. +- AP-23: speculative local Use movement and 3/2/0.6-unit radius buckets. + +Their file pointers move in the same commit as their implementation bodies. + +## 3. Invariants + +### 3.1 Render selection + +- The renderer builds a private list, then publishes it only at + `CompleteFrame`; input reads the previous complete frame. +- A hit carries server GUID **and local entity ID**. The query accepts it only + if `LiveEntityRuntime` still resolves the same current, visible top-level + incarnation. +- Plain selection excludes self. Active target mode includes self. +- A successful hit starts the HIGH click-light pulse before target-mode or + selection routing. +- Target-mode success or rejection consumes the click and never falls through + to ordinary selection/use. +- A miss does not clear selection. A miss during target mode leaves target mode + active. +- Drag release may pulse the target, but does not select it. +- Vivid target markers deliberately query materialized state so selected + objects remain indicated through walls and long distances. Picking and new + combat acquisition use visible interaction state only. + +### 3.2 Input and outbound ordering + +- Keep the one `InputDispatcher.Fired` subscription in `GameWindow`. +- Combat attack handling, Press/DoubleClick filtering, and retained-UI capture + occur before delegation exactly as today. +- The controller handles only the currently implemented semantic actions: + previous selection, closest monster, left selection, double-left selection, + Use selected, and PickUp selected. +- Input callbacks enqueue Use/PickUp. The queue drains after local movement + output and before inbound packet dispatch. +- The queue drains only its boundary snapshot; reentrant enqueue waits until + the next frame. + +### 3.3 Use/PickUp and movement + +- `ItemInteractionController` decides Use/open/target mode. The new controller + does not copy ItemHolder policy. +- Far target: install speculative MoveTo and send exactly one wire request + immediately. ACE owns its arrival callback. +- Close target: install TurnToObject and defer the **first** request until + natural `MoveToComplete(None)`. +- Cancel/error completion never sends. +- The movement intent preserves target cylinder radius/height, the AP-23 use + radius, and the 7.5-unit CanCharge prediction. +- Every queued/deferred request is bound to the captured live incarnation and + revalidated before sending. +- Pickup preserves destination container and placement. Retail pending-slot + presentation occurs before the request. + +### 3.4 Lifecycle + +- Hidden, logical removal, session reset, and GUID replacement invalidate old + hits and pending actions immediately. +- Token-specific pending cleanup is allowed even after GUID replacement; + GUID-only selection cleanup retains the existing incarnation guard so old + teardown cannot clear a newly selected replacement. +- Session reset atomically clears selection history, outbound queue, deferred + action, item target mode/busy state, published selection frame, and click + pulse. +- `CombatTargetController` must not auto-acquire during `SessionReset`. +- No new GUID map, selection store, visibility store, or retry timer is added. + +## 4. Target interfaces + +### 4.1 Render hit identity + +`RetailSelectionPart` and `RetailSelectionHit` carry `LocalEntityId` alongside +`ServerGuid`. `RetailSelectionScene.Pick` returns the complete hit. Its lighting +source is also keyed by both identities so a reused GUID cannot inherit an old +click pulse. + +`RetailSelectionScene.Reset` clears building/published lists, frame keys, +frustum state, and pulse state. Geometry resolution receives a narrow internal +source interface so publication behavior is testable without installed DAT. + +### 4.2 `WorldSelectionQuery` + +The query receives: + +- canonical `LiveEntityRuntime`; +- `ClientObjectTable`; +- the shared `RetailSelectionScene`; +- player GUID and current player position providers; +- camera/viewport and cursor providers; +- a DAT selection-sphere resolver; +- the existing Setup-cylinder resolver. + +It exposes read-only operations for: + +- pick at cursor/screen point with exact-incarnation validation; +- current interaction record/entity lookup; +- item type, name, and description; +- creature/hostile/health eligibility; +- closest visible hostile result without mutating selection; +- combat camera target; +- vivid target information and selection sphere; +- useability/pickupability; +- AP-23 use radius, close-range, CanCharge, and target movement data; +- external-container cylinder range. + +The query never mutates selection, queues work, sends packets, or starts +movement. + +### 4.3 `SelectionInteractionController` + +The controller receives: + +- `SelectionState` and `WorldSelectionQuery`; +- `ItemInteractionController` provider; +- a typed Use/PickUp transport; +- a narrow movement-intent sink; +- toast/system diagnostic sinks; +- Auto Target setting provider. + +It owns `OutboundInteractionQueue` and an incarnation-bound +`PendingPostArrivalAction`. Public methods form the complete host seam: + +```text +HandleInputAction +PickAtCursor / PlaceDraggedItem +SelectClosestCombatTarget +GetSelectedOrClosestCombatTarget +SendUse / SendPickUp +DrainOutbound +OnNaturalMoveToComplete +OnEntityHidden / OnEntityRemoved +ResetSession +``` + +The controller does not subscribe directly to Silk input and does not own +rendering, combat state, the session, or item policy. + +## 5. Execution sequence + +### Commit 0 — plan and defect registration + +- Land this plan. +- Register #230 for GUID-incarnation/session-reset leakage. +- Register #231 for F-key pickup missing the pending destination reservation. +- No runtime behavior change. + +### Commit 1 — lifecycle identity hardening (#230) + +- Add local-entity identity to render parts/hits and selection lighting. +- Add scene reset and deterministic publication tests. +- Validate published hits against current visible incarnation. +- Bind pending post-arrival actions to a live record/local identity. +- Clear only the matching pending token on teardown, including GUID reuse. +- Make session reset clear scene and item interaction state. +- Prevent combat Auto Target during `SessionReset`. +- Keep this root-cause correction independently bisectable. + +### Commit 2 — retail pending pickup presentation (#231) + +- Add one ItemInteractionController entry point for a validated world pickup. +- Publish `PendingBackpackPlacementRequested` before calling the existing + placement delegate. +- Route F-key pickup through it with destination/placement unchanged. +- Do not optimistically move ownership; server confirmation/failure remains + authoritative. + +### Commit 3 — extract `WorldSelectionQuery` + +- Move every read-only selection/classification/range body from `GameWindow`. +- Preserve materialized-versus-visible semantics. +- Extend the live animation read seam with current motion so dead eligibility + does not depend on `GameWindow.AnimatedEntity`. +- Rewire cursor, vivid target, toolbar health, combat camera, external-container + range, drag target, and door-name reads through the query. +- Delete the corresponding `GameWindow` methods. + +### Commit 4 — extract `SelectionInteractionController` + +- Move world click routing, selection mutation, closest-target selection, + outbound queue, Use/PickUp, movement intent, deferred completion, and + hidden/removal/reset handling. +- Compose `ItemInteractionController`; do not duplicate its policies. +- Update IA-19/AD-11/AD-27/AP-23 file pointers. +- Add focused controller tests for every invariant in §3. + +### Commit 5 — input cutover and host cleanup + +- Replace the six selection/interaction switch cases with one controller + delegation call at the same ordered position. +- Forward render-frame, update-frame, MoveTo completion, hidden/removal, + session-reset, cursor, and drag hooks. +- Delete old fields/records/wrappers from `GameWindow`. +- Prove `GameWindow` no longer owns a selection algorithm or interaction + request body. + +### Commit 6 — review, gate, and ledger close + +- Run retail-conformance, architecture/integration, and adversarial read-only + reviews against the complete slice diff. +- Fix confirmed findings and repeat review until clean. +- Run focused Core/App tests, Release solution build, and full Release suite. +- Update code-structure ownership status, roadmap/current queue if necessary, + both session instruction files, divergence pointers, and durable interaction + memory. +- Record the new `GameWindow` line/field/method counts. + +## 6. Automated acceptance matrix + +### Render/query + +- Building frame is invisible until `CompleteFrame`. +- Last complete frame remains queryable while the next frame builds. +- Empty completion clears prior hits. +- Hidden, pending, deleted, and replaced incarnations reject stale hits. +- A replacement GUID is not pickable until its own part is published. +- Pulse is identity-bound and cannot color a replacement. +- Plain pick excludes self; target-mode pick includes self. +- Vivid target can use materialized state while combat candidates cannot. +- Closest hostile excludes self, players, pets, friendly NPCs, dead, Hidden, + pending, and deleted objects. + +### Interaction + +- Pulse precedes target-mode and selection routing. +- Target-mode success/rejection consumes the click without selection drift. +- Single click selects; double click captures the clicked GUID and queues one + activation. +- Miss preserves selection and target mode. +- Previous selection preserves current SelectionState semantics. +- Queue drains after movement and only once per captured boundary. +- Far Use/PickUp sends once; close sends only on natural completion. +- Cancel/error/Hidden/delete/reset sends zero. +- Delete then same-GUID create sends zero to the replacement. +- Useability and pickupability fallbacks remain byte-for-byte behaviorally + equivalent. +- Creature and Stuck pickup rejection messages remain exact. +- Corpse double-click/Use opens it and never attempts to pick up the corpse. +- F pickup reserves the first destination slot before wire send. +- Pickup destination/placement survive the deferred path. + +### Lifecycle/input + +- Session reset clears previous selection, target mode, busy state, queue, + pending completion, published picks, and pulse; repeated reset is safe. +- SessionReset cannot trigger combat auto-target. +- Modal/retained UI capture still prevents controller invocation upstream. +- Escape target-mode cancellation remains ahead of window-close behavior. + +## 7. Connected gate + +Run one normal capped Release client and verify: + +1. select a nearby door/NPC and see the click pulse and target corners; +2. double-click/Use the door or NPC; +3. select a distant corpse, press R, approach, and open it; +4. double-click one loot item and use F on another; both reserve the expected + backpack slot and settle after ACE confirmation; +5. enter combat, select a hostile, use closest-target and previous-selection; +6. kill the selected target with Auto Target enabled and observe only a hostile + replacement; +7. portal or relog and confirm no old selection/pulse/action survives. + +Any failure returns to the owning commit. No retry, grace period, or +GUID-only suppression is acceptable. + +## 8. Subagent policy for this slice + +The implementation is tightly coupled across `GameWindow`, live lifetime, +render publication, input order, and player MoveTo. One primary agent performs +all edits. Independent subagents are useful for read-only retail, +architecture, and adversarial reviews; they do not edit the shared slice +concurrently. From 047a4c83b592ffb1ffe1be6ee40b3782e5b29af8 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 07:07:58 +0200 Subject: [PATCH 12/98] fix(interaction): bind selection to live incarnations Carry local WorldEntity identity through render hits, lighting pulses, and deferred movement actions so GUID reuse cannot target a replacement. Reset all session-owned selection and ItemHolder state and prevent combat auto-target during teardown. --- docs/ISSUES.md | 9 +- .../Combat/CombatTargetController.cs | 1 + src/AcDream.App/Rendering/GameWindow.cs | 71 +++++++++-- .../IRetailSelectionGeometrySource.cs | 12 ++ .../IRetailSelectionLightingSource.cs | 5 +- .../Selection/RetailSelectionGeometryCache.cs | 2 +- .../Selection/RetailSelectionLightingPulse.cs | 18 ++- .../Selection/RetailSelectionScene.cs | 30 +++-- .../Rendering/Wb/WbDrawDispatcher.cs | 5 +- .../UI/ItemInteractionController.cs | 18 +++ src/AcDream.App/World/LiveEntityRuntime.cs | 40 ++++++ .../Selection/RetailSelectionMesh.cs | 2 + .../Selection/RetailWorldPicker.cs | 4 +- .../Combat/CombatTargetControllerTests.cs | 17 +++ .../RetailSelectionLightingPulseTests.cs | 41 ++++--- .../Rendering/RetailSelectionSceneTests.cs | 114 ++++++++++++++++++ .../UI/ItemInteractionControllerTests.cs | 16 +++ .../World/LiveEntityRuntimeTests.cs | 7 ++ .../Selection/RetailWorldPickerTests.cs | 4 + 19 files changed, 374 insertions(+), 42 deletions(-) create mode 100644 src/AcDream.App/Rendering/Selection/IRetailSelectionGeometrySource.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index dbe75065..65106aa7 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -102,7 +102,7 @@ ownership mutation. ## #230 — Selection hits and deferred actions can cross live GUID incarnations -**Status:** IN-PROGRESS — isolated as Slice 1 prerequisite hardening +**Status:** FIXED (2026-07-21) — incarnation identity is carried and revalidated **Severity:** HIGH **Filed:** 2026-07-21 **Component:** selection / live entity lifetime / interaction @@ -134,6 +134,13 @@ old click lighting never colors a replacement; natural MoveTo completion sends only for the captured current visible incarnation; session reset clears the complete interaction lifetime and never auto-targets. +**Resolution:** Render parts/hits and SmartBox lighting now carry the local +`WorldEntity.Id`; `LiveEntityRuntime` revalidates that identity against the +current interaction-visible record. Deferred actions capture the same identity +and teardown clears the captured action even after GUID replacement. Session +reset now clears published selection geometry, lighting, ItemHolder target and +throttle state, and cannot trigger combat auto-target acquisition. + --- ## #228 — Clean Release build emits 17 test-project warnings diff --git a/src/AcDream.App/Combat/CombatTargetController.cs b/src/AcDream.App/Combat/CombatTargetController.cs index cdd739ac..4ce51f96 100644 --- a/src/AcDream.App/Combat/CombatTargetController.cs +++ b/src/AcDream.App/Combat/CombatTargetController.cs @@ -63,6 +63,7 @@ public sealed class CombatTargetController : IDisposable private void OnSelectionChanged(SelectionTransition transition) { if (transition.SelectedObjectId is not null + || transition.Reason == SelectionChangeReason.SessionReset || !_autoTarget() || !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)) return; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9c8685e9..d5e07d95 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1168,6 +1168,7 @@ public sealed class GameWindow : IDisposable private readonly record struct PendingPostArrivalAction( uint Guid, + uint LocalEntityId, bool IsPickup, uint DestinationContainerId = 0u, int Placement = 0); @@ -2982,10 +2983,11 @@ public sealed class GameWindow : IDisposable Objects.Clear(); SpellBook.Clear(); _magicRuntime?.Reset(); - _itemInteractionController?.ClearBusy(); + _itemInteractionController?.ResetSession(); _selection.Reset(); _outboundInteractions.Clear(); _pendingPostArrivalAction = null; + _retailSelectionScene?.Reset(); _particleVisibility.Reset(); try { @@ -5490,14 +5492,15 @@ public sealed class GameWindow : IDisposable () => _liveEntityPresentation?.Forget(record), () => _entityEffects?.OnLiveEntityUnregistered(record), () => _remoteTeleportController?.Forget(record), - () => incarnation.RunIfNoReplacement(() => + () => { if (_pendingPostArrivalAction is { Guid: var pendingGuid } - && pendingGuid == serverGuid) + && pendingGuid == serverGuid + && _pendingPostArrivalAction.Value.LocalEntityId == record.LocalEntityId) { _pendingPostArrivalAction = null; } - }), + }, }; if (record.WorldEntity is not { } existingEntity) @@ -13049,7 +13052,7 @@ public sealed class GameWindow : IDisposable { uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u; if (target != 0u) - _retailSelectionScene?.BeginLightingPulse(target); + BeginSelectionLightingPulse(target); _itemInteractionController?.PlaceIn3D(itemPayload, target); } } @@ -13765,16 +13768,40 @@ public sealed class GameWindow : IDisposable private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf) { - if (_retailSelectionScene is null || _window is null) + if (_retailSelectionScene is null + || _liveEntities is null + || _window is null) return null; var camera = GetSelectionCamera(); - return _retailSelectionScene.Pick( + AcDream.Core.Selection.RetailSelectionHit? hit = _retailSelectionScene.Pick( mouseX, mouseY, camera.Viewport, camera.View, camera.Projection, includeSelf ? 0u : _playerServerGuid); + if (hit is not { } found + || !_liveEntities.TryGetInteractionEligibleRecord( + found.ServerGuid, + found.LocalEntityId, + out _)) + { + return null; + } + + return found.ServerGuid; + } + + private void BeginSelectionLightingPulse(uint serverGuid) + { + if (_retailSelectionScene is null + || _liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord record) != true + || record.LocalEntityId is not { } localEntityId) + { + return; + } + + _retailSelectionScene.BeginLightingPulse(serverGuid, localEntityId); } private void PickAndStoreSelection(bool useImmediately) @@ -13791,7 +13818,7 @@ public sealed class GameWindow : IDisposable // Retail UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound // @ 0x004E5AD0 pulses every successfully found click before it // branches into select/use/targeted-use behavior. - _retailSelectionScene?.BeginLightingPulse(guid); + BeginSelectionLightingPulse(guid); if (_itemInteractionController?.OfferPrimaryClick(guid) is not null and not AcDream.App.UI.ItemPrimaryClickResult.NotActive) @@ -13890,6 +13917,13 @@ public sealed class GameWindow : IDisposable // Close-range deferral fires the wire packet ONCE on // MoveToComplete(None) (turn-first done), not a retry of an // earlier failed send. No re-send path. + if (_liveEntities?.TryGetInteractionEligibleRecord(guid, out LiveEntityRecord useTarget) + != true + || useTarget.LocalEntityId is not { } useTargetLocalId) + { + return; + } + bool closeRange = IsCloseRangeTarget(guid); InstallSpeculativeTurnToTarget(guid); @@ -13897,7 +13931,10 @@ public sealed class GameWindow : IDisposable { // Defer the wire packet — OnAutoWalkArrivedSendDeferredAction // will fire it after rotation completes. - _pendingPostArrivalAction = new PendingPostArrivalAction(guid, IsPickup: false); + _pendingPostArrivalAction = new PendingPostArrivalAction( + guid, + useTargetLocalId, + IsPickup: false); if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}"); return; @@ -13979,6 +14016,14 @@ public sealed class GameWindow : IDisposable // overlay reports arrival. // // 2026-05-16: simplified — FIRST send on arrival, not a retry. + if (_liveEntities?.TryGetInteractionEligibleRecord( + itemGuid, + out LiveEntityRecord pickupTarget) != true + || pickupTarget.LocalEntityId is not { } pickupTargetLocalId) + { + return; + } + bool closeRange = IsCloseRangeTarget(itemGuid); InstallSpeculativeTurnToTarget(itemGuid); @@ -13986,6 +14031,7 @@ public sealed class GameWindow : IDisposable { _pendingPostArrivalAction = new PendingPostArrivalAction( itemGuid, + pickupTargetLocalId, IsPickup: true, destinationContainerId, placement); @@ -14031,6 +14077,13 @@ public sealed class GameWindow : IDisposable if (_liveSession is null || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) return; + if (_liveEntities?.TryGetInteractionEligibleRecord( + pending.Guid, + pending.LocalEntityId, + out _) != true) + { + return; + } var seq = _liveSession.NextGameActionSequence(); if (pending.IsPickup) diff --git a/src/AcDream.App/Rendering/Selection/IRetailSelectionGeometrySource.cs b/src/AcDream.App/Rendering/Selection/IRetailSelectionGeometrySource.cs new file mode 100644 index 00000000..7f3ef11c --- /dev/null +++ b/src/AcDream.App/Rendering/Selection/IRetailSelectionGeometrySource.cs @@ -0,0 +1,12 @@ +using AcDream.Core.Selection; + +namespace AcDream.App.Rendering.Selection; + +/// +/// Supplies the immutable DAT geometry used by retail's render-coupled picker. +/// The seam keeps frame publication and incarnation tests independent of DAT IO. +/// +internal interface IRetailSelectionGeometrySource +{ + RetailSelectionMesh? Resolve(uint gfxObjId); +} diff --git a/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs b/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs index d902ea7d..50779210 100644 --- a/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs +++ b/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs @@ -13,5 +13,8 @@ internal interface IRetailSelectionLightingSource /// Returns the current CMaterial luminosity/diffuse replacement for an /// object, or false when its authored material values must be restored. /// - bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting); + bool TryGetLighting( + uint serverGuid, + uint localEntityId, + out RetailSelectionLighting lighting); } diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs index 270606cd..6e26ce73 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs @@ -10,7 +10,7 @@ namespace AcDream.App.Rendering.Selection; /// Decodes the exact CPU geometry consumed by retail mouse selection. The broad /// sphere comes from the drawing-BSP root; polygons retain GfxObj DAT order. /// -internal sealed class RetailSelectionGeometryCache +internal sealed class RetailSelectionGeometryCache : IRetailSelectionGeometrySource { private readonly IDatReaderWriter _dats; private readonly object _datLock; diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs index a1300da9..cf28e950 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs @@ -27,6 +27,7 @@ internal sealed class RetailSelectionLightingPulse private readonly Func _now; private uint _serverGuid; + private uint _localEntityId; private int _flipCount; private double _nextFlip; private RetailSelectionLighting _lighting; @@ -34,15 +35,16 @@ internal sealed class RetailSelectionLightingPulse public RetailSelectionLightingPulse(Func? now = null) => _now = now ?? MonotonicSeconds; - public void Start(uint serverGuid) + public void Start(uint serverGuid, uint localEntityId) { - if (serverGuid == 0u) + if (serverGuid == 0u || localEntityId == 0u) { Clear(); return; } _serverGuid = serverGuid; + _localEntityId = localEntityId; _flipCount = 1; _lighting = RetailSelectionLighting.High; _nextFlip = _now() + FlipIntervalSeconds; @@ -76,9 +78,16 @@ internal sealed class RetailSelectionLightingPulse _nextFlip = now + FlipIntervalSeconds; } - public bool TryGet(uint serverGuid, out RetailSelectionLighting lighting) + public bool TryGet( + uint serverGuid, + uint localEntityId, + out RetailSelectionLighting lighting) { - if (_flipCount != 0 && serverGuid != 0u && serverGuid == _serverGuid) + if (_flipCount != 0 + && serverGuid != 0u + && localEntityId != 0u + && serverGuid == _serverGuid + && localEntityId == _localEntityId) { lighting = _lighting; return true; @@ -91,6 +100,7 @@ internal sealed class RetailSelectionLightingPulse public void Clear() { _serverGuid = 0u; + _localEntityId = 0u; _flipCount = 0; _nextFlip = 0d; _lighting = default; diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs index 8510be56..6a029d55 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs @@ -11,7 +11,7 @@ namespace AcDream.App.Rendering.Selection; /// internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource { - private readonly RetailSelectionGeometryCache _geometry; + private readonly IRetailSelectionGeometrySource _geometry; private readonly RetailSelectionLightingPulse _lightingPulse; private List _building = new(); private List _published = new(); @@ -21,7 +21,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId); public RetailSelectionScene( - RetailSelectionGeometryCache geometry, + IRetailSelectionGeometrySource geometry, RetailSelectionLightingPulse? lightingPulse = null) { _geometry = geometry ?? throw new ArgumentNullException(nameof(geometry)); @@ -32,14 +32,27 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail /// Starts retail's SmartBox click pulse independently of persistent target /// selection. Examine, use, and targeted-use clicks receive the same pulse. /// - public void BeginLightingPulse(uint serverGuid) - => _lightingPulse.Start(serverGuid); + public void BeginLightingPulse(uint serverGuid, uint localEntityId) + => _lightingPulse.Start(serverGuid, localEntityId); public void TickLighting() => _lightingPulse.Tick(); - public bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting) - => _lightingPulse.TryGet(serverGuid, out lighting); + public bool TryGetLighting( + uint serverGuid, + uint localEntityId, + out RetailSelectionLighting lighting) + => _lightingPulse.TryGet(serverGuid, localEntityId, out lighting); + + /// Clears every session-owned frame and material pulse. + public void Reset() + { + _building.Clear(); + _published.Clear(); + _buildingKeys.Clear(); + _viewFrustum = null; + _lightingPulse.Clear(); + } public void BeginFrame() { @@ -76,6 +89,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail _building.Add(new RetailSelectionPart( entity.ServerGuid, + entity.Id, partIndex, partWorld, mesh)); @@ -86,7 +100,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail (_published, _building) = (_building, _published); } - public uint? Pick( + public RetailSelectionHit? Pick( float mouseX, float mouseY, Vector2 viewport, @@ -99,7 +113,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail var ray = WorldPicker.BuildRay( mouseX, mouseY, viewport.X, viewport.Y, view, projection); return RetailWorldPicker.Pick( - ray.Origin, ray.Direction, _published, skipServerGuid)?.ServerGuid; + ray.Origin, ray.Direction, _published, skipServerGuid); } internal static bool DrawingSphereIntersectsFrustum( diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index e258f252..cb230e04 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -1527,7 +1527,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // bounding sphere — camera-INDEPENDENT (minimize_object_lighting). ComputeEntityLightSet(entity); _currentEntitySelectionLighting = - _selectionLighting?.TryGetLighting(entity.ServerGuid, out var lighting) == true + _selectionLighting?.TryGetLighting( + entity.ServerGuid, + entity.Id, + out var lighting) == true ? new Vector2(lighting.Luminosity, lighting.Diffuse) : new Vector2(0f, 1f); diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 5ec1d1c9..39dbcd42 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -587,6 +587,24 @@ public sealed class ItemInteractionController : IDisposable StateChanged?.Invoke(); } + /// + /// Ends all session-scoped ItemHolder state. Target modes, click + /// suppression, and use throttles must not survive a reconnect where the + /// same numeric GUID can identify a different object. + /// + public void ResetSession() + { + bool busyChanged = _busyCount != 0; + bool modeChanged = IsAnyTargetModeActive; + _busyCount = 0; + _lastUseMs = long.MinValue / 2; + _consumedPrimaryClickTarget = 0u; + _consumedPrimaryClickMs = long.MinValue / 2; + _interactionState.Clear(); + if (busyChanged && !modeChanged) + StateChanged?.Invoke(); + } + private bool ConsumeUseThrottle() { long now = _nowMs(); diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index a7adbd29..65d8c7ad 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -964,6 +964,46 @@ public sealed class LiveEntityRuntime out WorldEntity entity) => _visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out entity!); + public bool TryGetInteractionEligibleRecord( + uint serverGuid, + out LiveEntityRecord record) + { + if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? found) + && found.WorldEntity is { } entity + && _visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out WorldEntity? visible) + && ReferenceEquals(entity, visible)) + { + record = found; + return true; + } + + record = null!; + return false; + } + + /// + /// Resolves a render-published interaction candidate only while the same + /// logical WorldEntity incarnation still owns the server GUID. A stale + /// frame must never retarget a replacement which reused that GUID. + /// + public bool TryGetInteractionEligibleRecord( + uint serverGuid, + uint localEntityId, + out LiveEntityRecord record) + { + if (serverGuid != 0u + && localEntityId != 0u + && TryGetInteractionEligibleRecord(serverGuid, out LiveEntityRecord found) + && found.WorldEntity!.Id == localEntityId) + { + record = found; + return true; + } + + record = null!; + return false; + } + public bool ContainsWorldEntity(uint serverGuid) => _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) && record.WorldEntity is not null; diff --git a/src/AcDream.Core/Selection/RetailSelectionMesh.cs b/src/AcDream.Core/Selection/RetailSelectionMesh.cs index cccebf0c..0175f669 100644 --- a/src/AcDream.Core/Selection/RetailSelectionMesh.cs +++ b/src/AcDream.Core/Selection/RetailSelectionMesh.cs @@ -20,6 +20,7 @@ public sealed record RetailSelectionPolygon( /// One part which survived the normal world-render visibility traversal. public readonly record struct RetailSelectionPart( uint ServerGuid, + uint LocalEntityId, int PartIndex, Matrix4x4 LocalToWorld, RetailSelectionMesh Mesh); @@ -27,6 +28,7 @@ public readonly record struct RetailSelectionPart( /// Retail picker result, including which physics part supplied the hit. public readonly record struct RetailSelectionHit( uint ServerGuid, + uint LocalEntityId, int PartIndex, double Distance, bool PolygonHit); diff --git a/src/AcDream.Core/Selection/RetailWorldPicker.cs b/src/AcDream.Core/Selection/RetailWorldPicker.cs index 794efa89..fd8004f9 100644 --- a/src/AcDream.Core/Selection/RetailWorldPicker.cs +++ b/src/AcDream.Core/Selection/RetailWorldPicker.cs @@ -53,7 +53,7 @@ public static class RetailWorldPicker if (closestSphere is null || sphereT < closestSphere.Value.Distance) closestSphere = new RetailSelectionHit( - part.ServerGuid, part.PartIndex, sphereT, PolygonHit: false); + part.ServerGuid, part.LocalEntityId, part.PartIndex, sphereT, PolygonHit: false); // Retail stops at the FIRST hit polygon in this part's stored flat order. foreach (var polygon in part.Mesh.Polygons) @@ -63,7 +63,7 @@ public static class RetailWorldPicker if (closestPolygon is null || polygonT < closestPolygon.Value.Distance) closestPolygon = new RetailSelectionHit( - part.ServerGuid, part.PartIndex, polygonT, PolygonHit: true); + part.ServerGuid, part.LocalEntityId, part.PartIndex, polygonT, PolygonHit: true); break; } } diff --git a/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs b/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs index 1ef586bf..7a92e3e6 100644 --- a/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs +++ b/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs @@ -79,4 +79,21 @@ public sealed class CombatTargetControllerTests Assert.Equal(0x50000002u, selection.SelectedObjectId); } + + [Fact] + public void SessionReset_DoesNotAcquireTargetFromDepartingWorld() + { + var combat = new CombatState(); + combat.SetCombatMode(CombatMode.Missile); + var selection = new SelectionState(); + selection.Select(0x50000002u, SelectionChangeSource.World); + int calls = 0; + using var controller = new CombatTargetController( + combat, selection, () => true, () => { calls++; return null; }); + + selection.Reset(); + + Assert.Equal(0, calls); + Assert.Null(selection.SelectedObjectId); + } } diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs index 8ead7b21..9a670abb 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs @@ -10,28 +10,28 @@ public sealed class RetailSelectionLightingPulseTests double now = 10d; var pulse = new RetailSelectionLightingPulse(() => now); - pulse.Start(0x5000_1234u); - AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High); + pulse.Start(0x5000_1234u, 1234u); + AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.High); now = 10.199; pulse.Tick(); - AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High); + AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.High); now = 10.2; pulse.Tick(); - AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low); + AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.Low); now = 10.4; pulse.Tick(); - AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High); + AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.High); now = 10.6; pulse.Tick(); - AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low); + AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.Low); now = 10.8; pulse.Tick(); - Assert.False(pulse.TryGet(0x5000_1234u, out _)); + Assert.False(pulse.TryGet(0x5000_1234u, 1234u, out _)); } [Fact] @@ -39,12 +39,12 @@ public sealed class RetailSelectionLightingPulseTests { double now = 1d; var pulse = new RetailSelectionLightingPulse(() => now); - pulse.Start(0x5000_1234u); + pulse.Start(0x5000_1234u, 1234u); now = 20d; pulse.Tick(); - AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low); + AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.Low); } [Fact] @@ -52,24 +52,35 @@ public sealed class RetailSelectionLightingPulseTests { double now = 4d; var pulse = new RetailSelectionLightingPulse(() => now); - pulse.Start(0x5000_0001u); + pulse.Start(0x5000_0001u, 1u); now = 4.2; pulse.Tick(); - AssertLighting(pulse, 0x5000_0001u, RetailSelectionLighting.Low); + AssertLighting(pulse, 0x5000_0001u, 1u, RetailSelectionLighting.Low); - pulse.Start(0x5000_0002u); + pulse.Start(0x5000_0002u, 2u); - Assert.False(pulse.TryGet(0x5000_0001u, out _)); - AssertLighting(pulse, 0x5000_0002u, RetailSelectionLighting.High); + Assert.False(pulse.TryGet(0x5000_0001u, 1u, out _)); + AssertLighting(pulse, 0x5000_0002u, 2u, RetailSelectionLighting.High); + } + + [Fact] + public void GuidReuseDoesNotTransferPulseToReplacementIncarnation() + { + var pulse = new RetailSelectionLightingPulse(() => 1d); + pulse.Start(0x5000_0001u, 10u); + + Assert.False(pulse.TryGet(0x5000_0001u, 11u, out _)); + AssertLighting(pulse, 0x5000_0001u, 10u, RetailSelectionLighting.High); } private static void AssertLighting( RetailSelectionLightingPulse pulse, uint serverGuid, + uint localEntityId, RetailSelectionLighting expected) { - Assert.True(pulse.TryGet(serverGuid, out var actual)); + Assert.True(pulse.TryGet(serverGuid, localEntityId, out var actual)); Assert.Equal(expected, actual); } } diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs new file mode 100644 index 00000000..9fb24950 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs @@ -0,0 +1,114 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Selection; +using AcDream.Core.Selection; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RetailSelectionSceneTests +{ + private sealed class GeometrySource(RetailSelectionMesh mesh) + : IRetailSelectionGeometrySource + { + public RetailSelectionMesh? Resolve(uint gfxObjId) + => gfxObjId == 0x0100_0001u ? mesh : null; + } + + [Fact] + public void PublishedHitCarriesLogicalEntityIdentity() + { + var scene = CreateScene(); + var entity = Entity(localId: 44u, serverGuid: 0x5000_0044u); + Publish(scene, entity); + + RetailSelectionHit? hit = PickCenter(scene); + + Assert.NotNull(hit); + Assert.Equal(entity.ServerGuid, hit.Value.ServerGuid); + Assert.Equal(entity.Id, hit.Value.LocalEntityId); + } + + [Fact] + public void ResetClearsPublishedFrameAndLightingPulse() + { + double now = 1d; + var pulse = new RetailSelectionLightingPulse(() => now); + var scene = CreateScene(pulse); + var entity = Entity(localId: 45u, serverGuid: 0x5000_0045u); + Publish(scene, entity); + scene.BeginLightingPulse(entity.ServerGuid, entity.Id); + + scene.Reset(); + + Assert.Null(PickCenter(scene)); + Assert.False(scene.TryGetLighting(entity.ServerGuid, entity.Id, out _)); + } + + private static RetailSelectionScene CreateScene( + RetailSelectionLightingPulse? pulse = null) + { + var mesh = new RetailSelectionMesh( + Vector3.Zero, + 2f, + [new RetailSelectionPolygon( + [ + new(-1f, -1f, 0f), + new( 1f, -1f, 0f), + new( 1f, 1f, 0f), + new(-1f, 1f, 0f), + ], + SingleSided: false)]); + return new RetailSelectionScene(new GeometrySource(mesh), pulse); + } + + private static WorldEntity Entity(uint localId, uint serverGuid) + => new() + { + Id = localId, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [], + }; + + private static void Publish(RetailSelectionScene scene, WorldEntity entity) + { + scene.BeginFrame(); + (Matrix4x4 view, Matrix4x4 projection) = Camera(); + scene.SetViewFrustum(FrustumPlanes.FromViewProjection(view * projection)); + scene.AddVisiblePart( + entity, + partIndex: 0, + gfxObjId: 0x0100_0001u, + Matrix4x4.CreateTranslation(0f, 0f, -5f)); + scene.CompleteFrame(); + } + + private static RetailSelectionHit? PickCenter(RetailSelectionScene scene) + { + (Matrix4x4 view, Matrix4x4 projection) = Camera(); + return scene.Pick( + 400f, + 300f, + new Vector2(800f, 600f), + view, + projection, + skipServerGuid: 0u); + } + + private static (Matrix4x4 View, Matrix4x4 Projection) Camera() + { + Matrix4x4 view = Matrix4x4.CreateLookAt( + Vector3.Zero, + -Vector3.UnitZ, + Vector3.UnitY); + Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 3f, + 4f / 3f, + 0.1f, + 100f); + return (view, projection); + } +} diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 9622e244..58d2a4d6 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -1418,4 +1418,20 @@ public sealed class ItemInteractionControllerTests Assert.True(h.Controller.ActivateItem(0x50000A0Au)); Assert.Equal(2, h.Uses.Count); } + + [Fact] + public void ResetSession_ClearsTargetBusyThrottleAndConsumedClickState() + { + var h = new Harness(); + const uint kit = 0x50000A0Cu; + h.AddContained(kit, item => item.Useability = HealthKitUseability); + Assert.True(h.Controller.ActivateItem(kit)); + h.Controller.IncrementBusyCount(); + + h.Controller.ResetSession(); + + Assert.False(h.Controller.IsAnyTargetModeActive); + Assert.Equal(0, h.Controller.BusyCount); + Assert.Equal(InteractionModeKind.None, h.Controller.InteractionState.Current.Kind); + } } diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index f0da5d2b..b578950f 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -808,6 +808,9 @@ public sealed class LiveEntityRuntimeTests guid, 0x01010001u, id => Entity(id, guid))!; Assert.NotEqual(first.Id, second.Id); + Assert.False(runtime.TryGetInteractionEligibleRecord(guid, first.Id, out _)); + Assert.True(runtime.TryGetInteractionEligibleRecord(guid, second.Id, out var current)); + Assert.Same(second, current.WorldEntity); Assert.Equal(2, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); } @@ -1428,6 +1431,7 @@ public sealed class LiveEntityRuntimeTests Assert.False(entity.IsDrawVisible); Assert.Empty(runtime.WorldEntities); Assert.False(runtime.TryGetInteractionEligibleEntity(guid, out _)); + Assert.False(runtime.TryGetInteractionEligibleRecord(guid, entity.Id, out _)); Assert.True(runtime.TryApplyState(new SetState.Parsed( guid, @@ -1437,6 +1441,9 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(RetailHiddenTransition.BecameVisible, visible.HiddenTransition); Assert.True(entity.IsDrawVisible); Assert.True(runtime.TryGetInteractionEligibleEntity(guid, out var eligible)); + Assert.True(runtime.TryGetInteractionEligibleRecord(guid, entity.Id, out var eligibleRecord)); + Assert.Same(entity, eligibleRecord.WorldEntity); + Assert.False(runtime.TryGetInteractionEligibleRecord(guid, entity.Id + 1u, out _)); Assert.Same(entity, eligible); Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value); Assert.Same(entity, Assert.Single(spatial.Entities)); diff --git a/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs b/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs index 9528c2cb..18d3c80a 100644 --- a/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs +++ b/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs @@ -30,6 +30,7 @@ public sealed class RetailWorldPickerTests Assert.NotNull(hit); Assert.Equal(2u, hit.Value.ServerGuid); + Assert.Equal(1002u, hit.Value.LocalEntityId); Assert.True(hit.Value.PolygonHit); } @@ -72,6 +73,7 @@ public sealed class RetailWorldPickerTests { var front = new RetailSelectionPart( 1u, + 1001u, 0, Matrix4x4.Identity, new RetailSelectionMesh( @@ -92,6 +94,7 @@ public sealed class RetailWorldPickerTests * Matrix4x4.CreateTranslation(0f, 0f, -10f); var part = new RetailSelectionPart( 3u, + 1003u, 0, transform, new RetailSelectionMesh(Vector3.Zero, 1f, [SquareAtLocalZ(0f)])); @@ -152,6 +155,7 @@ public sealed class RetailWorldPickerTests float sphereRadius = 2f) => new( guid, + guid + 1000u, 0, Matrix4x4.CreateTranslation(0f, 0f, z), new RetailSelectionMesh( From 52dbb5749eeabcc6391b379c2353161dc4a2758d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 07:08:30 +0200 Subject: [PATCH 13/98] fix(interaction): publish pending keyboard pickups Route F-key pickup through the same ItemHolder backpack placement entry point as double-click activation so the inventory reserves the destination slot before the request is sent. --- docs/ISSUES.md | 7 +++- src/AcDream.App/Rendering/GameWindow.cs | 6 ++-- .../UI/ItemInteractionController.cs | 32 ++++++++++++------- .../UI/ItemInteractionControllerTests.cs | 15 +++++++++ 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 65106aa7..10a8dc8c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -69,7 +69,7 @@ Copy this block when adding a new issue: ## #231 — F-key pickup omits retail pending destination-slot presentation -**Status:** IN-PROGRESS — isolated as Slice 1 corrective commit +**Status:** FIXED (2026-07-21) — keyboard pickup uses the shared ItemHolder path **Severity:** MEDIUM **Filed:** 2026-07-21 **Component:** selection / inventory / retained UI @@ -98,6 +98,11 @@ destination/placement pending reservation, then sends one authoritative request. Server confirmation settles it; server failure removes it without speculative ownership mutation. +**Resolution:** `ItemInteractionController.PlaceWorldItemInBackpack` now owns +the shared pending-placement notification and request dispatch. Both +double-click activation and the F-key route enter that method, so they publish +the same destination container and placement before the server request. + --- ## #230 — Selection hits and deferred actions can cross live GUID incarnations diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d5e07d95..dd267485 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -13588,7 +13588,8 @@ public sealed class GameWindow : IDisposable case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp: if (_selection.SelectedObjectId is uint pickupTarget) - _outboundInteractions.Enqueue(() => SendPickUp(pickupTarget)); + _outboundInteractions.Enqueue(() => + _itemInteractionController?.PlaceWorldItemInBackpack(pickupTarget)); else _debugVm?.AddToast("Nothing selected"); break; @@ -13973,9 +13974,6 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={seq}"); } - private void SendPickUp(uint itemGuid) - => SendPickUp(itemGuid, _playerServerGuid, placement: 0); - private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement) { if (_liveSession is null diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 39dbcd42..45b15389 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -303,6 +303,25 @@ public sealed class ItemInteractionController : IDisposable return ExecuteUseActions(decision.Actions); } + /// + /// Retail keyboard pickup entry point. CPlayerSystem::PlaceInBackpack + /// publishes the waiting destination slot before issuing the move request, + /// exactly like double-click pickup through ItemHolder. + /// + public bool PlaceWorldItemInBackpack(uint itemGuid) + { + if (itemGuid == 0u || _placeInBackpack is null) + return false; + + uint containerId = _backpackContainerId(); + if (containerId == 0u) + containerId = _playerGuid(); + const int placement = 0; + PendingBackpackPlacementRequested?.Invoke(itemGuid, containerId, placement); + _placeInBackpack(itemGuid, containerId, placement); + return true; + } + /// /// Retail paperdoll drop entry point. The paperdoll resolves the exact /// target side/location; this shared interaction owner performs AutoWield's @@ -418,18 +437,7 @@ public sealed class ItemInteractionController : IDisposable switch (action.Kind) { case ItemPolicyActionKind.PlaceInBackpack: - if (_placeInBackpack is null) - break; - uint containerId = _backpackContainerId(); - if (containerId == 0u) - containerId = _playerGuid(); - const int placement = 0; - PendingBackpackPlacementRequested?.Invoke( - action.ObjectId, - containerId, - placement); - _placeInBackpack(action.ObjectId, containerId, placement); - acted = true; + acted |= PlaceWorldItemInBackpack(action.ObjectId); break; case ItemPolicyActionKind.WieldRight: case ItemPolicyActionKind.WieldLeft: diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 58d2a4d6..f2d3fd0d 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -1419,6 +1419,21 @@ public sealed class ItemInteractionControllerTests Assert.Equal(2, h.Uses.Count); } + [Fact] + public void KeyboardPickup_PublishesPendingDestinationBeforeRequest() + { + var h = new Harness(); + const uint item = 0x70000A0Bu; + var pending = new List<(uint Item, uint Container, int Placement)>(); + h.Controller.PendingBackpackPlacementRequested += + (objectId, container, placement) => pending.Add((objectId, container, placement)); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(item)); + + Assert.Equal(new[] { (item, Player, 0) }, pending); + Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements); + } + [Fact] public void ResetSession_ClearsTargetBusyThrottleAndConsumedClickState() { From e74f2ca99a47408ac4bfa3f270725233ddd4d1e0 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 07:16:22 +0200 Subject: [PATCH 14/98] refactor(interaction): extract world selection query Move render-hit validation, live target classification, closest-hostile search, selection spheres, use/pickup gates, range checks, and approach parameters behind a read-only App-layer query owner. GameWindow now supplies runtime seams and delegates instead of owning those algorithms. --- .../Interaction/WorldSelectionQuery.cs | 348 ++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 450 +++--------------- src/AcDream.App/World/LiveEntityRuntime.cs | 1 + .../Interaction/WorldSelectionQueryTests.cs | 320 +++++++++++++ .../World/LiveEntityRuntimeTests.cs | 1 + 5 files changed, 749 insertions(+), 371 deletions(-) create mode 100644 src/AcDream.App/Interaction/WorldSelectionQuery.cs create mode 100644 tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs new file mode 100644 index 00000000..f6a5cae1 --- /dev/null +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -0,0 +1,348 @@ +using System.Numerics; +using AcDream.App.Rendering.Selection; +using AcDream.App.UI.Layout; +using AcDream.App.World; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.World; + +namespace AcDream.App.Interaction; + +internal readonly record struct SelectionCameraSnapshot( + Matrix4x4 View, + Matrix4x4 Projection, + Vector2 Viewport); + +internal readonly record struct PlayerInteractionPose(uint CellId, Vector3 Position); + +internal readonly record struct WorldInteractionTarget( + uint ServerGuid, + uint LocalEntityId, + WorldEntity Entity); + +internal readonly record struct ClosestCombatTarget(uint ServerGuid, float DistanceSquared); + +internal readonly record struct InteractionApproach( + WorldInteractionTarget Target, + PlayerInteractionPose Player, + float UseRadius, + bool IsCloseRange, + bool CanCharge, + float TargetRadius, + float TargetHeight); + +/// +/// Read-only owner for retail world selection and interaction classification. +/// It consumes the canonical live-entity runtime but never mutates selection, +/// movement, inventory, or network state. +/// +internal sealed class WorldSelectionQuery +{ + private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u; + private const uint StuckObjectFlag = 0x0004u; + private const float DefaultUseRadius = 0.6f; + private const float CreatureUseRadius = 3f; + private const float LargeObjectUseRadius = 2f; + private const float AceCanChargeDistance = 7.5f; + + private const uint SmallItemMask = + (uint)(ItemType.MeleeWeapon + | ItemType.Armor + | ItemType.Clothing + | ItemType.Jewelry + | ItemType.Food + | ItemType.Money + | ItemType.Misc + | ItemType.MissileWeapon + | ItemType.Container + | ItemType.Gem + | ItemType.SpellComponents + | ItemType.Writable + | ItemType.Key + | ItemType.Caster); + + private readonly LiveEntityRuntime _liveEntities; + private readonly ClientObjectTable _objects; + private readonly RetailSelectionScene _selectionScene; + private readonly Func _playerGuid; + private readonly Func _camera; + private readonly Func _cursor; + private readonly Func _playerPose; + private readonly Func _setupCylinder; + private readonly Func _selectionSphere; + + public WorldSelectionQuery( + LiveEntityRuntime liveEntities, + ClientObjectTable objects, + RetailSelectionScene selectionScene, + Func playerGuid, + Func camera, + Func cursor, + Func playerPose, + Func setupCylinder, + Func selectionSphere) + { + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _selectionScene = selectionScene ?? throw new ArgumentNullException(nameof(selectionScene)); + _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _cursor = cursor ?? throw new ArgumentNullException(nameof(cursor)); + _playerPose = playerPose ?? throw new ArgumentNullException(nameof(playerPose)); + _setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder)); + _selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere)); + } + + public uint? PickAtCursor(bool includeSelf) + { + Vector2 cursor = _cursor(); + return PickAt(cursor.X, cursor.Y, includeSelf); + } + + public uint? PickAt(float mouseX, float mouseY, bool includeSelf) + { + SelectionCameraSnapshot camera = _camera(); + RetailSelectionHit? hit = _selectionScene.Pick( + mouseX, + mouseY, + camera.Viewport, + camera.View, + camera.Projection, + includeSelf ? 0u : _playerGuid()); + return hit is { } found + && _liveEntities.TryGetInteractionEligibleRecord( + found.ServerGuid, + found.LocalEntityId, + out _) + ? found.ServerGuid + : null; + } + + public void BeginLightingPulse(uint serverGuid) + { + if (TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)) + _selectionScene.BeginLightingPulse(target.ServerGuid, target.LocalEntityId); + } + + public bool TryGetInteractionTarget( + uint serverGuid, + out WorldInteractionTarget target) + { + if (_liveEntities.TryGetInteractionEligibleRecord( + serverGuid, + out LiveEntityRecord record) + && record.WorldEntity is { } entity) + { + target = new WorldInteractionTarget(serverGuid, entity.Id, entity); + return true; + } + + target = default; + return false; + } + + public bool IsCurrent(WorldInteractionTarget target) + => IsCurrent(target.ServerGuid, target.LocalEntityId); + + public bool IsCurrent(uint serverGuid, uint localEntityId) + => _liveEntities.TryGetInteractionEligibleRecord(serverGuid, localEntityId, out _); + + public ItemType GetItemType(uint serverGuid) + => _objects.Get(serverGuid)?.Type ?? ItemType.None; + + public string Describe(uint serverGuid) + { + string? name = _objects.Get(serverGuid)?.Name; + return string.IsNullOrWhiteSpace(name) ? $"0x{serverGuid:X8}" : name; + } + + public bool IsCreature(uint serverGuid) + { + if (serverGuid == _playerGuid() + || !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)) + { + return false; + } + + if (_liveEntities.TryGetAnimationRuntime(target.LocalEntityId, out var animation) + && animation.CurrentMotion == MotionCommand.Dead) + { + return false; + } + + return (GetItemType(serverGuid) & ItemType.Creature) != 0; + } + + public bool IsHostileMonster(uint serverGuid) + => IsCreature(serverGuid) + && CombatTargetPolicy.IsHostileMonster( + _playerGuid(), + _objects.Get(_playerGuid()), + _objects.Get(serverGuid)); + + public bool ShouldShowHealth(uint serverGuid) + => SelectedObjectHealthPolicy.ShouldQueryHealth( + _playerGuid(), + _objects.Get(_playerGuid()), + _objects.Get(serverGuid)); + + public ClosestCombatTarget? FindClosestHostileMonster() + { + if (!_liveEntities.TryGetWorldEntity(_playerGuid(), out WorldEntity player)) + return null; + + ClosestCombatTarget? best = null; + foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities) + { + if (!IsHostileMonster(guid)) + continue; + float distanceSquared = Vector3.DistanceSquared(entity.Position, player.Position); + if (best is null || distanceSquared < best.Value.DistanceSquared) + best = new ClosestCombatTarget(guid, distanceSquared); + } + return best; + } + + public Vector3? GetCombatCameraTargetPoint(uint serverGuid) + => IsHostileMonster(serverGuid) + && TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target) + ? target.Entity.Position + + Vector3.Transform(new Vector3(0f, 0f, 0.5f), target.Entity.Rotation) + : null; + + public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid) + { + if (!_liveEntities.TryGetWorldEntity(serverGuid, out _) + || !TryGetSelectionSphere(serverGuid, out Vector3 center, out float radius)) + { + return null; + } + + uint pwdBits = _liveEntities.TryGetSnapshot(serverGuid, out var spawn) + ? spawn.ObjectDescriptionFlags ?? 0u + : 0u; + return new VividTargetInfo(center, radius, (uint)GetItemType(serverGuid), pwdBits); + } + + /// + /// SmartBox::GetObjectBoundingBox @ 0x00452E20 delegates to + /// CPhysicsObj::GetSelectionSphere @ 0x0050EA40 and uses a 0.1-unit + /// fallback when Setup authored no sphere. + /// + public bool TryGetSelectionSphere( + uint serverGuid, + out Vector3 worldCenter, + out float worldRadius) + { + worldCenter = default; + worldRadius = 0f; + if (!_liveEntities.TryGetWorldEntity(serverGuid, out WorldEntity entity)) + return false; + + worldCenter = entity.Position; + worldRadius = 0.1f; + if (!_liveEntities.TryGetSnapshot(serverGuid, out var spawn) + || spawn.SetupTableId is not uint setupId + || _selectionSphere(setupId) is not { } sphere + || sphere.Radius <= 1e-4f) + { + return true; + } + + float scale = entity.Scale > 0f ? entity.Scale : 1f; + Vector3 localCenter = sphere.Origin * scale; + worldCenter = entity.Position + Vector3.Transform(localCenter, entity.Rotation); + worldRadius = sphere.Radius * scale; + return true; + } + + /// ItemUses::IsUseable @ retail 0x00566A20 call family. + public bool IsUseable(uint serverGuid) + { + if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn)) + { + if (spawn.Useability is uint useability) + return useability is not 0u and not 1u; + if (((spawn.ObjectDescriptionFlags ?? 0u) & LargeUseObjectFlags) != 0u) + return true; + } + return (GetItemType(serverGuid) & ItemType.Creature) != 0; + } + + /// ItemHolder::DetermineUseResult @ 0x00588460 pickup gate. + public bool IsPickupable(uint serverGuid) + { + if (!_liveEntities.TryGetSnapshot(serverGuid, out var spawn) + || ((spawn.ObjectDescriptionFlags ?? 0u) & StuckObjectFlag) != 0u) + { + return false; + } + return ((spawn.ItemType ?? 0u) & SmallItemMask) != 0u; + } + + public bool TryGetApproach( + uint serverGuid, + out InteractionApproach approach) + { + if (_playerPose() is not { } player + || !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)) + { + approach = default; + return false; + } + + float useRadius = GetUseRadius(serverGuid); + float dx = target.Entity.Position.X - player.Position.X; + float dy = target.Entity.Position.Y - player.Position.Y; + float distanceSquared = dx * dx + dy * dy; + (float radius, float height) = _setupCylinder(serverGuid, target.Entity); + approach = new InteractionApproach( + target, + player, + useRadius, + distanceSquared <= useRadius * useRadius, + distanceSquared >= AceCanChargeDistance * AceCanChargeDistance, + radius, + height); + return true; + } + + public bool IsWithinExternalContainerUseRange(uint serverGuid) + { + if (_playerPose() is not { } playerPose + || !_liveEntities.TryGetWorldEntity(_playerGuid(), out WorldEntity player) + || !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target) + || !_liveEntities.TryGetSnapshot(serverGuid, out var spawn) + || spawn.UseRadius is not > 0f) + { + // The server remains authoritative while render projection is absent. + return true; + } + + var playerCylinder = _setupCylinder(_playerGuid(), player); + var targetCylinder = _setupCylinder(serverGuid, target.Entity); + return ObjectRangeMath.ObjectsInRange( + playerPose.Position, + playerCylinder.Radius, + playerCylinder.Height, + target.Entity.Position, + targetCylinder.Radius, + targetCylinder.Height, + spawn.UseRadius.Value, + useRadii: true, + ignoreZDelta: false); + } + + private float GetUseRadius(uint serverGuid) + { + if ((GetItemType(serverGuid) & ItemType.Creature) != 0) + return CreatureUseRadius; + return _liveEntities.TryGetSnapshot(serverGuid, out var spawn) + && ((spawn.ObjectDescriptionFlags ?? 0u) & LargeUseObjectFlags) != 0u + ? LargeObjectUseRadius + : DefaultUseRadius; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index dd267485..6c4996a1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -44,6 +44,7 @@ public sealed class GameWindow : IDisposable private RetailStaticAnimatingObjectScheduler? _staticAnimationScheduler; private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher; private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene; + private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery; /// Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters /// support. Required at startup — missing bindless throws /// in OnLoad. @@ -304,6 +305,8 @@ public sealed class GameWindow : IDisposable { public required AcDream.Core.World.WorldEntity Entity; AcDream.Core.World.WorldEntity AcDream.App.World.ILiveEntityAnimationRuntime.Entity => Entity; + uint AcDream.App.World.ILiveEntityAnimationRuntime.CurrentMotion => + Sequencer?.CurrentMotion ?? 0u; public required DatReaderWriter.DBObjs.Setup Setup; public required DatReaderWriter.DBObjs.Animation Animation; public required int LowFrame; @@ -2743,6 +2746,40 @@ public sealed class GameWindow : IDisposable new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache( _dats!, _datLock)), _retailAlphaQueue); + _worldSelectionQuery ??= new AcDream.App.Interaction.WorldSelectionQuery( + _liveEntities, + Objects, + _retailSelectionScene, + () => _playerServerGuid, + () => + { + var camera = GetSelectionCamera(); + return new AcDream.App.Interaction.SelectionCameraSnapshot( + camera.View, + camera.Projection, + camera.Viewport); + }, + () => new System.Numerics.Vector2(_lastMouseX, _lastMouseY), + () => _playerController is { } player + ? new AcDream.App.Interaction.PlayerInteractionPose( + player.CellId, + player.Position) + : null, + GetSetupCylinder, + setupId => + { + if (_dats is null) + return null; + lock (_datLock) + { + if (!_dats.TryGet(setupId, out var setup) + || setup.SelectionSphere is not { } sphere) + { + return null; + } + return (sphere.Origin, sphere.Radius); + } + }); // A.5 T22.5: apply A2C gate from quality preset. _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; @@ -13740,14 +13777,10 @@ public sealed class GameWindow : IDisposable { if (!_persistedGameplay.ViewCombatTarget || !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode) - || _selection.SelectedObjectId is not uint selected - || !IsLiveHostileMonsterTarget(selected) - || !_visibleEntitiesByServerGuid.TryGetValue(selected, out var target)) + || _selection.SelectedObjectId is not uint selected) return null; - return target.Position - + System.Numerics.Vector3.Transform( - new System.Numerics.Vector3(0f, 0f, 0.5f), target.Rotation); + return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected); } // ============================================================ @@ -13765,45 +13798,13 @@ public sealed class GameWindow : IDisposable /// pick the local player while plain selection excludes it. /// private uint? PickWorldGuidAtCursor(bool includeSelf) - => PickWorldGuidAt(_lastMouseX, _lastMouseY, includeSelf); + => _worldSelectionQuery?.PickAtCursor(includeSelf); private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf) - { - if (_retailSelectionScene is null - || _liveEntities is null - || _window is null) - return null; - var camera = GetSelectionCamera(); - AcDream.Core.Selection.RetailSelectionHit? hit = _retailSelectionScene.Pick( - mouseX, - mouseY, - camera.Viewport, - camera.View, - camera.Projection, - includeSelf ? 0u : _playerServerGuid); - if (hit is not { } found - || !_liveEntities.TryGetInteractionEligibleRecord( - found.ServerGuid, - found.LocalEntityId, - out _)) - { - return null; - } - - return found.ServerGuid; - } + => _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf); private void BeginSelectionLightingPulse(uint serverGuid) - { - if (_retailSelectionScene is null - || _liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord record) != true - || record.LocalEntityId is not { } localEntityId) - { - return; - } - - _retailSelectionScene.BeginLightingPulse(serverGuid, localEntityId); - } + => _worldSelectionQuery?.BeginLightingPulse(serverGuid); private void PickAndStoreSelection(bool useImmediately) { @@ -13918,9 +13919,9 @@ public sealed class GameWindow : IDisposable // Close-range deferral fires the wire packet ONCE on // MoveToComplete(None) (turn-first done), not a retry of an // earlier failed send. No re-send path. - if (_liveEntities?.TryGetInteractionEligibleRecord(guid, out LiveEntityRecord useTarget) - != true - || useTarget.LocalEntityId is not { } useTargetLocalId) + if (_worldSelectionQuery?.TryGetInteractionTarget( + guid, + out AcDream.App.Interaction.WorldInteractionTarget useTarget) != true) { return; } @@ -13934,7 +13935,7 @@ public sealed class GameWindow : IDisposable // will fire it after rotation completes. _pendingPostArrivalAction = new PendingPostArrivalAction( guid, - useTargetLocalId, + useTarget.LocalEntityId, IsPickup: false); if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}"); @@ -14014,10 +14015,9 @@ public sealed class GameWindow : IDisposable // overlay reports arrival. // // 2026-05-16: simplified — FIRST send on arrival, not a retry. - if (_liveEntities?.TryGetInteractionEligibleRecord( + if (_worldSelectionQuery?.TryGetInteractionTarget( itemGuid, - out LiveEntityRecord pickupTarget) != true - || pickupTarget.LocalEntityId is not { } pickupTargetLocalId) + out AcDream.App.Interaction.WorldInteractionTarget pickupTarget) != true) { return; } @@ -14029,7 +14029,7 @@ public sealed class GameWindow : IDisposable { _pendingPostArrivalAction = new PendingPostArrivalAction( itemGuid, - pickupTargetLocalId, + pickupTarget.LocalEntityId, IsPickup: true, destinationContainerId, placement); @@ -14075,10 +14075,9 @@ public sealed class GameWindow : IDisposable if (_liveSession is null || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) return; - if (_liveEntities?.TryGetInteractionEligibleRecord( + if (_worldSelectionQuery?.IsCurrent( pending.Guid, - pending.LocalEntityId, - out _) != true) + pending.LocalEntityId) != true) { return; } @@ -14132,89 +14131,21 @@ public sealed class GameWindow : IDisposable /// until our local rotation overlay reports alignment, then fire. /// private bool IsCloseRangeTarget(uint targetGuid) - { - if (_playerController is null) return false; - if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity)) - return false; - - // Mirror InstallSpeculativeTurnToTarget's per-type radius heuristic. - float useRadius = 0.6f; - if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0) - { - useRadius = 3.0f; - } - else if (LastSpawns.TryGetValue(targetGuid, out var spawn) - && spawn.ObjectDescriptionFlags is { } odf) - { - const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u; - if ((odf & LargeFlatMask) != 0) useRadius = 2.0f; - } - - var bodyPos = _playerController.Position; - float dx = entity.Position.X - bodyPos.X; - float dy = entity.Position.Y - bodyPos.Y; - float distSq = dx * dx + dy * dy; - return distSq <= useRadius * useRadius; - } + => _worldSelectionQuery?.TryGetApproach(targetGuid, out var approach) == true + && approach.IsCloseRange; private bool IsWithinExternalContainerUseRange(uint targetGuid) - { - if (_playerController is null - || !_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity) - || !_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity)) - { - // The server remains authoritative for forced close. An entity can be - // temporarily absent from the visible-cell projection while its external - // view is still valid; do not manufacture a range exit from missing render state. - return true; - } - - if (!LastSpawns.TryGetValue(targetGuid, out var spawn) - || spawn.UseRadius is not > 0f) - return true; - - // gmExternalContainerUI::SetGroundObject @ 0x004CBBD0 registers the - // root's UseRadius with useRadii=true and ignoreZDelta=false. - // CPlayerSystem::CalculateObjectRangeChecks @ 0x0055F360 then calls - // ACCWeenieObject::ObjectsInRange @ 0x0058C1A0, whose use-radii branch - // compares the gap between both physics cylinders, not their centers. - var playerCylinder = GetSetupCylinder(_playerServerGuid, playerEntity); - var targetCylinder = GetSetupCylinder(targetGuid, entity); - return AcDream.Core.Physics.ObjectRangeMath.ObjectsInRange( - _playerController.Position, - playerCylinder.Radius, - playerCylinder.Height, - entity.Position, - targetCylinder.Radius, - targetCylinder.Height, - spawn.UseRadius.Value, - useRadii: true, - ignoreZDelta: false); - } + => _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true; private void InstallSpeculativeTurnToTarget(uint targetGuid) { - if (_playerController is not { } pc || pc.MoveTo is null) return; - if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity)) + if (_playerController is not { } pc + || pc.MoveTo is null + || _worldSelectionQuery?.TryGetApproach( + targetGuid, + out AcDream.App.Interaction.InteractionApproach approach) != true) return; - // Per-type use radius — same heuristic as the picker's - // radiusForGuid callback (register AP-23, re-anchored here by - // R4-V5; survives because ACE's close-branch broadcasts nothing - // actionable). - float useRadius = 0.6f; - if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0) - { - useRadius = 3.0f; - } - else if (LastSpawns.TryGetValue(targetGuid, out var spawn) - && spawn.ObjectDescriptionFlags is { } odf) - { - // BF_DOOR | BF_LIFESTONE | BF_PORTAL | BF_CORPSE - const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u; - if ((odf & LargeFlatMask) != 0) useRadius = 2.0f; - } - // Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit // from local distance so the speculative moveto uses the // same walk/run as the wire-triggered overwrite that arrives @@ -14224,13 +14155,6 @@ public sealed class GameWindow : IDisposable // the speculative install doesn't flip walk↔run when ACE's // MoveToObject broadcast overwrites it (PerformMovement // cancels + restarts — retail-consistent re-target). - const float AceCanChargeDistance = 7.5f; - var bodyPos = _playerController.Position; - float ddx = entity.Position.X - bodyPos.X; - float ddy = entity.Position.Y - bodyPos.Y; - float distToTarget = MathF.Sqrt(ddx * ddx + ddy * ddy); - bool speculativeCanCharge = distToTarget >= AceCanChargeDistance; - // R4-V5: retail's client-initiated use flow issues TurnToObject / // MoveToObject through the SAME manager the wire path uses (decomp // §9a/§9b callers) — in-range targets get a pure turn-to-face; @@ -14242,18 +14166,18 @@ public sealed class GameWindow : IDisposable // are non-default. var p = new AcDream.Core.Physics.Motion.MovementParameters { - DistanceToObject = useRadius, - CanCharge = speculativeCanCharge, + DistanceToObject = approach.UseRadius, + CanCharge = approach.CanCharge, }; var ms = new AcDream.Core.Physics.MovementStruct { ObjectId = targetGuid, TopLevelId = targetGuid, Pos = new AcDream.Core.Physics.Position( - _playerController.CellId, entity.Position, + approach.Player.CellId, approach.Target.Entity.Position, System.Numerics.Quaternion.Identity), Params = p, - Type = IsCloseRangeTarget(targetGuid) + Type = approach.IsCloseRange ? AcDream.Core.Physics.MovementType.TurnToObject : AcDream.Core.Physics.MovementType.MoveToObject, }; @@ -14265,7 +14189,7 @@ public sealed class GameWindow : IDisposable // arrival hybrid (centerDist − playerRadius ≤ useRadius) — the // auto-walk stopped ~one player-radius farther out than the AP-23 // constants intended, and the two MoveToObject sites disagreed. - (ms.Radius, ms.Height) = GetSetupCylinder(targetGuid, entity); + (ms.Radius, ms.Height) = (approach.TargetRadius, approach.TargetHeight); // Part of this install's AP-23 adaptation: store the autonomy flag // exactly as the wire mt-6 this install anticipates would (the P1 // unpack store, IsAutonomous=false) — without it the per-tick @@ -14281,25 +14205,9 @@ public sealed class GameWindow : IDisposable private uint? SelectClosestCombatTarget(bool showToast) { - if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)) - return null; - - uint? bestGuid = null; - float bestDistanceSq = float.PositiveInfinity; - foreach (var (guid, entity) in _visibleEntitiesByServerGuid) - { - if (!IsLiveHostileMonsterTarget(guid)) - continue; - - float distanceSq = System.Numerics.Vector3.DistanceSquared( - entity.Position, - playerEntity.Position); - if (distanceSq >= bestDistanceSq) - continue; - - bestDistanceSq = distanceSq; - bestGuid = guid; - } + AcDream.App.Interaction.ClosestCombatTarget? closest = + _worldSelectionQuery?.FindClosestHostileMonster(); + uint? bestGuid = closest?.ServerGuid; if (bestGuid is { } selection) _selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard); @@ -14308,7 +14216,7 @@ public sealed class GameWindow : IDisposable if (bestGuid is { } selected) { string label = DescribeLiveEntity(selected); - float distance = MathF.Sqrt(bestDistanceSq); + float distance = MathF.Sqrt(closest!.Value.DistanceSquared); Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}"); if (showToast) _debugVm?.AddToast($"Target {label}"); @@ -14323,30 +14231,10 @@ public sealed class GameWindow : IDisposable } private bool IsLiveCreatureTarget(uint guid) - { - if (guid == _playerServerGuid) - return false; - if (!_visibleEntitiesByServerGuid.ContainsKey(guid)) - return false; - - if (_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity) - && _animatedEntities.TryGetValue(entity.Id, out var animated) - && animated.Sequencer?.CurrentMotion == AcDream.Core.Physics.MotionCommand.Dead) - return false; - - return (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0; - } + => _worldSelectionQuery?.IsCreature(guid) == true; private bool IsLiveHostileMonsterTarget(uint guid) - { - if (!IsLiveCreatureTarget(guid)) - return false; - - return AcDream.Core.Combat.CombatTargetPolicy.IsHostileMonster( - _playerServerGuid, - Objects.Get(_playerServerGuid), - Objects.Get(guid)); - } + => _worldSelectionQuery?.IsHostileMonster(guid) == true; /// /// True if the selected-object strip should show a Health meter for . @@ -14355,10 +14243,7 @@ public sealed class GameWindow : IDisposable /// gmToolbarUI::HandleSelectionChanged @ 0x004BF380. /// private bool IsHealthBarTarget(uint guid) - => AcDream.Core.Combat.SelectedObjectHealthPolicy.ShouldQueryHealth( - _playerServerGuid, - Objects.Get(_playerServerGuid), - Objects.Get(guid)); + => _worldSelectionQuery?.ShouldShowHealth(guid) == true; private (System.Numerics.Matrix4x4 View, System.Numerics.Matrix4x4 Projection, @@ -14374,20 +14259,7 @@ public sealed class GameWindow : IDisposable } private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid) - { - if (!_entitiesByServerGuid.ContainsKey(guid) - || !TryGetEntitySelectionSphere(guid, out var center, out float radius)) - return null; - - uint pwdBits = LastSpawns.TryGetValue(guid, out var spawn) - ? spawn.ObjectDescriptionFlags ?? 0u - : 0u; - return new AcDream.App.UI.Layout.VividTargetInfo( - center, - radius, - (uint)LiveItemType(guid), - pwdBits); - } + => _worldSelectionQuery?.ResolveVividTargetInfo(guid); /// @@ -14420,45 +14292,11 @@ public sealed class GameWindow : IDisposable out System.Numerics.Vector3 worldCenter, out float worldRadius) { + if (_worldSelectionQuery is { } query) + return query.TryGetSelectionSphere(guid, out worldCenter, out worldRadius); worldCenter = default; worldRadius = 0f; - - if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return false; - - // SmartBox::GetObjectBoundingBox @ 0x00452E20 installs a 0.1-unit - // origin sphere when CPhysicsObj::GetSelectionSphere has no authored - // Setup sphere. Preserve that presentation for every materialized - // object rather than dropping the marker entirely. - worldCenter = entity.Position; - worldRadius = 0.1f; - if (!LastSpawns.TryGetValue(guid, out var spawn) - || spawn.SetupTableId is not uint setupId - || _dats is null - || !_dats.TryGet(setupId, out var setup)) - return true; - - // DAT Setup carries `SelectionSphere` (Origin + Radius). A zero - // radius means the Setup did not author one, so retain the retail - // 0.1-unit fallback installed above. - var sel = setup.SelectionSphere; - if (sel is null || sel.Radius <= 1e-4f) return true; - - // Retail GetSelectionSphere applies part-array scale to the - // sphere center (component-wise) and to the radius (Z-scale - // only). For uniform entity scale these coincide. - float scale = entity.Scale > 0f ? entity.Scale : 1f; - var localCenter = new System.Numerics.Vector3( - sel.Origin.X * scale, - sel.Origin.Y * scale, - sel.Origin.Z * scale); - - // Setup-local center → world. Entity rotation applies; entity - // position is the world origin of the setup. - var rot = System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation); - var rotated = System.Numerics.Vector3.Transform(localCenter, rot); - worldCenter = entity.Position + rotated; - worldRadius = sel.Radius * scale; - return true; + return false; } /// @@ -14490,90 +14328,7 @@ public sealed class GameWindow : IDisposable /// /// private bool IsUseableTarget(uint guid) - { - if (LastSpawns.TryGetValue(guid, out var spawn)) - { - // Authoritative path: server published Useability. - // 2026-05-16 — retail-faithful gate per ItemUses::IsUseable - // at acclient_2013_pseudo_c.txt:256455 (4 call-site cross- - // checks confirm: ItemHolder::UseObject 0x00588a80, - // DetermineUseResult 0x402697, UsingItem 0x367638, - // disable-button state 0x198826 — all key off non-zero). - // BN's `!(x) & 1` rendering is a mis-decompile of the - // setne+and test-flag inliner. Real semantic: - // - // IsUseable(_useability) := (_useability != USEABLE_UNDEF) - // - // ANY non-zero value passes (including USEABLE_NO=1, - // USEABLE_CONTAINED=8, etc.). Retail trusts the server to - // have only set non-zero on entities where Use is sensible. - // - // Previous implementation (B.8) checked - // `(useability & USEABLE_REMOTE_BIT) != 0` which is STRICTER - // than retail — a USEABLE_NO door would be blocked locally - // but pass retail's gate. Now matches retail bit-for-bit. - if (spawn.Useability is uint useability) - { - // Retail-faithful Use gate per acclient_2013_pseudo_c.txt:256455 - // ItemUses::IsUseable: non-zero useability passes. But two - // values produce "cannot be used" client-side without a - // wire send in retail's observable behaviour: - // USEABLE_UNDEF (0): server's Use handler would reject; - // retail UseObject path shows "cannot be used" toast. - // USEABLE_NO (1): explicitly not useable — same outcome. - // Both come from acclient.h:6478 ITEM_USEABLE enum. - // - // Retail technically sends the packet for USEABLE_NO (the - // audit's `IsUseable != 0` reading is correct), but ACE - // never broadcasts MovementType=6 for it, so retail - // doesn't visibly approach. Our client installs a - // speculative auto-walk overlay BEFORE the server - // response — so the only way to avoid "approach then fail" - // is to gate USEABLE_NO client-side. Net result matches - // user-observed retail behaviour. - const uint USEABLE_UNDEF = 0u; - const uint USEABLE_NO = 1u; - if (useability == USEABLE_UNDEF || useability == USEABLE_NO) - return false; - return true; - } - - // Useability NOT in PWD — fall back to known-useable types. - // ObjectDescriptionFlags BF_DOOR|BF_LIFESTONE|BF_PORTAL|BF_CORPSE - // historically work with Use; allow them through. - if (spawn.ObjectDescriptionFlags is { } odf) - { - const uint UseableFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u; - if ((odf & UseableFlatMask) != 0) - { - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled) - Console.WriteLine(System.FormattableString.Invariant( - $"[useability-fallback] flat-class guid=0x{guid:X8} odf=0x{odf:X8} (ACE sent no useability bit)")); - return true; - } - } - } - - // Creatures (NPCs / players) are always Use targets in our - // fallback even when ACE didn't publish useability. Retail - // would have blocked here (null → USEABLE_UNDEF → 0 → block), - // but ACE's seed DB has many talk-only NPC weenies with - // `ItemUseable = null`; without the fallback the M1 "click NPC" - // flow regresses. The diagnostic line below lets us measure - // how often this branch fires in real play. - if ((LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0) - { - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled) - Console.WriteLine(System.FormattableString.Invariant( - $"[useability-fallback] creature guid=0x{guid:X8} (ACE sent no useability bit)")); - return true; - } - - // Default: not useable. Signs, banners, untyped scenery with no - // server-supplied useability and no creature/door PWD bits land - // here — exactly the retail "nothing happens" case. - return false; - } + => _worldSelectionQuery?.IsUseable(guid) == true; /// /// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based. @@ -14597,63 +14352,16 @@ public sealed class GameWindow : IDisposable /// /// private bool IsPickupableTarget(uint guid) - { - if (!LastSpawns.TryGetValue(guid, out var spawn)) - return false; - - // 2026-05-16 — primary discriminator is the BF_STUCK - // ObjectDescriptionFlag (acclient.h:6435, bit 0x4). Retail and - // ACE mark immovable world objects (signs, banners, doors, - // benches) as Stuck server-side. ACE's PutItemInContainer - // handler (Player_Inventory.cs:831-836) responds with - // WeenieError.Stuck (0x29) when the client attempts a pickup - // on an item with the Stuck flag — so the client should gate - // out signs etc. before sending the wire packet. - // - // Discriminates same-ItemType ambiguity that useability can't: - // Holtburg sign (Misc + USEABLE_NO + BF_STUCK) → block - // Spell component (Misc + USEABLE_NO + ~BF_STUCK) → allow - // Door (no SmallItemMask + BF_DOOR + BF_STUCK) → never matches SmallItemMask, separately - if (spawn.ObjectDescriptionFlags is { } odf) - { - const uint BF_STUCK = 0x0004u; - // Corpses are BF_STUCK containers: Use opens their contents; the - // corpse object itself is never picked up. ItemHolder::DetermineUseResult - // @ 0x00588460 excludes Stuck objects from PlaceInBackpack. - if ((odf & BF_STUCK) != 0u) return false; - } - - // Small-item ItemType class: dropped weapons, armor, food, - // jewelry, money, misc, gems, spell components, etc. - uint it = spawn.ItemType ?? 0u; - const uint SmallItemMask = - (uint)(AcDream.Core.Items.ItemType.MeleeWeapon - | AcDream.Core.Items.ItemType.Armor - | AcDream.Core.Items.ItemType.Clothing - | AcDream.Core.Items.ItemType.Jewelry - | AcDream.Core.Items.ItemType.Food - | AcDream.Core.Items.ItemType.Money - | AcDream.Core.Items.ItemType.Misc - | AcDream.Core.Items.ItemType.MissileWeapon - | AcDream.Core.Items.ItemType.Container - | AcDream.Core.Items.ItemType.Gem - | AcDream.Core.Items.ItemType.SpellComponents - | AcDream.Core.Items.ItemType.Writable - | AcDream.Core.Items.ItemType.Key - | AcDream.Core.Items.ItemType.Caster); - return (it & SmallItemMask) != 0u; - } + => _worldSelectionQuery?.IsPickupable(guid) == true; private AcDream.Core.Items.ItemType LiveItemType(uint guid) => - Objects.Get(guid)?.Type ?? AcDream.Core.Items.ItemType.None; + _worldSelectionQuery?.GetItemType(guid) ?? AcDream.Core.Items.ItemType.None; private string? LiveName(uint guid) => Objects.Get(guid)?.Name; private string DescribeLiveEntity(uint guid) { - var name = LiveName(guid); - if (!string.IsNullOrWhiteSpace(name)) return name!; - return $"0x{guid:X8}"; + return _worldSelectionQuery?.Describe(guid) ?? $"0x{guid:X8}"; } /// diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 65d8c7ad..77400276 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -15,6 +15,7 @@ namespace AcDream.App.World; public interface ILiveEntityAnimationRuntime { WorldEntity Entity { get; } + uint CurrentMotion { get; } } /// Remote motion state owned by a live object. diff --git a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs new file mode 100644 index 00000000..0bb1ed9f --- /dev/null +++ b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs @@ -0,0 +1,320 @@ +using System.Numerics; +using AcDream.App.Interaction; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Selection; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Interaction; + +public sealed class WorldSelectionQueryTests +{ + private const uint Player = 0x5000_0001u; + private const uint Target = 0x7000_0001u; + + private sealed class Resources : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) { } + public void Unregister(WorldEntity entity) { } + } + + private sealed class GeometrySource(RetailSelectionMesh mesh) + : IRetailSelectionGeometrySource + { + public RetailSelectionMesh? Resolve(uint gfxObjId) => mesh; + } + + private sealed class Harness + { + public readonly ClientObjectTable Objects = new(); + public readonly LiveEntityRuntime Runtime; + public readonly RetailSelectionScene Scene; + public readonly WorldSelectionQuery Query; + public PlayerInteractionPose? PlayerPose = new(0x0101_0001u, Vector3.Zero); + + public Harness() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101_FFFFu, + new LandBlock(), + Array.Empty())); + Runtime = new LiveEntityRuntime(spatial, new Resources()); + Scene = new RetailSelectionScene(new GeometrySource(Mesh())); + Query = new WorldSelectionQuery( + Runtime, + Objects, + Scene, + () => Player, + Camera, + () => new Vector2(400f, 300f), + () => PlayerPose, + (_, _) => (0.5f, 2f), + _ => (new Vector3(1f, 0f, 0f), 2f)); + + Add(Player, Vector3.Zero, ItemType.Creature, SelectedObjectHealthPolicy.BfPlayer); + } + + public WorldEntity Add( + uint guid, + Vector3 position, + ItemType type, + uint publicFlags = 0u, + uint? useability = null, + uint objectDescriptionFlags = 0u, + ushort instance = 1, + float scale = 1f, + Quaternion? rotation = null) + { + WorldSession.EntitySpawn spawn = Spawn(guid, instance) with + { + ItemType = (uint)type, + Useability = useability, + ObjectDescriptionFlags = objectDescriptionFlags, + }; + Runtime.RegisterLiveEntity(spawn); + WorldEntity entity = Runtime.MaterializeLiveEntity( + guid, + 0x0101_0001u, + id => Entity(id, guid, position, scale, rotation ?? Quaternion.Identity))!; + Objects.AddOrUpdate(new ClientObject + { + ObjectId = guid, + Name = $"Object {guid:X8}", + Type = type, + PublicWeenieBitfield = publicFlags, + }); + return entity; + } + + public void Publish(WorldEntity entity) + { + SelectionCameraSnapshot camera = Camera(); + Scene.BeginFrame(); + Scene.SetViewFrustum(FrustumPlanes.FromViewProjection( + camera.View * camera.Projection)); + Scene.AddVisiblePart( + entity, + 0, + 0x0100_0001u, + Matrix4x4.CreateTranslation(entity.Position)); + Scene.CompleteFrame(); + } + } + + [Fact] + public void PickRejectsPublishedPartAfterGuidWasReused() + { + var h = new Harness(); + WorldEntity oldTarget = h.Add(Target, new Vector3(0f, 0f, -5f), ItemType.Misc); + h.Publish(oldTarget); + Assert.Equal(Target, h.Query.PickAtCursor(includeSelf: false)); + + Assert.True(h.Runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(Target, 1), + isLocalPlayer: false)); + WorldEntity replacement = h.Add( + Target, + new Vector3(0f, 0f, -5f), + ItemType.Misc, + instance: 1); + + Assert.NotEqual(oldTarget.Id, replacement.Id); + Assert.Null(h.Query.PickAtCursor(includeSelf: false)); + } + + [Fact] + public void ClosestTargetIncludesOnlyVisibleLivingHostileMonsters() + { + var h = new Harness(); + h.Add( + 0x7000_0010u, + new Vector3(8f, 0f, 0f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + h.Add( + 0x7000_0011u, + new Vector3(2f, 0f, 0f), + ItemType.Creature, + publicFlags: 0u); + h.Add( + 0x7000_0012u, + new Vector3(1f, 0f, 0f), + ItemType.Misc, + SelectedObjectHealthPolicy.BfAttackable); + + ClosestCombatTarget? closest = h.Query.FindClosestHostileMonster(); + + Assert.Equal(0x7000_0010u, closest?.ServerGuid); + Assert.Equal(64f, closest?.DistanceSquared); + } + + [Theory] + [InlineData(0.59f, true)] + [InlineData(0.61f, false)] + [InlineData(7.49f, false)] + [InlineData(7.50f, true)] + public void ApproachUsesRetailGroundItemRadiusAndAceChargeBoundary( + float distance, + bool expectedBoundary) + { + var h = new Harness(); + h.Add(Target, new Vector3(distance, 0f, 0f), ItemType.Misc); + + Assert.True(h.Query.TryGetApproach(Target, out InteractionApproach approach)); + + if (distance < 1f) + Assert.Equal(expectedBoundary, approach.IsCloseRange); + else + Assert.Equal(expectedBoundary, approach.CanCharge); + Assert.Equal(0.6f, approach.UseRadius); + Assert.Equal(0.5f, approach.TargetRadius); + Assert.Equal(2f, approach.TargetHeight); + } + + [Fact] + public void PickupAndUseabilityPreserveIndependentRetailGates() + { + var h = new Harness(); + const uint stuckComponent = 0x7000_0020u; + const uint looseComponent = 0x7000_0021u; + h.Add( + stuckComponent, + Vector3.UnitX, + ItemType.SpellComponents, + useability: 1u, + objectDescriptionFlags: 0x0004u); + h.Add( + looseComponent, + Vector3.UnitX * 2f, + ItemType.SpellComponents, + useability: 1u); + + Assert.False(h.Query.IsUseable(stuckComponent)); + Assert.False(h.Query.IsPickupable(stuckComponent)); + Assert.False(h.Query.IsUseable(looseComponent)); + Assert.True(h.Query.IsPickupable(looseComponent)); + } + + [Fact] + public void SelectionSphereAppliesSetupOffsetScaleAndRotation() + { + var h = new Harness(); + h.Add( + Target, + new Vector3(10f, 20f, 3f), + ItemType.Misc, + scale: 2f, + rotation: Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f)); + + Assert.True(h.Query.TryGetSelectionSphere(Target, out Vector3 center, out float radius)); + + Assert.True(Vector3.Distance(new Vector3(10f, 22f, 3f), center) < 0.0001f); + Assert.Equal(4f, radius); + } + + private static SelectionCameraSnapshot Camera() + { + Matrix4x4 view = Matrix4x4.CreateLookAt( + Vector3.Zero, + -Vector3.UnitZ, + Vector3.UnitY); + Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 3f, + 4f / 3f, + 0.1f, + 100f); + return new SelectionCameraSnapshot(view, projection, new Vector2(800f, 600f)); + } + + private static RetailSelectionMesh Mesh() + => new( + Vector3.Zero, + 2f, + [new RetailSelectionPolygon( + [ + new(-1f, -1f, 0f), + new( 1f, -1f, 0f), + new( 1f, 1f, 0f), + new(-1f, 1f, 0f), + ], + SingleSided: false)]); + + private static WorldEntity Entity( + uint id, + uint guid, + Vector3 position, + float scale, + Quaternion rotation) + => new() + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = position, + Rotation = rotation, + Scale = scale, + MeshRefs = [], + }; + + private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) + { + var position = new CreateObject.ServerPosition( + 0x0101_0001u, + 10f, + 10f, + 5f, + 1f, + 0f, + 0f, + 0f); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x0200_0001u, + MotionTableId: 0x0900_0001u, + 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(1, 1, 1, 1, 0, 1, 0, 1, instance)); + return new WorldSession.EntitySpawn( + guid, + position, + 0x0200_0001u, + [], + [], + [], + null, + null, + "fixture", + null, + null, + 0x0900_0001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index b578950f..ebf3c29e 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -28,6 +28,7 @@ public sealed class LiveEntityRuntimeTests private sealed class AnimationRuntime(WorldEntity entity) : ILiveEntityAnimationRuntime { public WorldEntity Entity { get; } = entity; + public uint CurrentMotion { get; init; } } private sealed class EffectProfile : ILiveEntityEffectProfile { } From fa8d5232cce4cd7885740ae2389a89f4a2e4a6da Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 07:24:26 +0200 Subject: [PATCH 15/98] refactor(interaction): add selection interaction owner Introduce a tested controller for selection mutations, target-mode interception, outbound ordering, Use/PickUp lifetime, and live-incarnation cleanup, plus narrow movement and WorldSession transport adapters. --- .../PlayerInteractionMovementSink.cs | 57 ++++ .../SelectionInteractionController.cs | 296 ++++++++++++++++ .../SelectionInteractionTransport.cs | 62 ++++ .../Interaction/WorldSelectionQuery.cs | 17 +- .../SelectionInteractionControllerTests.cs | 323 ++++++++++++++++++ 5 files changed, 754 insertions(+), 1 deletion(-) create mode 100644 src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs create mode 100644 src/AcDream.App/Interaction/SelectionInteractionController.cs create mode 100644 src/AcDream.App/Interaction/SelectionInteractionTransport.cs create mode 100644 tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs diff --git a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs new file mode 100644 index 00000000..2890aedb --- /dev/null +++ b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs @@ -0,0 +1,57 @@ +using AcDream.App.Input; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; + +namespace AcDream.App.Interaction; + +internal interface IPlayerInteractionMovementSink +{ + bool BeginApproach(InteractionApproach approach); +} + +/// +/// Installs retail's client-side TurnToObject/MoveToObject prediction through +/// the same MovementManager used by authoritative movement packets. +/// +internal sealed class PlayerInteractionMovementSink( + Func player) + : IPlayerInteractionMovementSink +{ + private readonly Func _player = player + ?? throw new ArgumentNullException(nameof(player)); + + public bool BeginApproach(InteractionApproach approach) + { + PlayerMovementController? controller = _player(); + if (controller?.MoveTo is null) + return false; + + var parameters = new MovementParameters + { + DistanceToObject = approach.UseRadius, + CanCharge = approach.CanCharge, + }; + var movement = new MovementStruct + { + ObjectId = approach.Target.ServerGuid, + TopLevelId = approach.Target.ServerGuid, + Pos = new Position( + approach.Player.CellId, + approach.Target.Entity.Position, + System.Numerics.Quaternion.Identity), + Params = parameters, + Type = approach.IsCloseRange + ? MovementType.TurnToObject + : MovementType.MoveToObject, + Radius = approach.TargetRadius, + Height = approach.TargetHeight, + }; + + // P1's wire MoveToObject store marks the command non-autonomous. + // The speculative local install must do the same or the next raw-input + // pump overwrites it before ACE's authoritative movement arrives. + controller.SetLastMoveWasAutonomous(false); + controller.Movement.PerformMovement(movement); + return true; + } +} diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs new file mode 100644 index 00000000..67e37dde --- /dev/null +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -0,0 +1,296 @@ +using AcDream.App.Input; +using AcDream.App.UI; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.Ui; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Interaction; + +/// +/// Owns world selection mutations and one-shot Use/PickUp interaction state. +/// Read-only classification stays in ; +/// retained item policy stays in . +/// +internal sealed class SelectionInteractionController +{ + private readonly SelectionState _selection; + private readonly IWorldSelectionQuery _query; + private readonly ItemInteractionController _items; + private readonly ISelectionInteractionTransport _transport; + private readonly IPlayerInteractionMovementSink _movement; + private readonly OutboundInteractionQueue _outbound = new(); + private readonly Action? _toast; + private PendingPostArrivalAction? _pendingPostArrival; + + private readonly record struct PendingPostArrivalAction( + uint ServerGuid, + uint LocalEntityId, + bool IsPickup, + uint DestinationContainerId, + int Placement); + + public SelectionInteractionController( + SelectionState selection, + IWorldSelectionQuery query, + ItemInteractionController items, + ISelectionInteractionTransport transport, + IPlayerInteractionMovementSink movement, + Action? toast = null) + { + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _query = query ?? throw new ArgumentNullException(nameof(query)); + _items = items ?? throw new ArgumentNullException(nameof(items)); + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + _movement = movement ?? throw new ArgumentNullException(nameof(movement)); + _toast = toast; + } + + public bool HandleInputAction(InputAction action) + { + switch (action) + { + case InputAction.SelectionClosestMonster: + SelectClosestCombatTarget(showToast: true); + return true; + case InputAction.SelectionPreviousSelection: + _selection.SelectPrevious(); + return true; + case InputAction.SelectLeft: + PickAndStoreSelection(useImmediately: false); + return true; + case InputAction.SelectDblLeft: + PickAndStoreSelection(useImmediately: true); + return true; + case InputAction.UseSelected: + UseCurrentSelection(); + return true; + case InputAction.SelectionPickUp: + if (_selection.SelectedObjectId is uint pickupTarget) + { + _outbound.Enqueue(() => + _items.PlaceWorldItemInBackpack(pickupTarget)); + } + else + { + _toast?.Invoke("Nothing selected"); + } + return true; + case InputAction.EscapeKey when _items.IsAnyTargetModeActive: + _items.CancelTargetMode(); + return true; + default: + return false; + } + } + + public uint? PickAt(float mouseX, float mouseY, bool includeSelf) + => _query.PickAt(mouseX, mouseY, includeSelf); + + public uint? PickAtCursor(bool includeSelf) + => _query.PickAtCursor(includeSelf); + + public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) + { + if (_selection.SelectedObjectId is { } selected + && _query.IsHostileMonster(selected)) + { + return selected; + } + return autoTarget ? SelectClosestCombatTarget(showToast: false) : null; + } + + public uint? SelectClosestCombatTarget(bool showToast) + { + ClosestCombatTarget? closest = _query.FindClosestHostileMonster(); + uint? bestGuid = closest?.ServerGuid; + if (bestGuid is { } selected) + _selection.Select(selected, SelectionChangeSource.Keyboard); + else + _selection.Clear(SelectionChangeSource.Keyboard); + + if (bestGuid is { } guid) + { + string label = _query.Describe(guid); + float distance = MathF.Sqrt(closest!.Value.DistanceSquared); + Console.WriteLine($"combat: selected target 0x{guid:X8} {label} dist={distance:F1}"); + if (showToast) + _toast?.Invoke($"Target {label}"); + } + else if (showToast) + { + _toast?.Invoke("No monster target"); + Console.WriteLine("combat: no creature target found"); + } + return bestGuid; + } + + public void PickAndStoreSelection(bool useImmediately) + { + uint? picked = _query.PickAtCursor(_items.IsAnyTargetModeActive); + if (picked is not uint guid) + { + if (!_items.IsAnyTargetModeActive) + _toast?.Invoke("Nothing to select"); + return; + } + + // UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound + // @ 0x004E5AD0 pulses before select/use/target-mode branching. + _query.BeginLightingPulse(guid); + if (_items.OfferPrimaryClick(guid) is not ItemPrimaryClickResult.NotActive) + return; + + _selection.Select(guid, SelectionChangeSource.World); + string label = _query.Describe(guid); + Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); + _toast?.Invoke($"Selected: {label}"); + if (useImmediately) + _outbound.Enqueue(() => _items.ActivateItem(guid)); + } + + public void UseCurrentSelection() + { + if (_selection.SelectedObjectId is not uint selected) + { + _toast?.Invoke("Nothing selected"); + return; + } + _outbound.Enqueue(() => _items.UseSelectedOrEnterMode(selected)); + } + + public void SendUse(uint serverGuid) + { + if (!_transport.IsInWorld) + { + _toast?.Invoke("Not in world"); + return; + } + if (!_query.IsUseable(serverGuid) + || !_query.TryGetApproach(serverGuid, out InteractionApproach approach)) + { + return; + } + + _movement.BeginApproach(approach); + if (approach.IsCloseRange) + { + _pendingPostArrival = new PendingPostArrivalAction( + serverGuid, + approach.Target.LocalEntityId, + IsPickup: false, + DestinationContainerId: 0u, + Placement: 0); + return; + } + + if (_transport.TrySendUse(serverGuid, out uint sequence)) + Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}"); + } + + public void SendPickup(uint itemGuid, uint destinationContainerId, int placement) + { + if (!_transport.IsInWorld) + { + _toast?.Invoke("Not in world"); + return; + } + if (_query.IsCreature(itemGuid)) + { + _toast?.Invoke(RetailMessages.CannotPickUpCreatures); + return; + } + if (!_query.IsPickupable(itemGuid)) + { + _toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(itemGuid))); + return; + } + if (!_query.TryGetApproach(itemGuid, out InteractionApproach approach)) + return; + + _movement.BeginApproach(approach); + if (approach.IsCloseRange) + { + _pendingPostArrival = new PendingPostArrivalAction( + itemGuid, + approach.Target.LocalEntityId, + IsPickup: true, + destinationContainerId, + placement); + return; + } + + if (_transport.TrySendPickup( + itemGuid, + destinationContainerId, + placement, + out uint sequence)) + { + Console.WriteLine( + $"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}"); + } + } + + /// Fires only after natural MoveToComplete(None), never cancellation. + public void OnNaturalMoveToComplete() + { + if (_pendingPostArrival is not { } pending) + return; + _pendingPostArrival = null; + if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId)) + return; + + if (pending.IsPickup) + { + _transport.TrySendPickup( + pending.ServerGuid, + pending.DestinationContainerId, + pending.Placement, + out _); + } + else + { + _transport.TrySendUse(pending.ServerGuid, out _); + } + } + + public void DrainOutbound() => _outbound.Drain(); + + public void OnEntityHidden(uint serverGuid) + { + if (_pendingPostArrival?.ServerGuid == serverGuid) + _pendingPostArrival = null; + if (_selection.SelectedObjectId == serverGuid) + { + _selection.Clear( + SelectionChangeSource.System, + SelectionChangeReason.Cleared); + } + } + + public void OnEntityRemoved(LiveEntityRecord record, bool replacementExists) + { + ArgumentNullException.ThrowIfNull(record); + if (_pendingPostArrival is { } pending + && pending.ServerGuid == record.ServerGuid + && pending.LocalEntityId == record.LocalEntityId) + { + _pendingPostArrival = null; + } + if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid) + { + _selection.Clear( + SelectionChangeSource.System, + SelectionChangeReason.SelectedObjectRemoved); + } + } + + public void ResetSession() + { + _items.ResetSession(); + _selection.Reset(); + _outbound.Clear(); + _pendingPostArrival = null; + } +} diff --git a/src/AcDream.App/Interaction/SelectionInteractionTransport.cs b/src/AcDream.App/Interaction/SelectionInteractionTransport.cs new file mode 100644 index 00000000..7af08489 --- /dev/null +++ b/src/AcDream.App/Interaction/SelectionInteractionTransport.cs @@ -0,0 +1,62 @@ +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Interaction; + +internal interface ISelectionInteractionTransport +{ + bool IsInWorld { get; } + bool TrySendUse(uint serverGuid, out uint sequence); + bool TrySendPickup( + uint itemGuid, + uint destinationContainerId, + int placement, + out uint sequence); +} + +internal sealed class WorldSessionSelectionInteractionTransport( + Func session) + : ISelectionInteractionTransport +{ + private readonly Func _session = session + ?? throw new ArgumentNullException(nameof(session)); + + public bool IsInWorld => + _session()?.CurrentState == WorldSession.State.InWorld; + + public bool TrySendUse(uint serverGuid, out uint sequence) + { + WorldSession? live = _session(); + if (live?.CurrentState != WorldSession.State.InWorld) + { + sequence = 0u; + return false; + } + + sequence = live.NextGameActionSequence(); + live.SendGameAction(InteractRequests.BuildUse(sequence, serverGuid)); + return true; + } + + public bool TrySendPickup( + uint itemGuid, + uint destinationContainerId, + int placement, + out uint sequence) + { + WorldSession? live = _session(); + if (live?.CurrentState != WorldSession.State.InWorld) + { + sequence = 0u; + return false; + } + + sequence = live.NextGameActionSequence(); + live.SendGameAction(InteractRequests.BuildPickUp( + sequence, + itemGuid, + destinationContainerId, + placement)); + return true; + } +} diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index f6a5cae1..f5548a81 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -34,12 +34,27 @@ internal readonly record struct InteractionApproach( float TargetRadius, float TargetHeight); +internal interface IWorldSelectionQuery +{ + uint? PickAtCursor(bool includeSelf); + uint? PickAt(float mouseX, float mouseY, bool includeSelf); + void BeginLightingPulse(uint serverGuid); + bool IsCurrent(uint serverGuid, uint localEntityId); + string Describe(uint serverGuid); + bool IsCreature(uint serverGuid); + bool IsHostileMonster(uint serverGuid); + ClosestCombatTarget? FindClosestHostileMonster(); + bool IsUseable(uint serverGuid); + bool IsPickupable(uint serverGuid); + bool TryGetApproach(uint serverGuid, out InteractionApproach approach); +} + /// /// Read-only owner for retail world selection and interaction classification. /// It consumes the canonical live-entity runtime but never mutates selection, /// movement, inventory, or network state. /// -internal sealed class WorldSelectionQuery +internal sealed class WorldSelectionQuery : IWorldSelectionQuery { private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u; private const uint StuckObjectFlag = 0x0004u; diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs new file mode 100644 index 00000000..c9fa956f --- /dev/null +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -0,0 +1,323 @@ +using System.Numerics; +using AcDream.App.Interaction; +using AcDream.App.UI; +using AcDream.App.World; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.World; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.Interaction; + +public sealed class SelectionInteractionControllerTests +{ + private const uint Player = 0x5000_0001u; + private const uint Target = 0x7000_0001u; + + private sealed class Query : IWorldSelectionQuery + { + public uint? Picked { get; set; } + public bool LastIncludeSelf { get; private set; } + public bool Current { get; set; } = true; + public bool Creature { get; set; } + public bool Hostile { get; set; } + public bool Useable { get; set; } = true; + public bool Pickupable { get; set; } = true; + public ClosestCombatTarget? Closest { get; set; } + public InteractionApproach? Approach { get; set; } + public List Events { get; } = new(); + + public uint? PickAtCursor(bool includeSelf) + { + LastIncludeSelf = includeSelf; + Events.Add("pick"); + return Picked; + } + + public uint? PickAt(float mouseX, float mouseY, bool includeSelf) + => PickAtCursor(includeSelf); + + public void BeginLightingPulse(uint serverGuid) => Events.Add("pulse"); + public bool IsCurrent(uint serverGuid, uint localEntityId) => Current; + public string Describe(uint serverGuid) => $"Target {serverGuid:X8}"; + public bool IsCreature(uint serverGuid) => Creature; + public bool IsHostileMonster(uint serverGuid) => Hostile; + public ClosestCombatTarget? FindClosestHostileMonster() => Closest; + public bool IsUseable(uint serverGuid) => Useable; + public bool IsPickupable(uint serverGuid) => Pickupable; + + public bool TryGetApproach(uint serverGuid, out InteractionApproach approach) + { + if (Approach is { } found) + { + approach = found; + return true; + } + approach = default; + return false; + } + } + + private sealed class Transport : ISelectionInteractionTransport + { + private uint _sequence; + public bool IsInWorld { get; set; } = true; + public List Uses { get; } = new(); + public List<(uint Item, uint Container, int Placement)> Pickups { get; } = new(); + + public bool TrySendUse(uint serverGuid, out uint sequence) + { + if (!IsInWorld) + { + sequence = 0u; + return false; + } + sequence = ++_sequence; + Uses.Add(serverGuid); + return true; + } + + public bool TrySendPickup( + uint itemGuid, + uint destinationContainerId, + int placement, + out uint sequence) + { + if (!IsInWorld) + { + sequence = 0u; + return false; + } + sequence = ++_sequence; + Pickups.Add((itemGuid, destinationContainerId, placement)); + return true; + } + } + + private sealed class Movement : IPlayerInteractionMovementSink + { + public List Approaches { get; } = new(); + public bool BeginApproach(InteractionApproach approach) + { + Approaches.Add(approach); + return true; + } + } + + private sealed class Harness + { + public readonly Query Query = new(); + public readonly Transport Transport = new(); + public readonly Movement Movement = new(); + public readonly SelectionState Selection = new(); + public readonly ClientObjectTable Objects = new(); + public readonly List Toasts = new(); + public readonly List Examines = new(); + public readonly ItemInteractionController Items; + public readonly SelectionInteractionController Controller; + + public Harness() + { + SelectionInteractionController? controller = null; + Objects.AddOrUpdate(new ClientObject + { + ObjectId = Player, + Type = ItemType.Creature, + }); + Items = new ItemInteractionController( + Objects, + () => Player, + sendUse: guid => controller!.SendUse(guid), + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + sendExamine: guid => + { + Query.Events.Add("examine"); + Examines.Add(guid); + }, + placeInBackpack: (item, container, placement) => + controller!.SendPickup(item, container, placement)); + Controller = controller = new SelectionInteractionController( + Selection, + Query, + Items, + Transport, + Movement, + Toasts.Add); + } + + public void SetApproach(bool closeRange, uint localEntityId = 101u) + { + var entity = new WorldEntity + { + Id = localEntityId, + ServerGuid = Target, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = new Vector3(5f, 0f, 0f), + Rotation = Quaternion.Identity, + MeshRefs = [], + }; + Query.Approach = new InteractionApproach( + new WorldInteractionTarget(Target, localEntityId, entity), + new PlayerInteractionPose(0x0101_0001u, Vector3.Zero), + 0.6f, + closeRange, + CanCharge: !closeRange, + TargetRadius: 0.5f, + TargetHeight: 2f); + } + } + + [Fact] + public void TargetModeClickPulsesBeforeItIsConsumedAndIncludesSelf() + { + var h = new Harness(); + h.Query.Picked = Target; + h.Items.InteractionState.EnterExamine(); + + Assert.True(h.Controller.HandleInputAction(InputAction.SelectLeft)); + + Assert.True(h.Query.LastIncludeSelf); + Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events); + Assert.Equal(new[] { Target }, h.Examines); + Assert.Null(h.Selection.SelectedObjectId); + } + + [Fact] + public void ClosestTargetInputMutatesSelectionOnce() + { + var h = new Harness(); + h.Query.Closest = new ClosestCombatTarget(Target, 25f); + + Assert.True(h.Controller.HandleInputAction(InputAction.SelectionClosestMonster)); + + Assert.Equal(Target, h.Selection.SelectedObjectId); + Assert.Contains(h.Toasts, text => text.Contains("Target 70000001")); + } + + [Fact] + public void CloseUseTurnsFirstThenSendsExactlyOnceOnNaturalCompletion() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + + h.Controller.SendUse(Target); + + Assert.Single(h.Movement.Approaches); + Assert.Empty(h.Transport.Uses); + + h.Controller.OnNaturalMoveToComplete(); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Equal(new[] { Target }, h.Transport.Uses); + } + + [Fact] + public void FarUseSendsImmediatelyAndNaturalCompletionDoesNotRetry() + { + var h = new Harness(); + h.SetApproach(closeRange: false); + + h.Controller.SendUse(Target); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Single(h.Movement.Approaches); + Assert.Equal(new[] { Target }, h.Transport.Uses); + } + + [Fact] + public void HiddenOrStaleIncarnationCancelsDeferredAction() + { + var hidden = new Harness(); + hidden.SetApproach(closeRange: true); + hidden.Controller.SendUse(Target); + hidden.Controller.OnEntityHidden(Target); + hidden.Controller.OnNaturalMoveToComplete(); + Assert.Empty(hidden.Transport.Uses); + + var stale = new Harness(); + stale.SetApproach(closeRange: true); + stale.Controller.SendUse(Target); + stale.Query.Current = false; + stale.Controller.OnNaturalMoveToComplete(); + Assert.Empty(stale.Transport.Uses); + } + + [Fact] + public void PickupInputWaitsForFrameDrainThenUsesPendingDestination() + { + var h = new Harness(); + h.SetApproach(closeRange: false); + h.Selection.Select(Target, SelectionChangeSource.World); + + Assert.True(h.Controller.HandleInputAction(InputAction.SelectionPickUp)); + Assert.Empty(h.Transport.Pickups); + + h.Controller.DrainOutbound(); + + Assert.Equal(new[] { (Target, Player, 0) }, h.Transport.Pickups); + } + + [Fact] + public void SessionResetClearsQueuedAndDeferredWork() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Selection.Select(Target, SelectionChangeSource.World); + h.Controller.HandleInputAction(InputAction.SelectionPickUp); + h.Controller.SendUse(Target); + + h.Controller.ResetSession(); + h.Controller.DrainOutbound(); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Null(h.Selection.SelectedObjectId); + Assert.Empty(h.Transport.Pickups); + Assert.Empty(h.Transport.Uses); + } + + [Fact] + public void OldRemovalClearsCapturedActionButDoesNotClearReplacementSelection() + { + var h = new Harness(); + h.SetApproach(closeRange: true, localEntityId: 101u); + h.Selection.Select(Target, SelectionChangeSource.World); + h.Controller.SendUse(Target); + var oldRecord = new LiveEntityRecord(Spawn(Target, instance: 1)); + oldRecord.WorldEntity = new WorldEntity + { + Id = 101u, + ServerGuid = Target, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [], + }; + + h.Controller.OnEntityRemoved(oldRecord, replacementExists: true); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Equal(Target, h.Selection.SelectedObjectId); + Assert.Empty(h.Transport.Uses); + } + + private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) + => new( + guid, + Position: null, + SetupTableId: null, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: null, + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: instance); +} From d2bb5af45300d9888e1bd2a66c67c392d3dbca82 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 07:26:02 +0200 Subject: [PATCH 16/98] refactor(interaction): cut GameWindow over to controller Route selection input, frame-ordered dispatch, movement completion, hidden/delete cleanup, and session reset through SelectionInteractionController. Keep ItemInteractionController backend-independent by constructing it outside the retained-UI gate. --- src/AcDream.App/Rendering/GameWindow.cs | 750 ++++-------------------- 1 file changed, 111 insertions(+), 639 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6c4996a1..d562c822 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -45,6 +45,7 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher; private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene; private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery; + private AcDream.App.Interaction.SelectionInteractionController? _selectionInteractions; /// Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters /// support. Required at startup — missing bindless throws /// in OnLoad. @@ -183,7 +184,6 @@ public sealed class GameWindow : IDisposable private readonly LiveEntityAnimationScheduler _liveAnimationScheduler; private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; - private readonly AcDream.App.Input.OutboundInteractionQueue _outboundInteractions = new(); private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator; private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; @@ -1162,20 +1162,6 @@ public sealed class GameWindow : IDisposable /// private IReadOnlyDictionary LastSpawns => _liveEntities?.Snapshots ?? EmptyLiveSpawnMap; - // B.6/B.7 (2026-05-16): pending close-range action that will be fired - // once the local auto-walk overlay reports arrival (body has finished - // rotating to face the target). Only set for close-range Use/PickUp; - // far-range sends fire the wire packet immediately at SendUse/SendPickUp - // time. Cleared before the deferred send fires — single-fire, no retry. - private PendingPostArrivalAction? _pendingPostArrivalAction; - - private readonly record struct PendingPostArrivalAction( - uint Guid, - uint LocalEntityId, - bool IsPickup, - uint DestinationContainerId = 0u, - int Placement = 0); - // R5-V2: the local player's CPhysicsObj stand-in — owns the player's // TargetManager voyeur system, registered in _physicsHosts so remote // entities chasing the player resolve it. Replaces the AP-79 @@ -2114,63 +2100,64 @@ public sealed class GameWindow : IDisposable Objects, guid => _liveSession?.SendNoLongerViewingContents(guid)); + AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!; + _itemInteractionController = new AcDream.App.UI.ItemInteractionController( + Objects, + playerGuid: () => _playerServerGuid, + // ItemHolder::UseObject is the common policy owner, but world + // activation still has to pass through the application-level + // use adapter. It owns speculative turn/move, the close-range + // deferred send, and ACE's far-range MoveTo callback. Calling + // WorldSession directly here made keyboard R approach a corpse + // without completing the same open transaction as double-click. + sendUse: SendUse, + sendExamine: g => _liveSession?.SendAppraise(g), + sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target), + sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), + sendDrop: item => _liveSession?.SendDropItem(item), + sendGive: (target, item, amount) => + _liveSession?.SendGiveObject(target, item, amount), + dragOnPlayerOpensSecureTrade: () => + (_characterOptions1 + & AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 + .DragItemOnPlayerOpensSecureTrade) != 0, + toast: text => _debugVm?.AddToast(text), + readyForInventoryRequest: () => _liveSession is not null + && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld, + playerOnGround: GetDebugPlayerOnGround, + inNonCombatMode: () => Combat.CurrentMode + == AcDream.Core.Combat.CombatMode.NonCombat, + combatState: Combat, + sendChangeCombatMode: mode => + _liveSession?.SendChangeCombatMode(mode), + isComponentPack: magicCatalog.IsComponentPack, + placeInBackpack: SendPickUp, + backpackContainerId: () => + _retailUiRuntime?.InventoryPanelController?.CurrentOpenContainerId + ?? _playerServerGuid, + // Retail ItemHolder::DetermineUseResult treats children of the + // current ground object as loot. Keep this late-bound because + // ViewContents can replace/close the external container while + // the retained controller remains alive. + groundObjectId: () => _externalContainers.CurrentContainerId, + sendSplitToWorld: (item, amount) => + _liveSession?.SendStackableSplitTo3D(item, amount), + selectedObjectId: () => _selection.SelectedObjectId ?? 0u, + stackSplitQuantity: _stackSplitQuantity, + systemMessage: text => Chat.OnSystemMessage(text, 0x1Au), + sendPutItemInContainer: (item, container, placement) => + _liveSession?.SendPutItemInContainer(item, container, placement), + sendSplitToContainer: (item, container, placement, amount) => + _liveSession?.SendStackableSplitToContainer( + item, container, placement, amount), + requestExternalContainer: guid => _externalContainers.RequestOpen(guid)); + // Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1). if (_options.RetailUi) { _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); _uiHost.Root.UiLocked = _persistedGameplay.LockUI; - AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!; - _itemInteractionController = new AcDream.App.UI.ItemInteractionController( - Objects, - playerGuid: () => _playerServerGuid, - // ItemHolder::UseObject is the common policy owner, but world - // activation still has to pass through the application-level - // use adapter. It owns speculative turn/move, the close-range - // deferred send, and ACE's far-range MoveTo callback. Calling - // WorldSession directly here made keyboard R approach a corpse - // without completing the same open transaction as double-click. - sendUse: SendUse, - sendExamine: g => _liveSession?.SendAppraise(g), - sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target), - sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), - sendDrop: item => _liveSession?.SendDropItem(item), - sendGive: (target, item, amount) => - _liveSession?.SendGiveObject(target, item, amount), - dragOnPlayerOpensSecureTrade: () => - (_characterOptions1 - & AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 - .DragItemOnPlayerOpensSecureTrade) != 0, - toast: text => _debugVm?.AddToast(text), - readyForInventoryRequest: () => _liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld, - playerOnGround: GetDebugPlayerOnGround, - inNonCombatMode: () => Combat.CurrentMode - == AcDream.Core.Combat.CombatMode.NonCombat, - combatState: Combat, - sendChangeCombatMode: mode => - _liveSession?.SendChangeCombatMode(mode), - isComponentPack: magicCatalog.IsComponentPack, - placeInBackpack: SendPickUp, - backpackContainerId: () => - _retailUiRuntime?.InventoryPanelController?.CurrentOpenContainerId - ?? _playerServerGuid, - // Retail ItemHolder::DetermineUseResult treats children of the - // current ground object as loot. Keep this late-bound because - // ViewContents can replace/close the external container while - // the retained controller remains alive. - groundObjectId: () => _externalContainers.CurrentContainerId, - sendSplitToWorld: (item, amount) => - _liveSession?.SendStackableSplitTo3D(item, amount), - selectedObjectId: () => _selection.SelectedObjectId ?? 0u, - stackSplitQuantity: _stackSplitQuantity, - systemMessage: text => Chat.OnSystemMessage(text, 0x1Au), - sendPutItemInContainer: (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement), - sendSplitToContainer: (item, container, placement, amount) => - _liveSession?.SendStackableSplitToContainer( - item, container, placement, amount), - requestExternalContainer: guid => _externalContainers.RequestOpen(guid)); var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( _itemInteractionController, // Retail UpdateCursorState (0x00564630) keys target-mode @@ -2780,6 +2767,18 @@ public sealed class GameWindow : IDisposable return (sphere.Origin, sphere.Radius); } }); + if (_itemInteractionController is { } itemInteractions) + { + _selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController( + _selection, + _worldSelectionQuery, + itemInteractions, + new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport( + () => _liveSession), + new AcDream.App.Interaction.PlayerInteractionMovementSink( + () => _playerController), + text => _debugVm?.AddToast(text)); + } // A.5 T22.5: apply A2C gate from quality preset. _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; @@ -3020,10 +3019,13 @@ public sealed class GameWindow : IDisposable Objects.Clear(); SpellBook.Clear(); _magicRuntime?.Reset(); - _itemInteractionController?.ResetSession(); - _selection.Reset(); - _outboundInteractions.Clear(); - _pendingPostArrivalAction = null; + if (_selectionInteractions is { } interactions) + interactions.ResetSession(); + else + { + _itemInteractionController?.ResetSession(); + _selection.Reset(); + } _retailSelectionScene?.Reset(); _particleVisibility.Reset(); try @@ -5493,16 +5495,12 @@ public sealed class GameWindow : IDisposable private void ClearTargetForHiddenEntity(uint serverGuid) { - if (_pendingPostArrivalAction is { Guid: var pendingGuid } - && pendingGuid == serverGuid) - _pendingPostArrivalAction = null; - - if (_selection.SelectedObjectId == serverGuid) - { + if (_selectionInteractions is { } interactions) + interactions.OnEntityHidden(serverGuid); + else if (_selection.SelectedObjectId == serverGuid) _selection.Clear( AcDream.Core.Selection.SelectionChangeSource.System, AcDream.Core.Selection.SelectionChangeReason.Cleared); - } if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost) && hiddenHost is EntityPhysicsHost hiddenEntityHost) @@ -5531,11 +5529,18 @@ public sealed class GameWindow : IDisposable () => _remoteTeleportController?.Forget(record), () => { - if (_pendingPostArrivalAction is { Guid: var pendingGuid } - && pendingGuid == serverGuid - && _pendingPostArrivalAction.Value.LocalEntityId == record.LocalEntityId) + bool replacementExists = + _liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord current) == true + && !ReferenceEquals(current, record); + if (_selectionInteractions is { } interactions) { - _pendingPostArrivalAction = null; + interactions.OnEntityRemoved(record, replacementExists); + } + else if (!replacementExists && _selection.SelectedObjectId == serverGuid) + { + _selection.Clear( + AcDream.Core.Selection.SelectionChangeSource.System, + AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved); } }, }; @@ -5599,16 +5604,6 @@ public sealed class GameWindow : IDisposable // incarnation. cleanups.Add(() => incarnation.RunIfNoReplacement( () => _remoteLastMove.Remove(serverGuid))); - cleanups.Add(() => incarnation.RunIfNoReplacement(() => - { - if (_selection.SelectedObjectId == serverGuid) - { - _selection.Clear( - AcDream.Core.Selection.SelectionChangeSource.System, - AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved); - } - })); - cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188 cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record)); // Logical teardown ends the retained shadow payload too. The weaker @@ -5948,7 +5943,7 @@ public sealed class GameWindow : IDisposable // Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled - && IsDoorName(LiveName(update.Guid))) + && IsDoorName(Objects.Get(update.Guid)?.Name)) { Console.WriteLine(System.FormattableString.Invariant( $"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}")); @@ -11495,7 +11490,7 @@ public sealed class GameWindow : IDisposable // active server MoveTo chain when it receives MoveToState, so sending // Use directly from Silk's key callback inverted that order and left // the client walking to a corpse whose server callback was cancelled. - _outboundInteractions.Drain(); + _selectionInteractions?.DrainOutbound(); _scriptRunner?.PublishTime(_physicsScriptGameTime); System.Numerics.Vector3? playerPosition = _entitiesByServerGuid.TryGetValue( @@ -13503,6 +13498,9 @@ public sealed class GameWindow : IDisposable if (_retailUiRuntime?.HandleInputAction(action) == true) return; + if (_selectionInteractions?.HandleInputAction(action) == true) + return; + // K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press // Q to start, press Q again to stop. Pressing Backup / Stop / // StrafeLeft / StrafeRight while autorun is active also cancels it @@ -13599,38 +13597,10 @@ public sealed class GameWindow : IDisposable _settingsPanel.IsVisible = !_settingsPanel.IsVisible; break; - case AcDream.UI.Abstractions.Input.InputAction.SelectionClosestMonster: - SelectClosestCombatTarget(showToast: true); - break; - - case AcDream.UI.Abstractions.Input.InputAction.SelectionPreviousSelection: - _selection.SelectPrevious(); - break; - case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat: ToggleLiveCombatMode(); break; - case AcDream.UI.Abstractions.Input.InputAction.SelectLeft: - PickAndStoreSelection(useImmediately: false); - break; - - case AcDream.UI.Abstractions.Input.InputAction.SelectDblLeft: - PickAndStoreSelection(useImmediately: true); - break; - - case AcDream.UI.Abstractions.Input.InputAction.UseSelected: - UseCurrentSelection(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp: - if (_selection.SelectedObjectId is uint pickupTarget) - _outboundInteractions.Enqueue(() => - _itemInteractionController?.PlaceWorldItemInBackpack(pickupTarget)); - else - _debugVm?.AddToast("Nothing selected"); - break; - case AcDream.UI.Abstractions.Input.InputAction.EscapeKey: if (_itemInteractionController?.IsAnyTargetModeActive == true) _itemInteractionController.CancelTargetMode(); @@ -13757,15 +13727,8 @@ public sealed class GameWindow : IDisposable result); private uint? GetSelectedOrClosestCombatTarget() - { - if (_selection.SelectedObjectId is { } selected && IsLiveHostileMonsterTarget(selected)) - return selected; - - if (!_persistedGameplay.AutoTarget) - return null; - - return SelectClosestCombatTarget(showToast: false); - } + => _selectionInteractions?.GetSelectedOrClosestCombatTarget( + _persistedGameplay.AutoTarget); /// /// Resolves retail's combat-camera target. ClientCombatSystem:: @@ -13798,450 +13761,44 @@ public sealed class GameWindow : IDisposable /// pick the local player while plain selection excludes it. /// private uint? PickWorldGuidAtCursor(bool includeSelf) - => _worldSelectionQuery?.PickAtCursor(includeSelf); + => _selectionInteractions?.PickAtCursor(includeSelf) + ?? _worldSelectionQuery?.PickAtCursor(includeSelf); private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf) - => _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf); + => _selectionInteractions?.PickAt(mouseX, mouseY, includeSelf) + ?? _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf); private void BeginSelectionLightingPulse(uint serverGuid) => _worldSelectionQuery?.BeginLightingPulse(serverGuid); - private void PickAndStoreSelection(bool useImmediately) - { - if (_cameraController is null || _window is null) return; - - // Target-use mode picks WITH self (retail: kit-heal yourself by - // clicking your own toon); plain selection keeps the self-exclusion. - var picked = PickWorldGuidAtCursor( - includeSelf: _itemInteractionController?.IsAnyTargetModeActive == true); - - if (picked is uint guid) - { - // Retail UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound - // @ 0x004E5AD0 pulses every successfully found click before it - // branches into select/use/targeted-use behavior. - BeginSelectionLightingPulse(guid); - - if (_itemInteractionController?.OfferPrimaryClick(guid) - is not null and not AcDream.App.UI.ItemPrimaryClickResult.NotActive) - return; - - _selection.Select(guid, AcDream.Core.Selection.SelectionChangeSource.World); - string label = DescribeLiveEntity(guid); - Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); - // B.7 (2026-05-15): one-shot per-pick diagnostic so we can - // see exactly which ItemType + PWD bitfield bits + resolved - // RadarBlipColor are produced for the just-picked entity. - // Helps verify whether a "green NPC" really is flagged as - // Vendor server-side or whether our lookup is wrong. - uint rawItemType = (uint)LiveItemType(guid); - uint pwdBits = 0; - uint? pickUseability = null; - float? pickUseRadius = null; - float pickScale = 1f; - uint? pickSetupId = null; - if (LastSpawns.TryGetValue(guid, out var spawn)) - { - if (spawn.ObjectDescriptionFlags is { } odf) pwdBits = odf; - pickUseability = spawn.Useability; - pickUseRadius = spawn.UseRadius; - pickScale = spawn.ObjScale ?? 1f; - pickSetupId = spawn.SetupTableId; - } - var col = AcDream.Core.Ui.RadarBlipColors.For(rawItemType, pwdBits); - string useStr = pickUseability.HasValue ? $"0x{pickUseability.Value:X4}" : "null"; - string radStr = pickUseRadius.HasValue ? pickUseRadius.Value.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) : "null"; - string setupStr = pickSetupId.HasValue ? $"0x{pickSetupId.Value:X8}" : "null"; - Console.WriteLine(System.FormattableString.Invariant( - $"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} color=({col.R},{col.G},{col.B})")); - _debugVm?.AddToast($"Selected: {label}"); - if (useImmediately) - { - // Route world double-click through the same retail - // ItemHolder policy as keyboard Use. This both establishes - // ClientUISystem::groundObject for containers and preserves - // movement-release-before-Use wire ordering. - _outboundInteractions.Enqueue(() => - _itemInteractionController?.ActivateItem(guid)); - } - } - else - { - if (_itemInteractionController?.IsAnyTargetModeActive == true) - return; - _debugVm?.AddToast("Nothing to select"); - } - } - - private void UseCurrentSelection() - { - if (_selection.SelectedObjectId is not uint sel) - { - _debugVm?.AddToast("Nothing selected"); - return; - } - - // Retail sends keyboard Use through ItemHolder::UseObject @ 0x00588A80, - // the same policy owner used by the toolbar. Keeping a second classifier here - // made BF_CORPSE containers look pickupable and sent PutItemInContainer, - // which ACE correctly rejected as Stuck (0x0029). - uint selected = sel; - _outboundInteractions.Enqueue(() => - _itemInteractionController?.UseSelectedOrEnterMode(selected)); - } - - private void SendUse(uint guid) - { - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) - { - _debugVm?.AddToast("Not in world"); - return; - } - - // Retail-faithful useability gate (acclient_2013_pseudo_c.txt:256455 - // ItemUses::IsUseable). Signs / banners with useability=0 silently - // ignore Use. - if (!IsUseableTarget(guid)) - { - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[B.4b] SendUse ignored — not useable guid=0x{guid:X8}"); - return; - } - - // B.6/R4-V5: install a speculative local TurnToObject/MoveToObject - // through the player's MoveToManager so close-range Use rotates the - // body to face before the action fires. For FAR targets, ACE's - // CreateMoveToChain (Player_Move.cs:37-179) takes over via inbound - // MovementType=6, whose PerformMovement re-targets with ACE's - // wire-supplied radius. - // - // Close-range deferral fires the wire packet ONCE on - // MoveToComplete(None) (turn-first done), not a retry of an - // earlier failed send. No re-send path. - if (_worldSelectionQuery?.TryGetInteractionTarget( - guid, - out AcDream.App.Interaction.WorldInteractionTarget useTarget) != true) - { - return; - } - - bool closeRange = IsCloseRangeTarget(guid); - InstallSpeculativeTurnToTarget(guid); - - if (closeRange) - { - // Defer the wire packet — OnAutoWalkArrivedSendDeferredAction - // will fire it after rotation completes. - _pendingPostArrivalAction = new PendingPostArrivalAction( - guid, - useTarget.LocalEntityId, - IsPickup: false); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}"); - return; - } - - // Far range: send Use ONCE. ACE's CreateMoveToChain - // (Player_Use.cs:205) holds a callback (TryUseItem) and fires - // it server-side when WithinUseRadius passes during the - // MoveToChain poll (Player_Move.cs:150). No client-side retry - // needed — the Phase B.6 MoveToState-suppression fix - // (GameWindow.cs:6410) keeps ACE's chain alive during the - // walk. - var seq = _liveSession.NextGameActionSequence(); - var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid); - _liveSession.SendGameAction(body); - Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}"); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - string label = DescribeLiveEntity(guid); - Console.WriteLine(System.FormattableString.Invariant( - $"[autowalk-out] op=use target=0x{guid:X8} name=\"{label}\" seq={seq}")); - } - } - - // Phase D.5.1 — direct use-by-guid for toolbar shortcut clicks. - // Mirrors the B.4b far-range send path; no proximity / auto-walk needed - // for items already in the player's inventory. + // Contained/toolbar activation is not a world-selection interaction: it + // sends directly without speculative TurnToObject/MoveToObject. private void UseItemByGuid(uint guid) { if (_liveSession is null || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) return; - var seq = _liveSession.NextGameActionSequence(); - var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid); - _liveSession.SendGameAction(body); - Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={seq}"); + uint sequence = _liveSession.NextGameActionSequence(); + _liveSession.SendGameAction( + AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid)); + Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={sequence}"); } - private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement) - { - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) - { - _debugVm?.AddToast("Not in world"); - return; - } - - // Creature-pickup block (with retail toast). Comes BEFORE the - // generic IsPickupableTarget gate so creatures get the specific - // "cannot pick up creatures!" message instead of the generic - // "can't be picked up!". - // Retail string acclient_2013_pseudo_c.txt:401642 (data_7e22b4). - if (IsLiveCreatureTarget(itemGuid)) - { - _debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotPickUpCreatures); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[B.5] SendPickUp ignored — creature item=0x{itemGuid:X8}"); - return; - } - - // Generic non-pickupable gate (signs, banners, decorative scenery). - // Retail string acclient_2013_pseudo_c.txt:401589 (sprintf - // "The %s can't be picked up!"). - if (!IsPickupableTarget(itemGuid)) - { - string label = DescribeLiveEntity(itemGuid); - _debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CantBePickedUp(label)); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[B.5] SendPickUp ignored — not pickupable item=0x{itemGuid:X8}"); - return; - } - - // B.6 (2026-05-15): same speculative turn-to-target + deferral as - // SendUse — close-range pickup rotates locally to face the - // item first, then the wire packet fires when the local - // overlay reports arrival. - // - // 2026-05-16: simplified — FIRST send on arrival, not a retry. - if (_worldSelectionQuery?.TryGetInteractionTarget( - itemGuid, - out AcDream.App.Interaction.WorldInteractionTarget pickupTarget) != true) - { - return; - } - - bool closeRange = IsCloseRangeTarget(itemGuid); - InstallSpeculativeTurnToTarget(itemGuid); - - if (closeRange) - { - _pendingPostArrivalAction = new PendingPostArrivalAction( - itemGuid, - pickupTarget.LocalEntityId, - IsPickup: true, - destinationContainerId, - placement); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[B.5] pickup deferred (close-range, turn-first) item=0x{itemGuid:X8}"); - return; - } - - // Far range: send PickUp ONCE. Same auto-fire-via-MoveToChain - // callback pattern as SendUse — ACE's chain fires - // PutItemInContainer/Move server-side when in range. No - // client-side retry; Phase B.6 MoveToState suppression keeps - // ACE's chain alive. - var seq = _liveSession.NextGameActionSequence(); - var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp( - seq, itemGuid, destinationContainerId, placement); - _liveSession.SendGameAction(body); - Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={seq}"); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - string label = DescribeLiveEntity(itemGuid); - Console.WriteLine(System.FormattableString.Invariant( - $"[autowalk-out] op=pickup target=0x{itemGuid:X8} name=\"{label}\" seq={seq}")); - } - } - - /// - /// 2026-05-16 / R4-V5. Fires the deferred close-range Use/PickUp action - /// once the player's moveto completes naturally (the MoveToManager's - /// MoveToComplete(None) client seam — the body has finished - /// rotating to face / walking to the target; a user-input cancel never - /// fires it). This is a FIRST send — not a retry of an earlier failed - /// send. Far-range Use/PickUp paths fire the wire packet immediately at - /// / time and never touch - /// _pendingPostArrivalAction. - /// - private void OnAutoWalkArrivedSendDeferredAction() - { - if (_pendingPostArrivalAction is not { } pending) - return; - _pendingPostArrivalAction = null; - - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) - return; - if (_worldSelectionQuery?.IsCurrent( - pending.Guid, - pending.LocalEntityId) != true) - { - return; - } - - var seq = _liveSession.NextGameActionSequence(); - if (pending.IsPickup) - { - var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp( - seq, - pending.Guid, - pending.DestinationContainerId, - pending.Placement); - _liveSession.SendGameAction(body); - Console.WriteLine($"[B.5] pickup-deferred item=0x{pending.Guid:X8} container=0x{pending.DestinationContainerId:X8} placement={pending.Placement} seq={seq}"); - } - else - { - var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, pending.Guid); - _liveSession.SendGameAction(body); - Console.WriteLine($"[B.4b] use-deferred guid=0x{pending.Guid:X8} seq={seq}"); - } - } - - /// - /// B.6 (2026-05-15). Install a local auto-walk overlay against the - /// given target entity at Use / PickUp send time. ACE's response - /// branches by distance: - /// - /// Far target → ACE broadcasts a MovementType=6 - /// MoveToObject which arrives shortly after and overwrites - /// our speculative state with ACE's wire-supplied use-radius - /// and origin. No conflict; same target either way. - /// Close target → ACE skips MoveToChain (WithinUseRadius - /// shortcut at Player_Move.cs:66) and rotates the body - /// server-side via Rotate(target). ACE doesn't broadcast - /// anything actionable to us, so our pre-installed overlay - /// handles the local rotation — body turns to face the target - /// in place, then ends. - /// - /// - /// Per-type use radius mirrors the picker's radius heuristic: - /// 3 m for creatures, 2 m for doors / lifestones / portals, - /// 0.6 m for everything else (ground items). - /// - /// - /// - /// B.6 (2026-05-15). True if the local player is already inside the - /// target's use radius right now — i.e. ACE will NOT auto-walk us - /// when we send the action. Used to gate the close-range deferral - /// in SendUse / SendPickUp: when close, we hold the wire packet - /// until our local rotation overlay reports alignment, then fire. - /// - private bool IsCloseRangeTarget(uint targetGuid) - => _worldSelectionQuery?.TryGetApproach(targetGuid, out var approach) == true - && approach.IsCloseRange; - private bool IsWithinExternalContainerUseRange(uint targetGuid) => _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true; - private void InstallSpeculativeTurnToTarget(uint targetGuid) - { - if (_playerController is not { } pc - || pc.MoveTo is null - || _worldSelectionQuery?.TryGetApproach( - targetGuid, - out AcDream.App.Interaction.InteractionApproach approach) != true) - return; + private void SendUse(uint guid) + => _selectionInteractions?.SendUse(guid); - // Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit - // from local distance so the speculative moveto uses the - // same walk/run as the wire-triggered overwrite that arrives - // moments later. ACE's Creature.SetWalkRunThreshold sets - // CanCharge when player→target distance >= WalkRunThreshold / - // 2 = 7.5 m (the 15 m wire default halved). Match exactly so - // the speculative install doesn't flip walk↔run when ACE's - // MoveToObject broadcast overwrites it (PerformMovement - // cancels + restarts — retail-consistent re-target). - // R4-V5: retail's client-initiated use flow issues TurnToObject / - // MoveToObject through the SAME manager the wire path uses (decomp - // §9a/§9b callers) — in-range targets get a pure turn-to-face; - // out-of-range targets get the local moveto that ACE's mt-6 - // broadcast will re-target moments later. MovementParameters ctor - // defaults (0x1EE0F: MoveTowards, UseSpheres, threshold 15, - // MinDistance 0) match the old BeginServerAutoWalk install's - // semantics; only the AP-23 radius + the #77 CanCharge prediction - // are non-default. - var p = new AcDream.Core.Physics.Motion.MovementParameters - { - DistanceToObject = approach.UseRadius, - CanCharge = approach.CanCharge, - }; - var ms = new AcDream.Core.Physics.MovementStruct - { - ObjectId = targetGuid, - TopLevelId = targetGuid, - Pos = new AcDream.Core.Physics.Position( - approach.Player.CellId, approach.Target.Entity.Position, - System.Numerics.Quaternion.Identity), - Params = p, - Type = approach.IsCloseRange - ? AcDream.Core.Physics.MovementType.TurnToObject - : AcDream.Core.Physics.MovementType.MoveToObject, - }; - // R5-V3 (#171, diff-review find): retail resolves the TARGET's - // PartArray radius/height at EVERY MoveToObject call site — the - // client-initiated use flow included (CPhysicsObj::MoveToObject - // 0x005128e9/0x00512903), not just the wire mt-6 route. Without - // this, the player's now-real own radius made the UseSpheres - // arrival hybrid (centerDist − playerRadius ≤ useRadius) — the - // auto-walk stopped ~one player-radius farther out than the AP-23 - // constants intended, and the two MoveToObject sites disagreed. - (ms.Radius, ms.Height) = (approach.TargetRadius, approach.TargetHeight); - // Part of this install's AP-23 adaptation: store the autonomy flag - // exactly as the wire mt-6 this install anticipates would (the P1 - // unpack store, IsAutonomous=false) — without it the per-tick - // pump's A3 dispatch stays on the raw branch (the user's last input - // set it autonomous) and clobbers this moveto's dispatched motions - // with the idle raw state. - _playerController.SetLastMoveWasAutonomous(false); - // R5-V5: through the facade (MovementManager::PerformMovement - // 0x005240d0) — retail's client-initiated use flow reaches the same - // manager the wire path uses. - pc.Movement.PerformMovement(ms); - } + private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement) + => _selectionInteractions?.SendPickup(itemGuid, destinationContainerId, placement); + + private void OnAutoWalkArrivedSendDeferredAction() + => _selectionInteractions?.OnNaturalMoveToComplete(); private uint? SelectClosestCombatTarget(bool showToast) - { - AcDream.App.Interaction.ClosestCombatTarget? closest = - _worldSelectionQuery?.FindClosestHostileMonster(); - uint? bestGuid = closest?.ServerGuid; + => _selectionInteractions?.SelectClosestCombatTarget(showToast); - if (bestGuid is { } selection) - _selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard); - else - _selection.Clear(AcDream.Core.Selection.SelectionChangeSource.Keyboard); - if (bestGuid is { } selected) - { - string label = DescribeLiveEntity(selected); - float distance = MathF.Sqrt(closest!.Value.DistanceSquared); - Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}"); - if (showToast) - _debugVm?.AddToast($"Target {label}"); - } - else if (showToast) - { - _debugVm?.AddToast("No monster target"); - Console.WriteLine("combat: no creature target found"); - } - - return bestGuid; - } - - private bool IsLiveCreatureTarget(uint guid) - => _worldSelectionQuery?.IsCreature(guid) == true; - - private bool IsLiveHostileMonsterTarget(uint guid) - => _worldSelectionQuery?.IsHostileMonster(guid) == true; - - /// - /// True if the selected-object strip should show a Health meter for . - /// Exact projection of retail's IsPlayer() || pet_owner || - /// ClientCombatSystem::ObjectIsAttackable() gate from - /// gmToolbarUI::HandleSelectionChanged @ 0x004BF380. - /// private bool IsHealthBarTarget(uint guid) => _worldSelectionQuery?.ShouldShowHealth(guid) == true; @@ -14288,91 +13845,6 @@ public sealed class GameWindow : IDisposable /// why our previous mesh-AABB indicator was smaller than retail's. /// /// - private bool TryGetEntitySelectionSphere(uint guid, - out System.Numerics.Vector3 worldCenter, - out float worldRadius) - { - if (_worldSelectionQuery is { } query) - return query.TryGetSelectionSphere(guid, out worldCenter, out worldRadius); - worldCenter = default; - worldRadius = 0f; - return false; - } - - /// - /// 2026-05-15. Retail-faithful gate for R-key Use / F-key PickUp. - /// Returns true when the entity is interactable from the world per - /// the server-supplied ITEM_USEABLE _useability field - /// (acclient.h:6478) — specifically when the USEABLE_REMOTE - /// (0x20) bit is set OR a composite form containing it - /// (USEABLE_REMOTE_NEVER_WALK = 0x60, the - /// SOURCE_X_TARGET_REMOTE variants in the 0x200000+ range). - /// - /// - /// Retail behaviour for non-useable entities (signs, banners, - /// decorative scenery): the R-key does nothing — no walk, no Use - /// packet, no toast. The retail client checks useability before - /// any action and silently ignores the press. We honor that with - /// a silent early return at the call site. - /// - /// - /// - /// Fallback when useability is unknown. The wire's - /// weenieFlags & 0x10 bit gates whether ACE serializes - /// useability at all. If absent, - /// is null. Conservatively we permit Use for entities we've - /// historically been able to interact with — creatures, doors, - /// lifestones, portals, corpses — to avoid regressing the existing - /// M1 flows. Pure-scenery untyped entities (the sign case) fall - /// through to "blocked". - /// - /// - private bool IsUseableTarget(uint guid) - => _worldSelectionQuery?.IsUseable(guid) == true; - - /// - /// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based. - /// - /// - /// The earlier (useability & USEABLE_REMOTE) != 0u check - /// was a misread of the audit. USEABLE_REMOTE (0x20) gates the USE - /// action ("can the player activate this item from the world"); - /// PICKUP is a separate action governed by retail's - /// PutItemInContainer handler, which accepts any small-item-class - /// entity from the world regardless of useability bits. A spell - /// component with useability=USEABLE_NO=1 is still pickupable in - /// retail — USEABLE_NO blocks using the component (you can't - /// "activate" it standalone), not picking it up. - /// - /// - /// - /// Now matches retail: a non-Stuck small-item ItemType class - /// → pickupable. Server validates the request server-side - /// (in-range, target-still-exists, container-has-room). - /// - /// - private bool IsPickupableTarget(uint guid) - => _worldSelectionQuery?.IsPickupable(guid) == true; - - private AcDream.Core.Items.ItemType LiveItemType(uint guid) => - _worldSelectionQuery?.GetItemType(guid) ?? AcDream.Core.Items.ItemType.None; - - private string? LiveName(uint guid) => Objects.Get(guid)?.Name; - - private string DescribeLiveEntity(uint guid) - { - return _worldSelectionQuery?.Describe(guid) ?? $"0x{guid:X8}"; - } - - /// - /// K.1b: Tab handler extracted into a method so the dispatcher - /// subscriber can call it. Same body as the previous Tab branch in - /// the legacy direct keyboard handler — toggle player↔fly mode, set - /// up + - /// when entering player mode, tear them down on exit. - /// K.2: also disarms the auto-entry trigger when the user toggles - /// manually (their choice wins). - /// private void TogglePlayerMode() { // Phase B.2 guard: only active when a live session is in-world. From 5acc3f01cfd06cb0d5a8c546128d06ceb5d52cff Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 09:01:02 +0200 Subject: [PATCH 17/98] fix(interaction): close selection lifecycle review gaps Bind queued actions and pending inventory requests to exact live incarnations, separate optimistic placement from authoritative responses, and serialize retail-style inventory ownership across UI surfaces. Co-authored-by: OpenAI Codex --- .../retail-divergence-register.md | 8 +- .../Input/OutboundInteractionQueue.cs | 10 +- .../PlayerInteractionMovementSink.cs | 19 +- .../SelectionInteractionController.cs | 238 +++++++- .../Interaction/WorldSelectionQuery.cs | 15 +- src/AcDream.App/Rendering/GameWindow.cs | 54 +- .../UI/ItemInteractionController.cs | 552 +++++++++++++++++- .../UI/Layout/ExternalContainerController.cs | 20 +- .../UI/Layout/InventoryController.cs | 171 +++++- .../UI/Layout/ToolbarController.cs | 23 + src/AcDream.Core/Items/ClientObjectTable.cs | 63 +- .../Physics/Motion/MoveToManager.cs | 16 +- .../Input/OutboundInteractionQueueTests.cs | 39 ++ .../PlayerInteractionMovementSinkTests.cs | 106 ++++ .../SelectionInteractionControllerTests.cs | 237 +++++++- .../Interaction/WorldSelectionQueryTests.cs | 75 +++ .../UI/ItemInteractionControllerTests.cs | 439 +++++++++++++- .../ExternalContainerControllerTests.cs | 77 +++ .../UI/Layout/InventoryControllerTests.cs | 461 +++++++++++++++ .../UI/Layout/PaperdollControllerTests.cs | 36 +- .../UI/Layout/ToolbarControllerTests.cs | 91 +++ .../RetailItemConfirmationControllerTests.cs | 32 + .../MoveToManagerCompletionSeamTests.cs | 21 + 23 files changed, 2635 insertions(+), 168 deletions(-) create mode 100644 tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a1c6c21b..b51c6997 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -56,7 +56,7 @@ accepted-divergence entries (#96, #49, #50). | IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/ExternalContainerController.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` | | IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | | IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | -| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | +| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`) | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | | IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` | --- @@ -73,7 +73,7 @@ accepted-divergence entries (#96, #49, #50). | 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 | -| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 | +| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow null for the established creature/large-object fallback while preserving explicit zero/one rejection | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsUseable`) | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally leaves without useability can become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923; `ItemUses::IsUseable @ 0x004FCCC0` | | 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 | | AD-13 | 1-second dedup window for identical system chat messages (retail has none) | `src/AcDream.Core/Chat/ChatLog.cs:29` | ACE dual-sends the same system text (0xF7E0 + 0x02EB) for back-compat; without dedup every line doubled (Phase J compromise) | Two genuinely distinct but textually identical system messages within 1 s collapse to one line where retail shows both | ACE dual-send 0xF7E0 + 0x02EB | | AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 | @@ -87,7 +87,7 @@ accepted-divergence entries (#96, #49, #50). | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | | AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | -| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) | +| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it). The companion `MoveToCancelled` seam only withdraws the waiting client presentation/action. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`OnNaturalMoveToComplete`/`OnMoveToCancelled`); `src/AcDream.App/Rendering/GameWindow.cs` (player MoveTo seam binding); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete`/`MoveToCancelled`) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract; cancellation/error only clears retained intent. | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) | | AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | | AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` | | AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path | @@ -132,7 +132,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 | | AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive → D3DBLEND_ONE | | AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 | -| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Rendering/GameWindow.cs` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b | +| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b | | ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` | | AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) | | AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 | diff --git a/src/AcDream.App/Input/OutboundInteractionQueue.cs b/src/AcDream.App/Input/OutboundInteractionQueue.cs index a23e12ca..0c50f248 100644 --- a/src/AcDream.App/Input/OutboundInteractionQueue.cs +++ b/src/AcDream.App/Input/OutboundInteractionQueue.cs @@ -16,6 +16,7 @@ namespace AcDream.App.Input; public sealed class OutboundInteractionQueue { private readonly Queue _pending = new(); + private uint _clearEpoch; public int Count => _pending.Count; @@ -33,9 +34,14 @@ public sealed class OutboundInteractionQueue public void Drain() { int count = _pending.Count; - while (count-- > 0) + uint epoch = _clearEpoch; + while (count-- > 0 && epoch == _clearEpoch && _pending.Count > 0) _pending.Dequeue().Invoke(); } - public void Clear() => _pending.Clear(); + public void Clear() + { + _pending.Clear(); + _clearEpoch++; + } } diff --git a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs index 2890aedb..7c8cfb50 100644 --- a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs +++ b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs @@ -6,7 +6,13 @@ namespace AcDream.App.Interaction; internal interface IPlayerInteractionMovementSink { - bool BeginApproach(InteractionApproach approach); + /// + /// Cancels any preceding MoveTo, invokes , + /// then installs the new movement. The callback boundary lets the intent + /// owner arm completion state before an already-facing TurnTo can finish + /// synchronously, without letting the preceding cancellation clear it. + /// + bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null); } /// @@ -20,7 +26,7 @@ internal sealed class PlayerInteractionMovementSink( private readonly Func _player = player ?? throw new ArgumentNullException(nameof(player)); - public bool BeginApproach(InteractionApproach approach) + public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null) { PlayerMovementController? controller = _player(); if (controller?.MoveTo is null) @@ -47,11 +53,16 @@ internal sealed class PlayerInteractionMovementSink( Height = approach.TargetHeight, }; + // PerformMovement cancels at its head. Do it explicitly before the + // intent is armed so cancellation of the preceding move cannot clear + // the new request; the internal second call is then a retail no-op. + controller.Movement.CancelMoveTo(WeenieError.ActionCancelled); + armAfterCancel?.Invoke(); + // P1's wire MoveToObject store marks the command non-autonomous. // The speculative local install must do the same or the next raw-input // pump overwrites it before ACE's authoritative movement arrives. controller.SetLastMoveWasAutonomous(false); - controller.Movement.PerformMovement(movement); - return true; + return controller.Movement.PerformMovement(movement) == WeenieError.None; } } diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 67e37dde..430d1258 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -1,6 +1,7 @@ using AcDream.App.Input; using AcDream.App.UI; using AcDream.App.World; +using AcDream.Core.Items; using AcDream.Core.Physics; using AcDream.Core.Selection; using AcDream.Core.Ui; @@ -24,12 +25,18 @@ internal sealed class SelectionInteractionController private readonly Action? _toast; private PendingPostArrivalAction? _pendingPostArrival; + private readonly record struct QueuedInteractionIdentity( + uint ServerGuid, + uint? LocalEntityId, + ClientObject? ClientObject); + private readonly record struct PendingPostArrivalAction( uint ServerGuid, uint LocalEntityId, bool IsPickup, uint DestinationContainerId, - int Placement); + int Placement, + ulong PendingPlacementToken); public SelectionInteractionController( SelectionState selection, @@ -69,8 +76,14 @@ internal sealed class SelectionInteractionController case InputAction.SelectionPickUp: if (_selection.SelectedObjectId is uint pickupTarget) { - _outbound.Enqueue(() => - _items.PlaceWorldItemInBackpack(pickupTarget)); + EnqueueIdentityBound( + pickupTarget, + requireLiveEntity: true, + guid => + { + if (ValidatePickupTarget(guid, showToast: true)) + _items.PlaceWorldItemInBackpack(guid); + }); } else { @@ -85,12 +98,18 @@ internal sealed class SelectionInteractionController } } - public uint? PickAt(float mouseX, float mouseY, bool includeSelf) - => _query.PickAt(mouseX, mouseY, includeSelf); - public uint? PickAtCursor(bool includeSelf) => _query.PickAtCursor(includeSelf); + public void PlaceDraggedItem(ItemDragPayload payload, float mouseX, float mouseY) + { + ArgumentNullException.ThrowIfNull(payload); + uint target = _query.PickAt(mouseX, mouseY, includeSelf: true) ?? 0u; + if (target != 0u) + _query.BeginLightingPulse(target); + _items.PlaceIn3D(payload, target); + } + public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) { if (_selection.SelectedObjectId is { } selected @@ -147,7 +166,13 @@ internal sealed class SelectionInteractionController Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); _toast?.Invoke($"Selected: {label}"); if (useImmediately) - _outbound.Enqueue(() => _items.ActivateItem(guid)); + EnqueueIdentityBound( + guid, + requireLiveEntity: true, + selected => + { + _items.ActivateItem(selected); + }); } public void UseCurrentSelection() @@ -157,11 +182,18 @@ internal sealed class SelectionInteractionController _toast?.Invoke("Nothing selected"); return; } - _outbound.Enqueue(() => _items.UseSelectedOrEnterMode(selected)); + EnqueueIdentityBound( + selected, + requireLiveEntity: false, + guid => + { + _items.UseSelectedOrEnterMode(guid); + }); } public void SendUse(uint serverGuid) { + CancelPendingApproach(); if (!_transport.IsInWorld) { _toast?.Invoke("Not in world"); @@ -173,63 +205,104 @@ internal sealed class SelectionInteractionController return; } - _movement.BeginApproach(approach); if (approach.IsCloseRange) { - _pendingPostArrival = new PendingPostArrivalAction( + var pending = new PendingPostArrivalAction( serverGuid, approach.Target.LocalEntityId, IsPickup: false, DestinationContainerId: 0u, - Placement: 0); + Placement: 0, + PendingPlacementToken: 0u); + bool started = _movement.BeginApproach( + approach, + () => _pendingPostArrival = pending); + if (!started && _pendingPostArrival == pending) + CancelPendingApproach(); return; } + _movement.BeginApproach(approach); if (_transport.TrySendUse(serverGuid, out uint sequence)) Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}"); } public void SendPickup(uint itemGuid, uint destinationContainerId, int placement) { + CancelPendingApproach(); if (!_transport.IsInWorld) { _toast?.Invoke("Not in world"); + CancelPickupPresentation(itemGuid); return; } - if (_query.IsCreature(itemGuid)) + if (!ValidatePickupTarget(itemGuid, showToast: true) + || !_query.TryGetApproach(itemGuid, out InteractionApproach approach)) + { + CancelPickupPresentation(itemGuid); + return; + } + + ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement( + itemGuid, + out PendingBackpackPlacement pendingPlacement) + ? pendingPlacement.Token + : 0u; + if (!IsCurrentPickupPresentation( + itemGuid, + destinationContainerId, + placement, + pendingPlacementToken)) { - _toast?.Invoke(RetailMessages.CannotPickUpCreatures); return; } - if (!_query.IsPickupable(itemGuid)) - { - _toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(itemGuid))); - return; - } - if (!_query.TryGetApproach(itemGuid, out InteractionApproach approach)) - return; - _movement.BeginApproach(approach); if (approach.IsCloseRange) { - _pendingPostArrival = new PendingPostArrivalAction( + var pending = new PendingPostArrivalAction( itemGuid, approach.Target.LocalEntityId, IsPickup: true, destinationContainerId, - placement); + placement, + pendingPlacementToken); + bool started = _movement.BeginApproach( + approach, + () => _pendingPostArrival = pending); + if (!started && _pendingPostArrival == pending) + CancelPendingApproach(); + else if (!started) + CancelPickupPresentation(itemGuid, pendingPlacementToken); return; } - if (_transport.TrySendPickup( + _movement.BeginApproach(approach); + if (!IsCurrentPickupPresentation( itemGuid, destinationContainerId, placement, - out uint sequence)) + pendingPlacementToken)) + { + return; + } + uint sequence = 0u; + if (_items.TryDispatchInventoryRequest( + InventoryRequestKind.Pickup, + itemGuid, + () => _transport.TrySendPickup( + itemGuid, + destinationContainerId, + placement, + out sequence), + pendingPlacementToken)) { Console.WriteLine( $"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}"); } + else + { + CancelPickupPresentation(itemGuid, pendingPlacementToken); + } } /// Fires only after natural MoveToComplete(None), never cancellation. @@ -239,15 +312,38 @@ internal sealed class SelectionInteractionController return; _pendingPostArrival = null; if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId)) + { + if (pending.IsPickup) + CancelPickupPresentation( + pending.ServerGuid, + pending.PendingPlacementToken); return; + } if (pending.IsPickup) { - _transport.TrySendPickup( - pending.ServerGuid, - pending.DestinationContainerId, - pending.Placement, - out _); + if (!IsCurrentPickupPresentation( + pending.ServerGuid, + pending.DestinationContainerId, + pending.Placement, + pending.PendingPlacementToken)) + { + return; + } + if (!_items.TryDispatchInventoryRequest( + InventoryRequestKind.Pickup, + pending.ServerGuid, + () => _transport.TrySendPickup( + pending.ServerGuid, + pending.DestinationContainerId, + pending.Placement, + out _), + pending.PendingPlacementToken)) + { + CancelPickupPresentation( + pending.ServerGuid, + pending.PendingPlacementToken); + } } else { @@ -257,10 +353,12 @@ internal sealed class SelectionInteractionController public void DrainOutbound() => _outbound.Drain(); + public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach(); + public void OnEntityHidden(uint serverGuid) { if (_pendingPostArrival?.ServerGuid == serverGuid) - _pendingPostArrival = null; + CancelPendingApproach(); if (_selection.SelectedObjectId == serverGuid) { _selection.Clear( @@ -276,7 +374,7 @@ internal sealed class SelectionInteractionController && pending.ServerGuid == record.ServerGuid && pending.LocalEntityId == record.LocalEntityId) { - _pendingPostArrival = null; + CancelPendingApproach(); } if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid) { @@ -288,9 +386,85 @@ internal sealed class SelectionInteractionController public void ResetSession() { + CancelPendingApproach(); _items.ResetSession(); _selection.Reset(); _outbound.Clear(); _pendingPostArrival = null; } + + private bool ValidatePickupTarget(uint serverGuid, bool showToast) + { + if (_query.IsCreature(serverGuid)) + { + if (showToast) + _toast?.Invoke(RetailMessages.CannotPickUpCreatures); + return false; + } + if (_query.IsPickupable(serverGuid)) + return true; + if (showToast) + _toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(serverGuid))); + return false; + } + + private bool EnqueueIdentityBound( + uint serverGuid, + bool requireLiveEntity, + Action action) + { + ArgumentNullException.ThrowIfNull(action); + uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId) + ? localId + : null; + ClientObject? item = _items.TryCaptureObjectIdentity(serverGuid, out ClientObject captured) + ? captured + : null; + if ((requireLiveEntity && localEntityId is null) + || (localEntityId is null && item is null)) + { + return false; + } + + var identity = new QueuedInteractionIdentity(serverGuid, localEntityId, item); + _outbound.Enqueue(() => + { + if (IsCurrent(identity)) + action(identity.ServerGuid); + }); + return true; + } + + private bool IsCurrent(QueuedInteractionIdentity identity) + => (identity.LocalEntityId is not uint localId + || _query.IsCurrent(identity.ServerGuid, localId)) + && (identity.ClientObject is not { } item + || _items.IsCurrentObjectIdentity(identity.ServerGuid, item)); + + private void CancelPendingApproach() + { + if (_pendingPostArrival is not { } pending) + return; + _pendingPostArrival = null; + if (pending.IsPickup) + CancelPickupPresentation( + pending.ServerGuid, + pending.PendingPlacementToken); + } + + private void CancelPickupPresentation(uint itemGuid, ulong token = 0u) + => _items.CancelPendingBackpackPlacement(itemGuid, token); + + private bool IsCurrentPickupPresentation( + uint itemGuid, + uint destinationContainerId, + int placement, + ulong token) + => token != 0u + && _items.TryGetPendingBackpackPlacement( + itemGuid, + out PendingBackpackPlacement pending) + && pending.Token == token + && pending.ContainerId == destinationContainerId + && pending.Placement == placement; } diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index f5548a81..05df1a4d 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -39,6 +39,7 @@ internal interface IWorldSelectionQuery uint? PickAtCursor(bool includeSelf); uint? PickAt(float mouseX, float mouseY, bool includeSelf); void BeginLightingPulse(uint serverGuid); + bool TryCaptureIdentity(uint serverGuid, out uint localEntityId); bool IsCurrent(uint serverGuid, uint localEntityId); string Describe(uint serverGuid); bool IsCreature(uint serverGuid); @@ -142,6 +143,18 @@ internal sealed class WorldSelectionQuery : IWorldSelectionQuery _selectionScene.BeginLightingPulse(target.ServerGuid, target.LocalEntityId); } + public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId) + { + if (TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)) + { + localEntityId = target.LocalEntityId; + return true; + } + + localEntityId = 0u; + return false; + } + public bool TryGetInteractionTarget( uint serverGuid, out WorldInteractionTarget target) @@ -274,7 +287,7 @@ internal sealed class WorldSelectionQuery : IWorldSelectionQuery return true; } - /// ItemUses::IsUseable @ retail 0x00566A20 call family. + /// ItemUses::IsUseable @ retail 0x004FCCC0 call family. public bool IsUseable(uint serverGuid) { if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn)) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d562c822..cef7eb2e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -13081,12 +13081,7 @@ public sealed class GameWindow : IDisposable private void OnUiDragReleasedOutside(object payload, int x, int y) { if (payload is AcDream.App.UI.ItemDragPayload itemPayload) - { - uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u; - if (target != 0u) - BeginSelectionLightingPulse(target); - _itemInteractionController?.PlaceIn3D(itemPayload, target); - } + _selectionInteractions?.PlaceDraggedItem(itemPayload, x, y); } // L.0 follow-up: persisted-settings cache populated by @@ -13746,15 +13741,8 @@ public sealed class GameWindow : IDisposable return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected); } - // ============================================================ - // Phase B.4b — outbound Use handler. Wires three input actions - // (LMB click select, LMB-double-click select+use, R hotkey - // use-selected) through WorldPicker into InteractRequests.BuildUse. - // The inbound reply (SetState 0xF74B) lands via L.2g slice 1. - // ============================================================ - /// - /// Shared world pick at the current cursor. The renderer supplies the + /// Item-target-mode world pick at the current cursor. The renderer supplies the /// exact visible CPhysicsPart equivalents and RetailWorldPicker performs /// retail's drawing-sphere broadphase followed by flat visual-polygon /// intersection. allows item target-use to @@ -13764,13 +13752,6 @@ public sealed class GameWindow : IDisposable => _selectionInteractions?.PickAtCursor(includeSelf) ?? _worldSelectionQuery?.PickAtCursor(includeSelf); - private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf) - => _selectionInteractions?.PickAt(mouseX, mouseY, includeSelf) - ?? _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf); - - private void BeginSelectionLightingPulse(uint serverGuid) - => _worldSelectionQuery?.BeginLightingPulse(serverGuid); - // Contained/toolbar activation is not a world-selection interaction: it // sends directly without speculative TurnToObject/MoveToObject. private void UseItemByGuid(uint guid) @@ -13818,33 +13799,6 @@ public sealed class GameWindow : IDisposable private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid) => _worldSelectionQuery?.ResolveVividTargetInfo(guid); - - /// - /// 2026-05-16 — retail-faithful port of - /// SmartBox::GetObjectBoundingBox (decomp 0x00452e20) - /// using CPhysicsObj::GetSelectionSphere (0x0050ea40) - /// → CPartArray::GetSelectionSphere (0x00518b80). - /// - /// - /// Retail's VividTargetIndicator does NOT use a per-mesh AABB — - /// it uses the Setup's selection_sphere field (a single - /// sphere encompassing the entire entity, baked at Setup-creation - /// time). The sphere is scaled by the part-array scale - /// (component-wise on center, Z-scale on radius — retail's exact - /// formula at 0x00518ba6–0x00518be3) and rotated by entity - /// orientation. The screen indicator rect is the projection of - /// the camera-aligned BBox of this sphere — i.e. a screen circle - /// of radius worldRadius * focalLength / depth. - /// - /// - /// - /// Result: the indicator rect MATCHES the Setup's intended - /// "selectable extent" — which is typically larger than the mesh - /// AABB by design (Setups bake a slightly oversized selection - /// sphere so far targets still get pickable indicators). That's - /// why our previous mesh-AABB indicator was smaller than retail's. - /// - /// private void TogglePlayerMode() { // Phase B.2 guard: only active when a live session is in-world. @@ -14084,7 +14038,11 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"[autowalk-end] reason=complete err={err}"); if (err == AcDream.Core.Physics.WeenieError.None) OnAutoWalkArrivedSendDeferredAction(); + else + _selectionInteractions?.OnMoveToCancelled(err); }; + playerMoveTo.MoveToCancelled = err => + _selectionInteractions?.OnMoveToCancelled(err); // R5-V3 (#171, retires TS-39 — player side): bind the sticky // seams to the player host's PositionManager (same trio as the diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 45b15389..9f0c80fc 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -12,6 +12,31 @@ public enum ItemPrimaryClickResult ConsumedRejected, } +public readonly record struct PendingBackpackPlacement( + ulong Token, + uint ItemId, + uint ContainerId, + int Placement, + ClientObject? ItemIdentity); + +public enum InventoryRequestKind +{ + Pickup, + PutInContainer, + SplitToContainer, + Merge, + DropToWorld, + SplitToWorld, + Give, +} + +public readonly record struct PendingInventoryRequest( + ulong Token, + InventoryRequestKind Kind, + uint ItemId, + ClientObject? ItemIdentity, + bool Dispatched); + /// /// Shared retail item interaction orchestrator. UI widgets route item clicks, /// target acquisition, and drag-out drops here instead of duplicating @@ -20,6 +45,8 @@ public enum ItemPrimaryClickResult public sealed class ItemInteractionController : IDisposable { private const long RetailUseThrottleMs = 200; + internal const string InventoryRequestBusyMessage = + "You can only move or use one item at a time"; private const long RetailDoubleClickMs = 500; private readonly ClientObjectTable _objects; @@ -56,6 +83,9 @@ public sealed class ItemInteractionController : IDisposable private uint _consumedPrimaryClickTarget; private long _consumedPrimaryClickMs = long.MinValue / 2; private int _busyCount; + private ulong _nextInventoryRequestToken; + private PendingBackpackPlacement? _pendingBackpackPlacement; + private PendingInventoryRequest? _pendingInventoryRequest; private bool _disposed; public ItemInteractionController( @@ -119,6 +149,11 @@ public sealed class ItemInteractionController : IDisposable _systemMessage = systemMessage; _interactionState = interactionState ?? new InteractionState(); _interactionState.Changed += OnInteractionModeChanged; + _objects.ObjectMoved += OnInventoryObjectMoved; + _objects.MoveRequestFailed += OnInventoryMoveFailed; + _objects.ObjectRemoved += OnInventoryObjectRemoved; + _objects.StackSizeUpdated += OnInventoryStackSizeUpdated; + _objects.Cleared += OnInventoryObjectsCleared; _autoWield = new AutoWieldController( _objects, _playerGuid, @@ -137,7 +172,20 @@ public sealed class ItemInteractionController : IDisposable /// panel inserts a waiting projection before the pickup request is sent. /// The destination and placement are the same values sent on the wire. /// - public event Action? PendingBackpackPlacementRequested; + public event Action? PendingBackpackPlacementRequested; + + /// + /// Withdraws a waiting pickup projection when the approach is rejected or + /// cancelled before a request reaches the server. + /// + public event Action? PendingBackpackPlacementCancelled; + + /// + /// Resolves the exact waiting projection after an authoritative response. + /// The token prevents a late response for a recycled GUID from erasing a + /// newer incarnation's projection in the inventory panel. + /// + public event Action? PendingBackpackPlacementResolved; public uint PlayerGuid => _playerGuid(); @@ -146,8 +194,151 @@ public sealed class ItemInteractionController : IDisposable public int BusyCount => _busyCount; public bool CanMakeInventoryRequest => + BaseCanMakeInventoryRequest && _pendingInventoryRequest is null; + + private bool BaseCanMakeInventoryRequest => _readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy; + /// + /// Retail ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest. + /// The destination ItemList's separate m_pendingItem rejection is + /// reported by . + /// + public bool EnsureInventoryRequestReady() + { + if (CanMakeInventoryRequest) + return true; + + if (_pendingInventoryRequest is not null + || _busyCount != 0 + || _autoWield.IsBusy) + _systemMessage?.Invoke(InventoryRequestBusyMessage); + return false; + } + + /// + /// Retail UIElement_ItemList::AcceptDragObject's local + /// m_pendingItem branch. This wording belongs only to the destination + /// list which already displays the waiting projection. + /// + public void ReportPendingBackpackPlacementConflict() + { + if (_pendingBackpackPlacement is not { } pending) + return; + string? itemName = _objects.Get(pending.ItemId)?.Name; + string name = string.IsNullOrWhiteSpace(itemName) ? "that item" : itemName; + _systemMessage?.Invoke($"Already attempting to place {name} here"); + } + + /// + /// Dispatches one retail inventory request. A provisional global + /// ACCWeenieObject::prevRequest owner closes callback reentrancy, + /// then becomes dispatched immediately after a successful wire send. + /// The callback returns false when it could not dispatch anything. + /// Retail: RecordRequest @ 0x0058C220, UIAttempt family + /// 0x0058D590..0x0058D850. + /// + public bool TryDispatchInventoryRequest( + InventoryRequestKind kind, + uint itemId, + Func dispatch, + ulong reservationToken = 0u) + { + ArgumentNullException.ThrowIfNull(dispatch); + if (itemId == 0u) + return false; + + if (reservationToken != 0u) + { + if (_pendingInventoryRequest is not { } reserved + || reserved.Token != reservationToken + || reserved.ItemId != itemId + || reserved.Kind != kind + || reserved.Dispatched) + { + return false; + } + bool reservedDispatch; + try + { + reservedDispatch = dispatch(); + } + catch + { + CancelPendingBackpackPlacement(itemId, reservationToken); + throw; + } + if (!reservedDispatch) + { + CancelPendingBackpackPlacement(itemId, reservationToken); + return false; + } + // A synchronous test transport or reentrant table listener may + // already have completed this exact request. Never resurrect it or + // overwrite a newer transaction acquired from that response. + if (_pendingInventoryRequest is { } reservationCurrent + && reservationCurrent.Token == reserved.Token) + { + _pendingInventoryRequest = reserved with { Dispatched = true }; + StateChanged?.Invoke(); + } + return true; + } + + if (!EnsureInventoryRequestReady()) + return false; + + ulong token = ++_nextInventoryRequestToken; + if (token == 0u) + token = ++_nextInventoryRequestToken; + var provisional = new PendingInventoryRequest( + token, + kind, + itemId, + _objects.Get(itemId), + Dispatched: false); + // Provisional ownership closes the reentrancy window around optimistic + // table mutation and arbitrary send adapters. Retail writes prevRequest + // after its synchronous event call; our callback seam can publish local + // notices reentrantly, so the owner is reserved before entering it. + _pendingInventoryRequest = provisional; + StateChanged?.Invoke(); + + bool dispatched; + try + { + dispatched = dispatch(); + } + catch + { + ClearPendingInventoryRequest(token); + throw; + } + if (!dispatched) + { + ClearPendingInventoryRequest(token); + return false; + } + if (_pendingInventoryRequest is { } current + && current.Token == token) + { + _pendingInventoryRequest = provisional with { Dispatched = true }; + StateChanged?.Invoke(); + } + return true; + } + + public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending) + { + if (_pendingInventoryRequest is { } current) + { + pending = current; + return true; + } + pending = default; + return false; + } + /// /// Increments retail's shared ClientUISystem busy reference after a /// request issued by another retained controller has been sent. The @@ -287,6 +478,8 @@ public sealed class ItemInteractionController : IDisposable if (!ConsumeUseThrottle()) return true; + if (!EnsureInventoryRequestReady()) + return false; var input = new ItemUsePolicyInput( Snapshot(item), @@ -317,11 +510,195 @@ public sealed class ItemInteractionController : IDisposable if (containerId == 0u) containerId = _playerGuid(); const int placement = 0; - PendingBackpackPlacementRequested?.Invoke(itemGuid, containerId, placement); + if (!TryBeginPendingBackpackPlacement( + itemGuid, + containerId, + placement, + out _)) + { + // Retail permits only one inventory request at a time. Consume the + // input without replacing a pending projection or issuing a wire + // request while another inventory transaction owns the gate. + return true; + } + // This callback may install a retail MoveToObject approach instead of + // sending immediately. SelectionInteractionController promotes the + // reservation to Dispatched only when it actually emits the wire move. _placeInBackpack(itemGuid, containerId, placement); return true; } + public bool TryBeginPendingBackpackPlacement( + uint itemGuid, + uint containerId, + int placement, + out PendingBackpackPlacement pending) + => TryBeginPendingBackpackPlacement( + itemGuid, + containerId, + placement, + InventoryRequestKind.Pickup, + out pending); + + private bool TryBeginPendingBackpackPlacement( + uint itemGuid, + uint containerId, + int placement, + InventoryRequestKind kind, + out PendingBackpackPlacement pending) + { + if (itemGuid == 0u || containerId == 0u) + { + pending = default; + return false; + } + + if (_pendingBackpackPlacement is { } existing) + { + ReportPendingBackpackPlacementConflict(); + pending = existing; + return false; + } + if (!EnsureInventoryRequestReady()) + { + pending = default; + return false; + } + + ulong token = ++_nextInventoryRequestToken; + if (token == 0u) + token = ++_nextInventoryRequestToken; + ClientObject? identity = _objects.Get(itemGuid); + pending = new PendingBackpackPlacement( + token, + itemGuid, + containerId, + placement, + identity); + _pendingBackpackPlacement = pending; + _pendingInventoryRequest = new PendingInventoryRequest( + token, + kind, + itemGuid, + identity, + Dispatched: false); + PendingBackpackPlacementRequested?.Invoke(pending); + if (_pendingBackpackPlacement != pending + || _pendingInventoryRequest is not { } published + || published.Token != token + || published.ItemId != itemGuid + || published.Kind != kind + || published.Dispatched) + { + return false; + } + StateChanged?.Invoke(); + return true; + } + + /// + /// Publishes retail's destination waiting projection, dispatches the move, + /// then records the global request. Failed dispatch withdraws only the + /// projection created by this call. + /// + public bool TryDispatchPendingBackpackPlacement( + uint itemGuid, + uint containerId, + int placement, + InventoryRequestKind kind, + Func dispatch) + { + // Callers such as gmToolbarUI check the global prevRequest before they + // ask CPlayerSystem to publish ShowPendingInPlayer. This preserves the + // generic busy wording when both states happen to be present. + if (!EnsureInventoryRequestReady()) + return false; + if (!TryBeginPendingBackpackPlacement( + itemGuid, + containerId, + placement, + kind, + out PendingBackpackPlacement pending)) + { + return false; + } + + if (_pendingBackpackPlacement != pending + || _pendingInventoryRequest is not { } reserved + || reserved.Token != pending.Token + || reserved.ItemId != pending.ItemId + || reserved.Kind != kind + || reserved.Dispatched) + { + return false; + } + + if (TryDispatchInventoryRequest( + kind, + itemGuid, + dispatch, + pending.Token)) + return true; + + CancelPendingBackpackPlacement(itemGuid, pending.Token); + return false; + } + + public bool TryGetPendingBackpackPlacement( + uint itemGuid, + out PendingBackpackPlacement pending) + { + if (_pendingBackpackPlacement is { } current + && current.ItemId == itemGuid) + { + pending = current; + return true; + } + + pending = default; + return false; + } + + public void CancelPendingBackpackPlacement(uint itemGuid = 0u, ulong token = 0u) + { + if (_pendingBackpackPlacement is not { } pending + || (itemGuid != 0u && pending.ItemId != itemGuid) + || (token != 0u && pending.Token != token)) + { + return; + } + + _pendingBackpackPlacement = null; + if (_pendingInventoryRequest is { } request + && request.Token == pending.Token + && !request.Dispatched) + { + _pendingInventoryRequest = null; + StateChanged?.Invoke(); + } + PendingBackpackPlacementCancelled?.Invoke(pending); + } + + private void ClearPendingInventoryRequest(ulong token) + { + if (_pendingInventoryRequest is not { } pending + || pending.Token != token) + { + return; + } + _pendingInventoryRequest = null; + StateChanged?.Invoke(); + } + + internal bool TryCaptureObjectIdentity(uint objectId, out ClientObject item) + { + item = _objects.Get(objectId)!; + return item is not null; + } + + internal bool IsCurrentObjectIdentity(uint objectId, ClientObject item) + => ReferenceEquals(_objects.Get(objectId), item); + /// /// Retail paperdoll drop entry point. The paperdoll resolves the exact /// target side/location; this shared interaction owner performs AutoWield's @@ -333,6 +710,8 @@ public sealed class ItemInteractionController : IDisposable return false; if (_objects.Get(itemGuid) is not { } item) return false; + if (!EnsureInventoryRequestReady()) + return false; return _autoWield.TryWield(item, targetMask); } @@ -360,7 +739,7 @@ public sealed class ItemInteractionController : IDisposable if (!compatible) return false; - if (!CanMakeInventoryRequest) + if (!EnsureInventoryRequestReady()) return false; _sendUseWithTarget?.Invoke(sourceGuid, targetGuid); _busyCount++; @@ -380,6 +759,8 @@ public sealed class ItemInteractionController : IDisposable { if (objectId == 0u || _sendUse is null) return false; + if (!EnsureInventoryRequestReady()) + return false; _sendUse(objectId); _busyCount++; return true; @@ -407,6 +788,8 @@ public sealed class ItemInteractionController : IDisposable return false; if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item) return false; + if (!EnsureInventoryRequestReady()) + return false; uint fullStack = (uint)Math.Max(1, item.StackSize); int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize( @@ -496,22 +879,54 @@ public sealed class ItemInteractionController : IDisposable switch (action.Kind) { case ItemPolicyActionKind.DropToWorld: - _objects.MoveItemOptimistic(action.ObjectId, newContainerId: 0u, newSlot: -1); - _sendDrop?.Invoke(action.ObjectId); + TryDispatchInventoryRequest( + InventoryRequestKind.DropToWorld, + action.ObjectId, + () => + { + if (_sendDrop is null + || !_objects.MoveItemOptimistic( + action.ObjectId, + newContainerId: 0u, + newSlot: -1)) + { + return false; + } + _sendDrop(action.ObjectId); + return true; + }); break; case ItemPolicyActionKind.SplitToWorld: // UIAttemptSplitTo3D leaves the original object in its container; // the server assigns the split-off ground stack a new guid. - _sendSplitToWorld?.Invoke(action.ObjectId, (uint)action.Amount); + TryDispatchInventoryRequest( + InventoryRequestKind.SplitToWorld, + action.ObjectId, + () => + { + if (_sendSplitToWorld is null) + return false; + _sendSplitToWorld(action.ObjectId, (uint)action.Amount); + return true; + }); break; case ItemPolicyActionKind.GiveToTarget: // UIAttemptGive @ 0x0058D620 sends the request and leaves // the source untouched until the authoritative server // InventoryRemoveObject / SetStackSize response arrives. - _sendGive?.Invoke( - action.TargetId, + TryDispatchInventoryRequest( + InventoryRequestKind.Give, action.ObjectId, - (uint)Math.Max(1, action.Amount)); + () => + { + if (_sendGive is null) + return false; + _sendGive( + action.TargetId, + action.ObjectId, + (uint)Math.Max(1, action.Amount)); + return true; + }); break; case ItemPolicyActionKind.PlaceInContainer: { @@ -519,10 +934,35 @@ public sealed class ItemInteractionController : IDisposable 1, _objects.Get(action.ObjectId)?.StackSize ?? action.Amount); uint amount = (uint)Math.Max(1, action.Amount); - if (amount < fullStack) - _sendSplitToContainer?.Invoke(action.ObjectId, action.TargetId, 0u, amount); - else - _sendPutItemInContainer?.Invoke(action.ObjectId, action.TargetId, 0); + InventoryRequestKind kind = amount < fullStack + ? InventoryRequestKind.SplitToContainer + : InventoryRequestKind.PutInContainer; + TryDispatchInventoryRequest( + kind, + action.ObjectId, + () => + { + if (amount < fullStack) + { + if (_sendSplitToContainer is null) + return false; + _sendSplitToContainer( + action.ObjectId, + action.TargetId, + 0u, + amount); + } + else + { + if (_sendPutItemInContainer is null) + return false; + _sendPutItemInContainer( + action.ObjectId, + action.TargetId, + 0); + } + return true; + }); break; } case ItemPolicyActionKind.Reject: @@ -571,11 +1011,93 @@ public sealed class ItemInteractionController : IDisposable private void OnInteractionModeChanged(InteractionModeTransition _) => StateChanged?.Invoke(); + private void OnInventoryObjectMoved(ClientObjectMove move) + { + if (move.Origin != ClientObjectMoveOrigin.AuthoritativeResponse) + return; + + CompleteInventoryResponse(move.ItemId, move.Item); + } + + private void OnInventoryMoveFailed(MoveRequestFailure failure) + { + ClientObject? identity = _objects.Get(failure.ItemId); + CompleteInventoryResponse(failure.ItemId, identity); + } + + private void OnInventoryObjectRemoved(ClientObject item) + { + CompleteInventoryResponse(item.ObjectId, item); + } + + private void OnInventoryStackSizeUpdated(ClientObject item) + { + CompleteInventoryResponse(item.ObjectId, item); + } + + private void CompleteInventoryResponse(uint itemId, ClientObject? identity) + { + PendingInventoryRequest? completedRequest = null; + PendingBackpackPlacement? completedPlacement = null; + + if (_pendingInventoryRequest is { } request + && request.ItemId == itemId + && MatchesIdentity(request.ItemIdentity, itemId, identity)) + { + completedRequest = request; + _pendingInventoryRequest = null; + } + + if (_pendingBackpackPlacement is { } placement + && placement.ItemId == itemId + && MatchesIdentity(placement.ItemIdentity, itemId, identity)) + { + completedPlacement = placement; + _pendingBackpackPlacement = null; + } + + // ACCWeenieObject::RecordResponse @ 0x0058CAB0 clears prevRequest + // before ServerSaysMoveItem publishes the item-list notice. Clear the + // coupled global owner and local waiting projection atomically before + // either callback so observers never see a half-completed transaction. + if (completedPlacement is { } resolved) + PendingBackpackPlacementResolved?.Invoke(resolved); + if (completedRequest is not null) + StateChanged?.Invoke(); + } + + private bool MatchesIdentity( + ClientObject? expected, + uint itemId, + ClientObject? actual) + { + if (expected is null) + return true; + if (actual is not null) + return ReferenceEquals(expected, actual); + return _objects.Get(itemId) is not { } current + || ReferenceEquals(expected, current); + } + + private void OnInventoryObjectsCleared() + { + bool changed = _pendingInventoryRequest is not null; + _pendingInventoryRequest = null; + _pendingBackpackPlacement = null; + if (changed) + StateChanged?.Invoke(); + } + public void Dispose() { if (_disposed) return; _disposed = true; _interactionState.Changed -= OnInteractionModeChanged; + _objects.ObjectMoved -= OnInventoryObjectMoved; + _objects.MoveRequestFailed -= OnInventoryMoveFailed; + _objects.ObjectRemoved -= OnInventoryObjectRemoved; + _objects.StackSizeUpdated -= OnInventoryStackSizeUpdated; + _objects.Cleared -= OnInventoryObjectsCleared; _autoWield.Dispose(); } @@ -602,9 +1124,11 @@ public sealed class ItemInteractionController : IDisposable /// public void ResetSession() { - bool busyChanged = _busyCount != 0; + CancelPendingBackpackPlacement(); + bool busyChanged = _busyCount != 0 || _pendingInventoryRequest is not null; bool modeChanged = IsAnyTargetModeActive; _busyCount = 0; + _pendingInventoryRequest = null; _lastUseMs = long.MinValue / 2; _consumedPrimaryClickTarget = 0u; _consumedPrimaryClickMs = long.MinValue / 2; diff --git a/src/AcDream.App/UI/Layout/ExternalContainerController.cs b/src/AcDream.App/UI/Layout/ExternalContainerController.cs index 7fc5ddf5..35371745 100644 --- a/src/AcDream.App/UI/Layout/ExternalContainerController.cs +++ b/src/AcDream.App/UI/Layout/ExternalContainerController.cs @@ -223,6 +223,8 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine { if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept) return; + if (!_itemInteraction.EnsureInventoryRequestReady()) + return; if (_objects.Get(payload.ObjId) is not { } item) return; @@ -238,10 +240,20 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine // ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 and // UIAttemptSplitToContainer @ 0x0058D7D0 are request-only. Do not // optimistically rewrite external ownership. - if (amount < fullStack) - _sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount); - else - _sendPutItemInContainer(item.ObjectId, _openContainer, placement); + InventoryRequestKind kind = amount < fullStack + ? InventoryRequestKind.SplitToContainer + : InventoryRequestKind.PutInContainer; + _itemInteraction.TryDispatchInventoryRequest( + kind, + item.ObjectId, + () => + { + if (amount < fullStack) + _sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount); + else + _sendPutItemInContainer(item.ObjectId, _openContainer, placement); + return true; + }); } private void OnExternalContainerChanged(ExternalContainerTransition transition) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 42b4397c..02e660a6 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -67,6 +67,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo private bool _disposed; private readonly record struct PendingListPlacement( + ulong Token, uint ItemId, uint ContainerId, int Placement); @@ -183,6 +184,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { _itemInteraction.StateChanged += OnInteractionStateChanged; _itemInteraction.PendingBackpackPlacementRequested += OnPendingBackpackPlacementRequested; + _itemInteraction.PendingBackpackPlacementCancelled += OnPendingBackpackPlacementCancelled; + _itemInteraction.PendingBackpackPlacementResolved += OnPendingBackpackPlacementResolved; } Populate(); @@ -242,7 +245,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo } private void OnObjectRemoved(ClientObject o) { - if (_pendingListPlacement?.ItemId == o.ObjectId) + bool fallbackResolved = _itemInteraction is null + && _pendingListPlacement?.ItemId == o.ObjectId; + if (fallbackResolved) _pendingListPlacement = null; if (_selection.SelectedObjectId == o.ObjectId) { @@ -250,15 +255,16 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo SelectionChangeSource.System, SelectionChangeReason.SelectedObjectRemoved); } - if (Concerns(o)) Populate(); + if (fallbackResolved || Concerns(o)) Populate(); } private void OnObjectMoved(ClientObjectMove move) { - bool resolvedPending = _pendingListPlacement?.ItemId == move.ItemId; - if (resolvedPending) + bool fallbackResolved = _itemInteraction is null + && _pendingListPlacement?.ItemId == move.ItemId; + if (fallbackResolved) _pendingListPlacement = null; uint player = _playerGuid(); - if (resolvedPending + if (fallbackResolved || (move.Item is { } item && Concerns(item)) || move.Previous.ContainerId == player || move.Current.ContainerId == player @@ -272,30 +278,45 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo Populate(); } private void OnInteractionStateChanged() => ApplyIndicators(); - private void OnPendingBackpackPlacementRequested( - uint itemId, - uint containerId, - int placement) + private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending) { if (_pendingListPlacement is not null - || itemId == 0u - || containerId != EffectiveOpen() - || _objects.Get(itemId) is not { } item + || pending.ItemId == 0u + || pending.ContainerId != EffectiveOpen() + || _objects.Get(pending.ItemId) is not { } item || IsBag(item)) { return; } _pendingListPlacement = new PendingListPlacement( - itemId, - containerId, - placement); + pending.Token, + pending.ItemId, + pending.ContainerId, + pending.Placement); + Populate(); + } + private void OnPendingBackpackPlacementCancelled(PendingBackpackPlacement pending) + { + if (_pendingListPlacement is not { } projection + || projection.Token != pending.Token) + return; + _pendingListPlacement = null; + Populate(); + } + private void OnPendingBackpackPlacementResolved(PendingBackpackPlacement pending) + { + if (_pendingListPlacement is not { } projection + || projection.Token != pending.Token) + return; + _pendingListPlacement = null; Populate(); } private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators(); private void OnMoveRequestFailed(MoveRequestFailure failure) { - if (_pendingListPlacement?.ItemId != failure.ItemId) + if (_itemInteraction is not null + || _pendingListPlacement?.ItemId != failure.ItemId) return; _pendingListPlacement = null; Populate(); @@ -531,9 +552,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo return ItemDragAcceptance.None; if (payload.ObjId == 0) return ItemDragAcceptance.Reject; - if (payload.SourceKind == ItemDragSource.Ground - && _pendingListPlacement is not null) - return ItemDragAcceptance.Reject; if (targetList == _contentsGrid) return ItemDragAcceptance.Accept; if (targetList == _containerList || targetList == _topContainer) @@ -561,6 +579,23 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo uint item = payload.ObjId; if (item == 0) return; + // UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every + // release while m_pendingItem exists, before merge, split, or ordinary + // placement. ItemList_DragOver has no equivalent gate, so hover may + // still show the authored accept cursor immediately before this no-op. + if (targetList == _contentsGrid + && _pendingListPlacement is { } localPending + && localPending.ContainerId == EffectiveOpen()) + { + _itemInteraction?.ReportPendingBackpackPlacementConflict(); + return; + } + if (_itemInteraction is not null + && !_itemInteraction.EnsureInventoryRequestReady()) + { + return; + } + // UIElement_ItemList::AcceptDragObject tries ItemHolder::AttemptMerge first // when a physical item is released on an occupied inventory cell. A legal // merge consumes the drop; an illegal merge falls through to insert/move. @@ -597,8 +632,20 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { // UIAttemptSplitToContainer leaves the source stack where it is. ACE will // publish the reduced source plus a newly-guided destination stack. - _sendStackableSplitToContainer?.Invoke( - item, container, (uint)placement, splitSize); + DispatchInventoryRequest( + InventoryRequestKind.SplitToContainer, + item, + () => + { + if (_sendStackableSplitToContainer is null) + return false; + _sendStackableSplitToContainer( + item, + container, + (uint)placement, + splitSize); + return true; + }); return; } } @@ -610,13 +657,52 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo // @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680. if (payload.SourceKind == ItemDragSource.Ground) { - if (_pendingListPlacement is not null) + if (_itemInteraction is not null) + { + if (!_itemInteraction.TryDispatchPendingBackpackPlacement( + item, + container, + placement, + InventoryRequestKind.Pickup, + () => + { + if (_sendPutItemInContainer is null) + return false; + _sendPutItemInContainer(item, container, placement); + return true; + })) + { + return; + } return; - _pendingListPlacement = new PendingListPlacement(item, container, placement); - Populate(); + } + else + { + if (_pendingListPlacement is not null) + return; + _pendingListPlacement = new PendingListPlacement(0u, item, container, placement); + Populate(); + } } else { + if (_itemInteraction is not null) + { + DispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + item, + () => + { + if (_sendPutItemInContainer is null + || !_objects.MoveItemOptimistic(item, container, placement)) + { + return false; + } + _sendPutItemInContainer(item, container, placement); + return true; + }); + return; + } _objects.MoveItemOptimistic(item, container, placement); } _sendPutItemInContainer?.Invoke(item, container, placement); @@ -651,14 +737,30 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (plan is not { } merge) return false; - _sendStackableMerge(merge.SourceObjectId, merge.TargetObjectId, merge.Amount); - // ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge; - // it is an attempt notice, not a later server-confirm event. - _notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId); - _selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory); - return true; + return DispatchInventoryRequest( + InventoryRequestKind.Merge, + merge.SourceObjectId, + () => + { + _sendStackableMerge( + merge.SourceObjectId, + merge.TargetObjectId, + merge.Amount); + // ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge; + // it is an attempt notice, not a later server-confirm event. + _notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId); + _selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory); + return true; + }); } + private bool DispatchInventoryRequest( + InventoryRequestKind kind, + uint itemId, + Func dispatch) + => _itemInteraction?.TryDispatchInventoryRequest(kind, itemId, dispatch) + ?? dispatch(); + /// True only when we KNOW the container is full (capacity known + contents indexed). A /// closed bag (unknown count) returns false → advisory accept; the server is authoritative. private bool IsContainerFull(uint container) @@ -691,7 +793,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo uint p = _playerGuid(); _openContainer = guid; if (guid != p) - _sendUse?.Invoke(guid); // open the side bag (ViewContents will land) + { + if (_itemInteraction is not null) + _itemInteraction.ActivateItem(guid); + else + _sendUse?.Invoke(guid); // open the side bag (ViewContents will land) + } Populate(); // repaint the grid for the new open container } @@ -814,6 +921,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { _itemInteraction.StateChanged -= OnInteractionStateChanged; _itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested; + _itemInteraction.PendingBackpackPlacementCancelled -= OnPendingBackpackPlacementCancelled; + _itemInteraction.PendingBackpackPlacementResolved -= OnPendingBackpackPlacementResolved; } } } diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 4b7969e2..84a9a4f2 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -519,6 +519,29 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont uint player = _playerGuid?.Invoke() ?? 0u; if (player == 0 || _repo.Get(payload.ObjId) is null) return; + if (_itemInteraction is not null) + { + Func dispatch = () => + { + if (_sendPutItemInContainer is null) + return false; + _sendPutItemInContainer(payload.ObjId, player, 0); + return true; + }; + if (_itemInteraction.IsOwnedByPlayer(payload.ObjId)) + _itemInteraction.TryDispatchPendingBackpackPlacement( + payload.ObjId, + player, + 0, + InventoryRequestKind.PutInContainer, + dispatch); + else + _itemInteraction.TryDispatchInventoryRequest( + InventoryRequestKind.Pickup, + payload.ObjId, + dispatch); + return; + } _sendPutItemInContainer?.Invoke(payload.ObjId, player, 0); } diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 86526f9e..0093e68b 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -50,11 +50,19 @@ public readonly record struct ClientObjectPlacement( /// ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0 snapshots and /// publishes both complete placements after applying the new state. /// +public enum ClientObjectMoveOrigin +{ + LocalProjection, + ServerRollbackProjection, + AuthoritativeResponse, +} + public readonly record struct ClientObjectMove( uint ItemId, ClientObject? Item, ClientObjectPlacement Previous, - ClientObjectPlacement Current); + ClientObjectPlacement Current, + ClientObjectMoveOrigin Origin); public enum ClientObjectRemovalReason { @@ -119,7 +127,10 @@ public sealed class ClientObjectTable /// /// Fires after a complete placement notice (moved between packs, - /// equipped, unequipped, dropped on ground). + /// equipped, unequipped, dropped on ground). + /// distinguishes an immediate local projection from ACE's authoritative + /// response; transaction owners must complete only on the latter. + /// /// is null when retail publishes the notice before CreateObject resolves /// the GUID; the old/new placement remains authoritative notice data. /// @@ -168,6 +179,13 @@ public sealed class ClientObjectTable /// Fires when an object's properties are updated (typically after Appraise). public event Action? ObjectUpdated; + /// + /// Fires only for an authoritative SetStackSize response. Inventory request + /// owners use this narrower seam instead of treating an unrelated appraisal + /// or property update as the response to a merge, split, give, or drop. + /// + public event Action? StackSizeUpdated; + /// Fires after all session object and pending-move state is flushed. public event Action? Cleared; @@ -242,7 +260,8 @@ public sealed class ClientObjectTable newSlot, item.WielderId, newEquipLocation), - containerTypeHint); + containerTypeHint, + ClientObjectMoveOrigin.LocalProjection); } /// @@ -270,13 +289,15 @@ public sealed class ClientObjectTable newSlot, newWielderId, newEquipLocation), - containerTypeHint); + containerTypeHint, + ClientObjectMoveOrigin.AuthoritativeResponse); } private bool ApplyPlacement( ClientObject item, ClientObjectPlacement current, - uint? containerTypeHint = null) + uint? containerTypeHint, + ClientObjectMoveOrigin origin) { ClientObjectPlacement previous = ClientObjectPlacement.From(item); List? changedContainers = RemoveFromOtherContainerIndexes( @@ -289,18 +310,19 @@ public sealed class ClientObjectTable if (containerTypeHint is { } hint) item.ContainerTypeHint = hint; Reindex(item, previous.ContainerId); - PublishPlacementChange(item, previous); + PublishPlacementChange(item, previous, origin); PublishContainerContentsChanges(changedContainers); return true; } private void PublishPlacementChange( ClientObject item, - ClientObjectPlacement previous) + ClientObjectPlacement previous, + ClientObjectMoveOrigin origin) { ClientObjectPlacement current = ClientObjectPlacement.From(item); UpdateEquipmentIndex(item.ObjectId, previous, current); - ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current)); + ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current, origin)); } /// @@ -337,7 +359,8 @@ public sealed class ClientObjectTable newContainerId, newSlot, newWielderId, - newEquipLocation))); + newEquipLocation), + ClientObjectMoveOrigin.AuthoritativeResponse)); return false; } @@ -364,14 +387,17 @@ public sealed class ClientObjectTable itemId, Item: null, Previous: default, - Current: placement)); + Current: placement, + ClientObjectMoveOrigin.AuthoritativeResponse)); WieldConfirmed?.Invoke(itemId); return false; } if (!ApplyPlacement( item, - placement)) + placement, + containerTypeHint: null, + ClientObjectMoveOrigin.AuthoritativeResponse)) { return false; } @@ -434,7 +460,7 @@ public sealed class ClientObjectTable } else item.ContainerSlot = newSlot; - PublishPlacementChange(item, previous); + PublishPlacementChange(item, previous, ClientObjectMoveOrigin.LocalProjection); PublishContainerContentsChanges(changedContainers); return true; } @@ -485,12 +511,12 @@ public sealed class ClientObjectTable if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; _pendingMoves.Remove(itemId); ClientObjectPlacement placement = pre.placement; - if (!ApplyServerMove( - itemId, - placement.ContainerId, - placement.WielderId, - placement.ContainerSlot, - placement.EquipLocation)) + if (!_objects.TryGetValue(itemId, out ClientObject? item) + || !ApplyPlacement( + item, + placement, + containerTypeHint: null, + ClientObjectMoveOrigin.ServerRollbackProjection)) return false; MoveRolledBack?.Invoke(_objects[itemId]); return true; @@ -654,6 +680,7 @@ public sealed class ClientObjectTable item.StackSize = stackSize; item.Value = value; ObjectUpdated?.Invoke(item); + StackSizeUpdated?.Invoke(item); return true; } diff --git a/src/AcDream.Core/Physics/Motion/MoveToManager.cs b/src/AcDream.Core/Physics/Motion/MoveToManager.cs index ac943c80..3bd27b9c 100644 --- a/src/AcDream.Core/Physics/Motion/MoveToManager.cs +++ b/src/AcDream.Core/Physics/Motion/MoveToManager.cs @@ -167,6 +167,14 @@ public sealed class MoveToManager /// public Action? MoveToComplete { get; set; } + /// + /// CLIENT ADDITION — cancellation companion to . + /// Fires only when actually tears down an + /// active move. It never changes retail movement behavior; App owners use + /// it to withdraw presentation state which was waiting for natural arrival. + /// + public Action? MoveToCancelled { get; set; } + /// Retail Timer::cur_time — injectable clock (tests drive /// this explicitly; production binds to the real wall/game clock). /// Defaults to a monotonically-increasing stub if not overridden via the @@ -1455,18 +1463,18 @@ public sealed class MoveToManager /// , , then /// StopCompletely. The argument is NEVER READ /// in retail's body (every call site's error code is dropped in this - /// build, decomp §7c) — kept for parity/logging/tests only; NO behavior - /// depends on its value. + /// build, decomp §7c). The retail body remains independent of it; only the + /// documented client-addition observes it + /// after teardown so App presentation can classify the cancellation. /// public void CancelMoveTo(WeenieError error) { - _ = error; // retail: never read in the body (decomp §7c) — kept for parity/logging. - if (MovementTypeState == MovementType.Invalid) return; _pendingActions.Clear(); CleanUp(); if (HasPhysicsObj) _stopCompletely(); + MoveToCancelled?.Invoke(error); } /// diff --git a/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs b/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs index e561cb5e..e1c10600 100644 --- a/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs +++ b/tests/AcDream.App.Tests/Input/OutboundInteractionQueueTests.cs @@ -53,4 +53,43 @@ public sealed class OutboundInteractionQueueTests Assert.False(invoked); } + + [Fact] + public void Drain_ReentrantClearStopsTheCapturedBatch() + { + var queue = new OutboundInteractionQueue(); + var actual = new List(); + queue.Enqueue(() => + { + actual.Add("first"); + queue.Clear(); + }); + queue.Enqueue(() => actual.Add("stale")); + + queue.Drain(); + + Assert.Equal(new[] { "first" }, actual); + Assert.Equal(0, queue.Count); + } + + [Fact] + public void Drain_ReentrantClearAndEnqueueDefersTheNewEpoch() + { + var queue = new OutboundInteractionQueue(); + var actual = new List(); + queue.Enqueue(() => + { + actual.Add("first"); + queue.Clear(); + queue.Enqueue(() => actual.Add("new-session")); + }); + queue.Enqueue(() => actual.Add("stale")); + + queue.Drain(); + Assert.Equal(new[] { "first" }, actual); + Assert.Equal(1, queue.Count); + + queue.Drain(); + Assert.Equal(new[] { "first", "new-session" }, actual); + } } diff --git a/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs b/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs new file mode 100644 index 00000000..8cc44107 --- /dev/null +++ b/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs @@ -0,0 +1,106 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Interaction; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Interaction; + +public sealed class PlayerInteractionMovementSinkTests +{ + private const uint Player = 0x5000_0001u; + private const uint Target = 0x7000_0001u; + private const uint Cell = 0x0101_0001u; + + [Fact] + public void MissingPlayerDoesNotArmTheIntent() + { + var sink = new PlayerInteractionMovementSink(() => null); + bool armed = false; + + Assert.False(sink.BeginApproach(Approach(closeRange: true), () => armed = true)); + Assert.False(armed); + } + + [Theory] + [InlineData(true, MovementType.TurnToObject)] + [InlineData(false, MovementType.MoveToObject)] + public void ProductionSinkCancelsThenArmsAndInstallsExactMovement( + bool closeRange, + MovementType expectedType) + { + var controller = new PlayerMovementController(new PhysicsEngine()); + controller.SetPosition(Vector3.Zero, Cell); + bool nonAutonomousAtTargetInstall = false; + int cancellations = 0; + double targetQuantum = 0d; + var moveTo = new MoveToManager( + controller.Motion, + stopCompletely: () => { }, + getPosition: () => controller.CellPosition, + getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw), + setHeading: (heading, _) => controller.Yaw = MoveToMath.YawFromHeading(heading), + getOwnRadius: () => 0.4f, + getOwnHeight: () => 1.8f, + contact: () => true, + isInterpolating: () => false, + getVelocity: () => Vector3.Zero, + getSelfId: () => Player, + setTarget: (_, topLevelId, radius, quantum) => + { + Assert.Equal(Target, topLevelId); + Assert.Equal(0.5f, radius); + targetQuantum = quantum; + nonAutonomousAtTargetInstall = + controller.Motion.PhysicsObj?.LastMoveWasAutonomous == false; + }, + clearTarget: () => { }, + getTargetQuantum: () => targetQuantum, + setTargetQuantum: quantum => targetQuantum = quantum); + moveTo.MoveToCancelled = _ => cancellations++; + controller.MoveTo = moveTo; + + // Prove the sink's explicit cancel occurs before its arm callback. + moveTo.MoveToPosition( + new Position(Cell, new Vector3(2f, 0f, 0f), Quaternion.Identity), + new MovementParameters { UseSpheres = false }); + bool armedAfterCancellation = false; + var sink = new PlayerInteractionMovementSink(() => controller); + + Assert.True(sink.BeginApproach( + Approach(closeRange), + () => armedAfterCancellation = cancellations == 1 && !moveTo.IsMovingTo())); + + Assert.True(armedAfterCancellation); + Assert.True(nonAutonomousAtTargetInstall); + Assert.Equal(expectedType, moveTo.MovementTypeState); + Assert.Equal(Target, moveTo.SoughtObjectId); + Assert.Equal(Target, moveTo.TopLevelObjectId); + Assert.Equal(closeRange ? 0f : 0.75f, moveTo.SoughtObjectRadius); + Assert.Equal(closeRange ? 0f : 2.25f, moveTo.SoughtObjectHeight); + Assert.Equal(0.6f, moveTo.Params.DistanceToObject); + Assert.Equal(!closeRange, moveTo.Params.CanCharge); + } + + private static InteractionApproach Approach(bool closeRange) + { + var entity = new WorldEntity + { + Id = 101u, + ServerGuid = Target, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = new Vector3(5f, 0f, 0f), + Rotation = Quaternion.Identity, + MeshRefs = [], + }; + return new InteractionApproach( + new WorldInteractionTarget(Target, entity.Id, entity), + new PlayerInteractionPose(Cell, Vector3.Zero), + UseRadius: 0.6f, + IsCloseRange: closeRange, + CanCharge: !closeRange, + TargetRadius: 0.75f, + TargetHeight: 2.25f); + } +} diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index c9fa956f..7c64a8e8 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -26,6 +26,8 @@ public sealed class SelectionInteractionControllerTests public bool Hostile { get; set; } public bool Useable { get; set; } = true; public bool Pickupable { get; set; } = true; + public bool CaptureIdentity { get; set; } = true; + public uint LocalEntityId { get; set; } = 101u; public ClosestCombatTarget? Closest { get; set; } public InteractionApproach? Approach { get; set; } public List Events { get; } = new(); @@ -41,6 +43,11 @@ public sealed class SelectionInteractionControllerTests => PickAtCursor(includeSelf); public void BeginLightingPulse(uint serverGuid) => Events.Add("pulse"); + public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId) + { + localEntityId = LocalEntityId; + return CaptureIdentity; + } public bool IsCurrent(uint serverGuid, uint localEntityId) => Current; public string Describe(uint serverGuid) => $"Target {serverGuid:X8}"; public bool IsCreature(uint serverGuid) => Creature; @@ -100,9 +107,15 @@ public sealed class SelectionInteractionControllerTests private sealed class Movement : IPlayerInteractionMovementSink { public List Approaches { get; } = new(); - public bool BeginApproach(InteractionApproach approach) + public bool Starts { get; set; } = true; + public Action? AfterArm { get; set; } + public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null) { Approaches.Add(approach); + if (!Starts) + return false; + armAfterCancel?.Invoke(); + AfterArm?.Invoke(); return true; } } @@ -116,6 +129,8 @@ public sealed class SelectionInteractionControllerTests public readonly ClientObjectTable Objects = new(); public readonly List Toasts = new(); public readonly List Examines = new(); + public readonly List PendingPlacements = new(); + public readonly List CancelledPlacements = new(); public readonly ItemInteractionController Items; public readonly SelectionInteractionController Controller; @@ -127,6 +142,12 @@ public sealed class SelectionInteractionControllerTests ObjectId = Player, Type = ItemType.Creature, }); + Objects.AddOrUpdate(new ClientObject + { + ObjectId = Target, + Name = "Target", + Type = ItemType.Misc, + }); Items = new ItemInteractionController( Objects, () => Player, @@ -148,6 +169,8 @@ public sealed class SelectionInteractionControllerTests Transport, Movement, Toasts.Add); + Items.PendingBackpackPlacementRequested += PendingPlacements.Add; + Items.PendingBackpackPlacementCancelled += CancelledPlacements.Add; } public void SetApproach(bool closeRange, uint localEntityId = 101u) @@ -270,12 +293,14 @@ public sealed class SelectionInteractionControllerTests h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.SelectionPickUp); h.Controller.SendUse(Target); + h.Items.InteractionState.EnterExamine(); h.Controller.ResetSession(); h.Controller.DrainOutbound(); h.Controller.OnNaturalMoveToComplete(); Assert.Null(h.Selection.SelectedObjectId); + Assert.False(h.Items.IsAnyTargetModeActive); Assert.Empty(h.Transport.Pickups); Assert.Empty(h.Transport.Uses); } @@ -305,6 +330,216 @@ public sealed class SelectionInteractionControllerTests Assert.Empty(h.Transport.Uses); } + [Fact] + public void SynchronousNaturalCompletionObservesTheArmedCloseAction() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Movement.AfterArm = h.Controller.OnNaturalMoveToComplete; + + h.Controller.SendUse(Target); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Equal(new[] { Target }, h.Transport.Uses); + } + + [Fact] + public void SynchronousResetCannotResurrectTheCloseAction() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Movement.AfterArm = h.Controller.ResetSession; + + h.Controller.SendUse(Target); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Empty(h.Transport.Uses); + } + + [Fact] + public void CancelledClosePickupWithdrawsPresentationAndNeverFiresLater() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); + Assert.Single(h.PendingPlacements); + Assert.Empty(h.Transport.Pickups); + + h.Controller.OnMoveToCancelled(WeenieError.ActionCancelled); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId)); + Assert.Empty(h.Transport.Pickups); + } + + [Fact] + public void ErrorCompletionClearsCloseActionBeforeALaterSuccess() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Controller.SendUse(Target); + + h.Controller.OnMoveToCancelled(WeenieError.NoObject); + h.Controller.OnNaturalMoveToComplete(); + + Assert.Empty(h.Transport.Uses); + } + + [Fact] + public void FailedClosePickupStartWithdrawsPresentation() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Movement.Starts = false; + + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); + + Assert.Single(h.PendingPlacements); + Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId)); + Assert.Empty(h.Transport.Pickups); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void InvalidKeyboardPickupNeverPublishesAWaitingSlot(bool creature) + { + var h = new Harness(); + h.Query.Creature = creature; + h.Query.Pickupable = false; + h.Selection.Select(Target, SelectionChangeSource.World); + + h.Controller.HandleInputAction(InputAction.SelectionPickUp); + h.Controller.DrainOutbound(); + + Assert.Empty(h.PendingPlacements); + Assert.Empty(h.Transport.Pickups); + } + + [Theory] + [InlineData(InputAction.SelectDblLeft)] + [InlineData(InputAction.UseSelected)] + [InlineData(InputAction.SelectionPickUp)] + public void QueuedWorldActionCannotCrossAnIncarnationBoundary(InputAction action) + { + var h = new Harness(); + h.Query.Picked = Target; + h.SetApproach(closeRange: false); + h.Selection.Select(Target, SelectionChangeSource.World); + + h.Controller.HandleInputAction(action); + h.Query.Current = false; + h.Controller.DrainOutbound(); + + Assert.Empty(h.Transport.Uses); + Assert.Empty(h.Transport.Pickups); + Assert.Empty(h.PendingPlacements); + } + + [Fact] + public void DragReleasePulsesTheDropTargetWithoutChangingSelection() + { + var h = new Harness(); + h.Query.Picked = Target; + const uint item = 0x7000_0002u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = item, + Name = "Dragged", + Type = ItemType.Misc, + }); + var payload = new ItemDragPayload( + item, + ItemDragSource.Inventory, + SourceSlot: 0, + new UiItemSlot()); + + h.Controller.PlaceDraggedItem(payload, 10f, 20f); + + Assert.Contains("pulse", h.Query.Events); + Assert.Null(h.Selection.SelectedObjectId); + } + + [Fact] + public void RejectedTargetModeClickConsumesTheFollowingDoubleClick() + { + var h = new Harness(); + h.Query.Picked = Target; + h.Objects.Remove(Target); + h.Items.InteractionState.EnterUse(); + + h.Controller.HandleInputAction(InputAction.SelectLeft); + h.Controller.HandleInputAction(InputAction.SelectDblLeft); + h.Controller.DrainOutbound(); + + Assert.Null(h.Selection.SelectedObjectId); + Assert.Empty(h.Transport.Uses); + Assert.Empty(h.Transport.Pickups); + } + + [Fact] + public void TransportLossAtClosePickupCompletionWithdrawsPresentation() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); + + h.Transport.IsInWorld = false; + h.Controller.OnNaturalMoveToComplete(); + + Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId)); + Assert.Empty(h.Transport.Pickups); + } + + [Fact] + public void RepeatedPickupKeepsTheOriginalReservationAndDeferredAction() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); + h.Selection.Select(Target, SelectionChangeSource.World); + + h.Controller.HandleInputAction(InputAction.SelectionPickUp); + h.Controller.DrainOutbound(); + + Assert.Single(h.PendingPlacements); + Assert.Empty(h.CancelledPlacements); + + h.Controller.OnNaturalMoveToComplete(); + Assert.Equal(new[] { (Target, Player, 0) }, h.Transport.Pickups); + } + + [Fact] + public void WithdrawnPickupReservationInvalidatesDeferredWireAction() + { + const uint replacement = 0x7000_0002u; + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = replacement, + Name = "Replacement loot", + Type = ItemType.Misc, + }); + + Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); + Assert.Empty(h.Transport.Pickups); + PendingBackpackPlacement original = Assert.Single(h.PendingPlacements); + h.Items.CancelPendingBackpackPlacement(Target, original.Token); + Assert.True(h.Items.TryBeginPendingBackpackPlacement( + replacement, + Player, + placement: 0, + out _)); + + h.Controller.OnNaturalMoveToComplete(); + + Assert.Empty(h.Transport.Pickups); + Assert.True(h.Items.TryGetPendingBackpackPlacement(replacement, out _)); + Assert.False(h.Items.TryGetPendingBackpackPlacement(Target, out _)); + } + private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) => new( guid, diff --git a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs index 0bb1ed9f..a1cc6f13 100644 --- a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs +++ b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs @@ -32,6 +32,9 @@ public sealed class WorldSelectionQueryTests public RetailSelectionMesh? Resolve(uint gfxObjId) => mesh; } + private sealed record Animation(WorldEntity Entity, uint CurrentMotion) + : ILiveEntityAnimationRuntime; + private sealed class Harness { public readonly ClientObjectTable Objects = new(); @@ -131,6 +134,41 @@ public sealed class WorldSelectionQueryTests Assert.Null(h.Query.PickAtCursor(includeSelf: false)); } + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void PickRejectsPublishedPartAfterVisibilityLifetimeEnds(int transition) + { + var h = new Harness(); + WorldEntity target = h.Add(Target, new Vector3(0f, 0f, -5f), ItemType.Misc); + h.Publish(target); + Assert.Equal(Target, h.Query.PickAtCursor(includeSelf: false)); + + switch (transition) + { + case 0: + Assert.True(h.Runtime.TryApplyState( + new SetState.Parsed( + Target, + (uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden), + InstanceSequence: 1, + StateSequence: 2), + out _)); + break; + case 1: + Assert.True(h.Runtime.WithdrawLiveEntityProjection(Target)); + break; + default: + Assert.True(h.Runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(Target, 1), + isLocalPlayer: false)); + break; + } + + Assert.Null(h.Query.PickAtCursor(includeSelf: false)); + } + [Fact] public void ClosestTargetIncludesOnlyVisibleLivingHostileMonsters() { @@ -150,6 +188,43 @@ public sealed class WorldSelectionQueryTests new Vector3(1f, 0f, 0f), ItemType.Misc, SelectedObjectHealthPolicy.BfAttackable); + h.Add( + 0x7000_0013u, + new Vector3(3f, 0f, 0f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable | SelectedObjectHealthPolicy.BfPlayer); + WorldEntity pet = h.Add( + 0x7000_0014u, + new Vector3(4f, 0f, 0f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + h.Objects.Get(pet.ServerGuid)!.PetOwnerId = Player; + WorldEntity dead = h.Add( + 0x7000_0015u, + new Vector3(5f, 0f, 0f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + h.Runtime.SetAnimationRuntime( + dead.ServerGuid, + new Animation(dead, MotionCommand.Dead)); + WorldEntity hidden = h.Add( + 0x7000_0016u, + new Vector3(6f, 0f, 0f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + Assert.True(h.Runtime.TryApplyState( + new SetState.Parsed( + hidden.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden), + InstanceSequence: 1, + StateSequence: 2), + out _)); + WorldEntity pending = h.Add( + 0x7000_0017u, + new Vector3(7f, 0f, 0f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + Assert.True(h.Runtime.WithdrawLiveEntityProjection(pending.ServerGuid)); ClosestCombatTarget? closest = h.Query.FindClosestHostileMonster(); diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index f2d3fd0d..36b8275e 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -1424,14 +1424,445 @@ public sealed class ItemInteractionControllerTests { var h = new Harness(); const uint item = 0x70000A0Bu; - var pending = new List<(uint Item, uint Container, int Placement)>(); - h.Controller.PendingBackpackPlacementRequested += - (objectId, container, placement) => pending.Add((objectId, container, placement)); + var pending = new List(); + h.Controller.PendingBackpackPlacementRequested += pending.Add; Assert.True(h.Controller.PlaceWorldItemInBackpack(item)); - Assert.Equal(new[] { (item, Player, 0) }, pending); + Assert.Collection( + pending, + placement => + { + Assert.NotEqual(0u, placement.Token); + Assert.Equal(item, placement.ItemId); + Assert.Equal(Player, placement.ContainerId); + Assert.Equal(0, placement.Placement); + }); Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var request)); + Assert.Equal(InventoryRequestKind.Pickup, request.Kind); + Assert.False(request.Dispatched); + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.Pickup, + item, + static () => true, + pending[0].Token)); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out request)); + Assert.True(request.Dispatched); + } + + [Fact] + public void PendingRequestSerializesEveryLaterItemWithoutSecondWire() + { + var h = new Harness(); + const uint item = 0x70000A0Du; + const uint laterItem = 0x70000A0Eu; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = item, + Name = "Loot", + Type = ItemType.Misc, + }); + var requested = new List(); + var cancelled = new List(); + h.Controller.PendingBackpackPlacementRequested += requested.Add; + h.Controller.PendingBackpackPlacementCancelled += cancelled.Add; + + Assert.True(h.Controller.PlaceWorldItemInBackpack(item)); + PendingBackpackPlacement first = Assert.Single(requested); + Assert.True(h.Controller.PlaceWorldItemInBackpack(item)); + Assert.True(h.Controller.PlaceWorldItemInBackpack(laterItem)); + + Assert.Single(requested); + Assert.Empty(cancelled); + Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements); + Assert.Equal( + new[] + { + "Already attempting to place Loot here", + "Already attempting to place Loot here", + }, + h.SystemMessages); + Assert.True(h.Controller.TryGetPendingBackpackPlacement(item, out var current)); + Assert.Equal(first, current); + + h.Objects.RejectMove(item, weenieError: 0x29u); + Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _)); + Assert.Empty(cancelled); + } + + [Fact] + public void BusyInventoryTransactionPreventsPickupReservationAndWireRequest() + { + var h = new Harness(); + const uint item = 0x70000A0Fu; + h.Controller.IncrementBusyCount(); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(item)); + + Assert.Empty(h.BackpackPlacements); + Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _)); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + h.SystemMessages); + } + + [Fact] + public void PendingPickupPreventsContainedUseRequest() + { + var h = new Harness(); + const uint pickup = 0x70000A10u; + const uint contained = 0x50000A11u; + h.AddContained(contained, item => item.Useability = ItemUseability.Contained); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup)); + h.Now += 200; + h.Controller.ActivateItem(contained); + + Assert.Empty(h.Uses); + Assert.True(h.Controller.TryGetPendingBackpackPlacement(pickup, out _)); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + h.SystemMessages); + } + + [Fact] + public void PendingPickupPreventsPaperdollWieldRequest() + { + var h = new Harness(); + const uint pickup = 0x70000A12u; + const uint helm = 0x50000A13u; + h.AddContained(helm, item => item.ValidLocations = EquipMask.HeadWear); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup)); + Assert.False(h.Controller.WieldFromPaperdoll(helm, EquipMask.HeadWear)); + + Assert.Empty(h.Wields); + Assert.Equal(EquipMask.None, h.Objects.Get(helm)!.CurrentlyEquippedLocation); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + h.SystemMessages); + } + + [Fact] + public void PendingPickupPreventsConfirmedUseRequest() + { + var h = new Harness(); + const uint pickup = 0x70000A14u; + const uint item = 0x50000A15u; + h.AddContained(item, contained => contained.Useability = ItemUseability.Contained); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup)); + Assert.False(h.Controller.ExecuteConfirmedUse(item)); + + Assert.Empty(h.Uses); + Assert.Equal(0, h.Controller.BusyCount); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + h.SystemMessages); + } + + [Fact] + public void GlobalInventoryRequestRejectsPickupUntilMatchingMoveResponse() + { + var h = new Harness(); + const uint moving = 0x50000A16u; + const uint unrelated = 0x50000A17u; + const uint pickup = 0x70000A18u; + h.AddContained(moving); + h.AddContained(unrelated); + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + moving, + static () => true)); + Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup)); + + Assert.Empty(h.BackpackPlacements); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(moving, pending.ItemId); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + h.SystemMessages); + + h.Objects.ApplyConfirmedServerMove(unrelated, Pack, 0u, 0); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out _)); + + h.Objects.ApplyConfirmedServerMove(moving, Pack, 0u, 0); + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); + Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup)); + Assert.Equal(new[] { (pickup, Player, 0) }, h.BackpackPlacements); + } + + [Fact] + public void SplitRequestReleasesOnlyOnSourceStackResponse() + { + var h = new Harness(); + const uint source = 0x50000A19u; + const uint unrelated = 0x50000A1Au; + h.AddContained(source, item => item.StackSize = 10); + h.AddContained(unrelated, item => item.StackSize = 10); + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.SplitToContainer, + source, + static () => true)); + h.Objects.UpdateStackSize(unrelated, 9, 0); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out _)); + + h.Objects.UpdateStackSize(source, 9, 0); + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); + } + + [Fact] + public void MatchingInventoryFailureReleasesGlobalRequest() + { + var h = new Harness(); + const uint source = 0x50000A1Bu; + h.AddContained(source); + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + source, + static () => true)); + + h.Objects.RejectMove(source, weenieError: 0x29u); + + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); + Assert.True(h.Controller.CanMakeInventoryRequest); + } + + [Fact] + public void ProvisionalInventoryOwnerRejectsReentrantSecondDispatch() + { + var h = new Harness(); + const uint first = 0x50000A1Cu; + const uint second = 0x50000A1Du; + h.AddContained(first); + h.AddContained(second); + bool secondDispatched = true; + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + first, + () => + { + secondDispatched = h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + second, + static () => true); + return true; + })); + + Assert.False(secondDispatched); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(first, pending.ItemId); + Assert.True(pending.Dispatched); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + h.SystemMessages); + } + + [Fact] + public void SynchronousMatchingResponseDoesNotResurrectProvisionalOwner() + { + var h = new Harness(); + const uint item = 0x50000A1Eu; + h.AddContained(item); + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + item, + () => h.Objects.ApplyConfirmedServerMove(item, Player, 0u, 0))); + + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); + Assert.True(h.Controller.CanMakeInventoryRequest); + } + + [Fact] + public void OptimisticMoveKeepsGlobalOwnerUntilAuthoritativeResponse() + { + var h = new Harness(); + const uint item = 0x50000A20u; + const uint second = 0x50000A21u; + h.AddContained(item); + h.AddContained(second); + + Assert.True(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + item, + () => h.Objects.MoveItemOptimistic(item, Player, 0))); + + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(item, pending.ItemId); + Assert.True(pending.Dispatched); + Assert.False(h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + second, + static () => true)); + + Assert.True(h.Objects.ApplyConfirmedServerMove(item, Player, 0u, 0)); + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); + } + + [Fact] + public void AuthoritativeResponseReleasesGlobalOwnerBeforePublishingPlacementResolution() + { + var h = new Harness(); + const uint first = 0x70000A22u; + const uint second = 0x50000A23u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = first, + Name = "Loot", + Type = ItemType.Misc, + }); + h.AddContained(second); + bool secondDispatched = false; + h.Controller.PendingBackpackPlacementResolved += _ => + secondDispatched = h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + second, + static () => true); + + Assert.True(h.Controller.TryDispatchPendingBackpackPlacement( + first, + Player, + 0, + InventoryRequestKind.Pickup, + static () => true)); + + Assert.True(h.Objects.ApplyConfirmedServerMove(first, Player, 0u, 0)); + + Assert.True(secondDispatched); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(second, pending.ItemId); + Assert.True(pending.Dispatched); + } + + [Fact] + public void FailedMoveReleasesOldOwnerOnceAndPreservesReentrantSameItemRequest() + { + var h = new Harness(); + const uint item = 0x50000A24u; + h.AddContained(item); + bool replacementDispatched = false; + h.Controller.PendingBackpackPlacementResolved += _ => + replacementDispatched = h.Controller.TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + item, + static () => true); + + Assert.True(h.Controller.TryDispatchPendingBackpackPlacement( + item, + Player, + 0, + InventoryRequestKind.PutInContainer, + () => h.Objects.MoveItemOptimistic(item, Player, 0))); + + Assert.True(h.Objects.RejectMove(item, weenieError: 0x29u)); + + Assert.True(replacementDispatched); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(item, pending.ItemId); + Assert.True(pending.Dispatched); + } + + [Fact] + public void ResponseClearsGlobalAndLocalStateBeforeReadinessNotification() + { + var h = new Harness(); + const uint first = 0x70000A25u; + const uint second = 0x70000A26u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = first, + Name = "First loot", + Type = ItemType.Misc, + }); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = second, + Name = "Second loot", + Type = ItemType.Misc, + }); + Assert.True(h.Controller.TryDispatchPendingBackpackPlacement( + first, + Player, + 0, + InventoryRequestKind.Pickup, + static () => true)); + + var eventOrder = new List(); + h.Controller.PendingBackpackPlacementResolved += placement => + { + if (placement.ItemId == first) + eventOrder.Add("resolved-first"); + }; + h.Controller.PendingBackpackPlacementRequested += placement => + { + if (placement.ItemId == second) + eventOrder.Add("requested-second"); + }; + bool attempted = false; + bool secondDispatched = false; + h.Controller.StateChanged += () => + { + if (attempted || !h.Controller.CanMakeInventoryRequest) + return; + attempted = true; + eventOrder.Add("ready"); + secondDispatched = h.Controller.TryDispatchPendingBackpackPlacement( + second, + Player, + 0, + InventoryRequestKind.Pickup, + static () => true); + }; + + Assert.True(h.Objects.ApplyConfirmedServerMove(first, Player, 0u, 0)); + + Assert.True(attempted); + Assert.True(secondDispatched); + Assert.True(h.Controller.TryGetPendingBackpackPlacement(second, out _)); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(second, pending.ItemId); + Assert.True(pending.Dispatched); + Assert.Equal( + new[] { "resolved-first", "ready", "requested-second" }, + eventOrder); + } + + [Fact] + public void ReentrantPendingPublicationCancellationPreventsWireDispatch() + { + var h = new Harness(); + const uint item = 0x70000A1Fu; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = item, + Name = "Loot", + Type = ItemType.Misc, + }); + bool wireDispatched = false; + h.Controller.PendingBackpackPlacementRequested += pending => + h.Controller.CancelPendingBackpackPlacement(pending.ItemId, pending.Token); + + Assert.False(h.Controller.TryDispatchPendingBackpackPlacement( + item, + Player, + 0, + InventoryRequestKind.Pickup, + () => + { + wireDispatched = true; + return true; + })); + + Assert.False(wireDispatched); + Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _)); + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs index 993a084c..ec06a3db 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs @@ -263,4 +263,81 @@ public sealed class ExternalContainerControllerTests Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId); Assert.Empty(h.Puts); } + + [Fact] + public void PendingPickupRejectsOwnedDropIntoChest() + { + using var h = new Harness(); + h.Open(); + h.Objects.AddOrUpdate(new ClientObject { ObjectId = Item, Type = ItemType.Misc }); + h.Objects.MoveItem(Item, Player, 0); + var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory }; + source.SetItem(Item, 0u); + + Assert.True(h.Interaction.PlaceWorldItemInBackpack(0x70000A01u)); + h.Controller.HandleDropRelease( + h.Contents, + h.Contents.GetItem(0)!, + new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source)); + + Assert.Empty(h.Puts); + Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId); + Assert.True(h.Interaction.TryGetPendingBackpackPlacement(0x70000A01u, out _)); + } + + [Fact] + public void PendingPickupRejectsPartialStackSplitIntoChest() + { + using var h = new Harness(); + h.Open(); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = Item, + StackSize = 5, + StackSizeMax = 100, + Type = ItemType.Misc, + }); + h.Objects.MoveItem(Item, Player, 0); + h.Selection.Select(Item, SelectionChangeSource.Inventory); + h.Split.Reset(5u, 2u); + var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory }; + source.SetItem(Item, 0u); + + Assert.True(h.Interaction.PlaceWorldItemInBackpack(0x70000A02u)); + h.Controller.HandleDropRelease( + h.Contents, + h.Contents.GetItem(0)!, + new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source)); + + Assert.Empty(h.Splits); + Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId); + Assert.True(h.Interaction.TryGetPendingBackpackPlacement(0x70000A02u, out _)); + } + + [Fact] + public void ExternalPutOwnsGlobalGateUntilMatchingServerMove() + { + using var h = new Harness(); + const uint pickup = 0x70000A03u; + h.Open(); + h.Objects.AddOrUpdate(new ClientObject { ObjectId = Item, Type = ItemType.Misc }); + h.Objects.MoveItem(Item, Player, 0); + var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory }; + source.SetItem(Item, 0u); + + h.Controller.HandleDropRelease( + h.Contents, + h.Contents.GetItem(0)!, + new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source)); + Assert.True(h.Interaction.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(Item, pending.ItemId); + + Assert.True(h.Interaction.PlaceWorldItemInBackpack(pickup)); + Assert.Empty(h.Pickups); + + h.Objects.ApplyConfirmedServerMove(Item, Chest, 0u, 0); + Assert.False(h.Interaction.TryGetPendingInventoryRequest(out _)); + Assert.True(h.Interaction.PlaceWorldItemInBackpack(pickup)); + Assert.Equal(new uint[] { pickup }, h.Pickups); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 94734057..5f717fc4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -706,6 +706,467 @@ public class InventoryControllerTests Assert.Equal(0xBu, grid.GetItem(2)!.ItemId); } + [Fact] + public void DirectLootWhilePendingKeepsTheOriginalProjectionAndRequest() + { + const uint chest = 0x70000021u; + const uint firstLoot = 0x70000022u; + const uint secondLoot = 0x70000023u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, firstLoot, chest, slot: 0, type: ItemType.Misc); + SeedContained(objects, secondLoot, chest, slot: 1, type: ItemType.Misc); + long now = 1_000; + var eventOrder = new List<(string Kind, uint Item, ulong Token)>(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + nowMs: () => now, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + interaction.PendingBackpackPlacementRequested += pending => + eventOrder.Add(("request", pending.ItemId, pending.Token)); + interaction.PendingBackpackPlacementCancelled += pending => + eventOrder.Add(("cancel", pending.ItemId, pending.Token)); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + itemInteraction: interaction); + + Assert.True(interaction.ActivateItem(firstLoot)); + now += 200; + Assert.False(interaction.ActivateItem(secondLoot)); + + Assert.Collection( + eventOrder, + first => Assert.Equal(("request", firstLoot), (first.Kind, first.Item))); + Assert.Equal(firstLoot, grid.GetItem(0)!.ItemId); + Assert.True(grid.GetItem(0)!.WaitingVisual); + Assert.DoesNotContain( + Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId), + id => id == secondLoot); + } + + [Fact] + public void GroundDragThenDirectPickupKeepsTheFirstPendingProjection() + { + const uint chest = 0x70000031u; + const uint draggedLoot = 0x70000032u; + const uint directLoot = 0x70000033u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, draggedLoot, chest, slot: 0, type: ItemType.Misc); + SeedContained(objects, directLoot, chest, slot: 1, type: ItemType.Misc); + var puts = new List<(uint Item, uint Container, int Placement)>(); + var eventOrder = new List<(string Kind, uint Item)>(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: (item, container, placement) => + puts.Add((item, container, placement))); + interaction.PendingBackpackPlacementRequested += pending => + eventOrder.Add(("request", pending.ItemId)); + interaction.PendingBackpackPlacementCancelled += pending => + eventOrder.Add(("cancel", pending.ItemId)); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + sendPutItemInContainer: (item, container, placement) => + puts.Add((item, container, placement)), + itemInteraction: interaction); + var source = new UiItemSlot { SourceKind = ItemDragSource.Ground }; + source.SetItem(draggedLoot, 0u); + + inventory.HandleDropRelease( + grid, + grid.GetItem(4)!, + new ItemDragPayload(draggedLoot, ItemDragSource.Ground, 0, source)); + Assert.True(interaction.PlaceWorldItemInBackpack(directLoot)); + + Assert.Equal(new[] { ("request", draggedLoot) }, eventOrder); + Assert.Equal(new[] { (draggedLoot, Player, 0) }, puts); + Assert.Equal(draggedLoot, grid.GetItem(0)!.ItemId); + Assert.True(grid.GetItem(0)!.WaitingVisual); + Assert.DoesNotContain( + Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId), + id => id == directLoot); + } + + [Fact] + public void DirectPickupThenGroundDragAcceptsHoverButRejectsTheSecondRequest() + { + const uint chest = 0x70000041u; + const uint directLoot = 0x70000042u; + const uint draggedLoot = 0x70000043u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, directLoot, chest, slot: 0, type: ItemType.Misc); + SeedContained(objects, draggedLoot, chest, slot: 1, type: ItemType.Misc); + var eventOrder = new List<(string Kind, uint Item)>(); + var puts = new List<(uint Item, uint Container, int Placement)>(); + var messages = new List(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: (item, container, placement) => + puts.Add((item, container, placement)), + systemMessage: messages.Add); + interaction.PendingBackpackPlacementRequested += pending => + eventOrder.Add(("request", pending.ItemId)); + interaction.PendingBackpackPlacementCancelled += pending => + eventOrder.Add(("cancel", pending.ItemId)); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + sendPutItemInContainer: (item, container, placement) => + puts.Add((item, container, placement)), + itemInteraction: interaction); + var source = new UiItemSlot { SourceKind = ItemDragSource.Ground }; + source.SetItem(draggedLoot, 0u); + + Assert.True(interaction.PlaceWorldItemInBackpack(directLoot)); + Assert.Equal( + ItemDragAcceptance.Accept, + inventory.OnDragOver( + grid, + grid.GetItem(5)!, + new ItemDragPayload(draggedLoot, ItemDragSource.Ground, 0, source))); + inventory.HandleDropRelease( + grid, + grid.GetItem(5)!, + new ItemDragPayload(draggedLoot, ItemDragSource.Ground, 0, source)); + + Assert.Equal(new[] { ("request", directLoot) }, eventOrder); + Assert.Equal(new[] { (directLoot, Player, 0) }, puts); + Assert.Equal(directLoot, grid.GetItem(0)!.ItemId); + Assert.True(grid.GetItem(0)!.WaitingVisual); + Assert.Equal( + new[] { "Already attempting to place that item here" }, + messages); + Assert.DoesNotContain( + Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId), + id => id == draggedLoot); + } + + [Fact] + public void PendingContentsProjectionUsesGenericGlobalMessageOnBagColumnDrop() + { + const uint chest = 0x70000044u; + const uint pendingLoot = 0x70000045u; + const uint ownedItem = 0x50000046u; + const uint bag = 0x50000047u; + var (layout, _, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, pendingLoot, chest, 0, type: ItemType.Misc); + SeedContained(objects, ownedItem, Player, 0, type: ItemType.Misc); + SeedBag(objects, bag, 1); + var puts = new List<(uint Item, uint Container, int Placement)>(); + var messages = new List(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }, + systemMessage: messages.Add); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + sendPutItemInContainer: (item, container, placement) => + puts.Add((item, container, placement)), + itemInteraction: interaction); + + Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot)); + inventory.HandleDropRelease( + containers, + containers.GetItem(0)!, + Payload(ownedItem)); + + Assert.Empty(puts); + Assert.Equal( + new[] { ItemInteractionController.InventoryRequestBusyMessage }, + messages); + } + + [Fact] + public void PendingPickupRejectsCompatibleGroundMergeBeforeWireDispatch() + { + const uint chest = 0x70000071u; + const uint pendingLoot = 0x70000072u; + const uint sourceStack = 0x70000073u; + const uint targetStack = 0x70000074u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, pendingLoot, chest, slot: 0, type: ItemType.Misc); + objects.AddOrUpdate(new ClientObject + { + ObjectId = sourceStack, + WeenieClassId = 0x1234u, + StackSize = 10, + StackSizeMax = 100, + }); + objects.MoveItem(sourceStack, chest, 1); + objects.AddOrUpdate(new ClientObject + { + ObjectId = targetStack, + WeenieClassId = 0x1234u, + StackSize = 50, + StackSizeMax = 100, + }); + objects.MoveItem(targetStack, Player, 0); + var merges = new List<(uint Source, uint Target, uint Amount)>(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + sendStackableMerge: (source, target, amount) => + merges.Add((source, target, amount)), + itemInteraction: interaction); + var sourceCell = new UiItemSlot { SourceKind = ItemDragSource.Ground }; + sourceCell.SetItem(sourceStack, 0u); + + Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot)); + UiItemSlot targetCell = Enumerable.Range(0, grid.GetNumUIItems()) + .Select(i => grid.GetItem(i)!) + .Single(cell => cell.ItemId == targetStack); + inventory.HandleDropRelease( + grid, + targetCell, + new ItemDragPayload(sourceStack, ItemDragSource.Ground, 0, sourceCell)); + + Assert.Empty(merges); + Assert.True(interaction.TryGetPendingBackpackPlacement(pendingLoot, out _)); + } + + [Fact] + public void PendingPickupRejectsPartialGroundSplitBeforeWireDispatch() + { + const uint chest = 0x70000081u; + const uint pendingLoot = 0x70000082u; + const uint sourceStack = 0x70000083u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, pendingLoot, chest, slot: 0, type: ItemType.Misc); + objects.AddOrUpdate(new ClientObject + { + ObjectId = sourceStack, + StackSize = 10, + StackSizeMax = 100, + }); + objects.MoveItem(sourceStack, chest, 1); + var selection = new SelectionState(); + selection.Select(sourceStack, SelectionChangeSource.Inventory); + var splitQuantity = new StackSplitQuantityState(); + splitQuantity.Reset(10u); + splitQuantity.SetValue(1u); + var splits = new List<(uint Item, uint Container, uint Placement, uint Amount)>(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: selection, + datFont: null, + sendStackableSplitToContainer: (item, container, placement, amount) => + splits.Add((item, container, placement, amount)), + itemInteraction: interaction, + stackSplitQuantity: splitQuantity); + var sourceCell = new UiItemSlot { SourceKind = ItemDragSource.Ground }; + sourceCell.SetItem(sourceStack, 0u); + + Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot)); + inventory.HandleDropRelease( + grid, + grid.GetItem(5)!, + new ItemDragPayload(sourceStack, ItemDragSource.Ground, 0, sourceCell)); + + Assert.Empty(splits); + Assert.True(interaction.TryGetPendingBackpackPlacement(pendingLoot, out _)); + } + + [Fact] + public void PendingPickupRejectsOwnedInventoryMoveBeforeOptimisticMutation() + { + const uint chest = 0x70000091u; + const uint pendingLoot = 0x70000092u; + const uint ownedItem = 0x70000093u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, pendingLoot, chest, slot: 0, type: ItemType.Misc); + SeedContained(objects, ownedItem, Player, slot: 0, type: ItemType.Misc); + var puts = new List<(uint Item, uint Container, int Placement)>(); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + sendPutItemInContainer: (item, container, placement) => + puts.Add((item, container, placement)), + itemInteraction: interaction); + + Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot)); + inventory.HandleDropRelease(grid, grid.GetItem(5)!, Payload(ownedItem)); + + Assert.Empty(puts); + Assert.Equal(Player, objects.Get(ownedItem)!.ContainerId); + Assert.Equal(0, objects.Get(ownedItem)!.ContainerSlot); + Assert.True(interaction.TryGetPendingBackpackPlacement(pendingLoot, out _)); + } + + [Fact] + public void PendingLootRemovalImmediatelyWithdrawsTheProjection() + { + const uint chest = 0x70000051u; + const uint loot = 0x70000052u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + itemInteraction: interaction); + + Assert.True(interaction.PlaceWorldItemInBackpack(loot)); + Assert.Equal(loot, grid.GetItem(0)!.ItemId); + Assert.True(grid.GetItem(0)!.WaitingVisual); + + Assert.True(objects.Remove(loot)); + + Assert.Equal(0u, grid.GetItem(0)!.ItemId); + Assert.False(interaction.TryGetPendingBackpackPlacement(loot, out _)); + } + + [Fact] + public void PendingLootGenerationReplacementDoesNotLeaveTheOldProjection() + { + const uint chest = 0x70000061u; + const uint loot = 0x70000062u; + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc); + using var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + groundObjectId: () => chest, + backpackContainerId: () => Player, + placeInBackpack: static (_, _, _) => { }); + using var inventory = InventoryController.Bind( + layout, + objects, + () => Player, + iconIds: static (_, _, _, _, _) => 0u, + strength: () => 100, + selection: new SelectionState(), + datFont: null, + itemInteraction: interaction); + + Assert.True(interaction.PlaceWorldItemInBackpack(loot)); + Assert.Equal(loot, grid.GetItem(0)!.ItemId); + + objects.ReplaceGeneration(WorldReplacement(loot), generation: 2); + + Assert.Equal(0u, grid.GetItem(0)!.ItemId); + Assert.False(interaction.TryGetPendingBackpackPlacement(loot, out _)); + } + [Fact] public void LootDrop_ServerFailureRemovesOnlyThePendingProjection() { diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index f45ddae2..1d1c4775 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -46,7 +46,8 @@ public class PaperdollControllerTests SelectionState? selection = null, IReadOnlyDictionary? emptySlotSprites = null, List<(uint item, uint container, int placement)>? puts = null, - List? systemMessages = null) + List? systemMessages = null, + Action? configureInteraction = null) { var itemInteraction = new ItemInteractionController( objects, @@ -59,6 +60,7 @@ public class PaperdollControllerTests ? null : (item, container, placement) => puts.Add((item, container, placement)), systemMessage: systemMessages is null ? null : systemMessages.Add); + configureInteraction?.Invoke(itemInteraction); return PaperdollController.Bind(layout, objects, () => Player, iconIds: (_, _, _, _, _) => 0x1234u, itemInteraction: itemInteraction, @@ -240,6 +242,38 @@ public class PaperdollControllerTests Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire } + [Fact] + public void HandleDropRelease_pendingPickupRejectsWieldWithoutOptimisticMutation() + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + const uint pickup = 0x70000D02u; + const uint helm = 0x50000D03u; + SeedPackItem(objects, helm, EquipMask.HeadWear); + var wields = new List<(uint item, uint mask)>(); + var messages = new List(); + ItemInteractionController? interaction = null; + var ctrl = Bind( + layout, + objects, + wields, + systemMessages: messages, + configureInteraction: value => interaction = value); + Assert.True(interaction!.TryBeginPendingBackpackPlacement( + pickup, + Player, + 0, + out _)); + ctrl.HandleDropRelease( + lists[HeadSlot], + lists[HeadSlot].Cell, + new ItemDragPayload(helm, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell)); + + Assert.Empty(wields); + Assert.Equal(EquipMask.None, objects.Get(helm)!.CurrentlyEquippedLocation); + Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages); + } + [Fact] public void HandleDropRelease_weaponSlot_delegatesToConfirmedAutoWieldTransaction() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index b0521cb4..c21d6e1a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -301,6 +301,97 @@ public class ToolbarControllerTests Assert.Equal(new[] { (item, player, 0) }, puts); } + [Fact] + public void InventoryButton_unownedGroundDropUsesDirectAttemptWithoutDestinationProjection() + { + const uint player = 0x5000u; + const uint item = 0x70005001u; + var (layout, _, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = item, Name = "Loot", Type = ItemType.Misc }); + var placements = new List<(uint Item, uint Container, int Placement)>(); + var directPuts = new List<(uint Item, uint Container, int Placement)>(); + using var interaction = new ItemInteractionController( + repo, + playerGuid: () => player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + placeInBackpack: (i, c, p) => placements.Add((i, c, p))); + ToolbarController.Bind( + layout, + repo, + () => Array.Empty(), + iconIds: static (_, _, _, _, _) => 0u, + useItem: static _ => { }, + itemInteraction: interaction, + playerGuid: () => player, + sendPutItemInContainer: (i, c, p) => directPuts.Add((i, c, p))); + var button = (UiButton)layout.FindElement(InventoryButtonId)!; + var payload = new ItemDragPayload(item, ItemDragSource.Ground, 0, new UiItemSlot()); + + Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload))); + Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload))); + + Assert.Empty(placements); + Assert.Equal(new[] { (item, player, 0) }, directPuts); + Assert.False(interaction.TryGetPendingBackpackPlacement(item, out _)); + Assert.True(interaction.TryGetPendingInventoryRequest(out var request)); + Assert.Equal(item, request.ItemId); + } + + [Fact] + public void InventoryButton_ownedDropPublishesDestinationAndGloballyRejectsSecondRequest() + { + const uint player = 0x5000u; + const uint first = 0x70005002u; + const uint second = 0x70005003u; + var (layout, _, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + const uint pack = 0x5004u; + repo.AddOrUpdate(new ClientObject { ObjectId = player, Name = "Player", Type = ItemType.Creature }); + repo.AddOrUpdate(new ClientObject { ObjectId = pack, Name = "Pack", Type = ItemType.Container }); + repo.MoveItem(pack, player, 0); + repo.AddOrUpdate(new ClientObject { ObjectId = first, Name = "First Loot", Type = ItemType.Misc }); + repo.AddOrUpdate(new ClientObject { ObjectId = second, Name = "Second Loot", Type = ItemType.Misc }); + repo.MoveItem(first, pack, 0); + repo.MoveItem(second, pack, 1); + var puts = new List<(uint Item, uint Container, int Placement)>(); + var messages = new List(); + using var interaction = new ItemInteractionController( + repo, + playerGuid: () => player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + systemMessage: messages.Add); + ToolbarController.Bind( + layout, + repo, + () => Array.Empty(), + iconIds: static (_, _, _, _, _) => 0u, + useItem: static _ => { }, + itemInteraction: interaction, + playerGuid: () => player, + sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p))); + var button = (UiButton)layout.FindElement(InventoryButtonId)!; + + foreach (uint item in new[] { first, second }) + { + var payload = new ItemDragPayload(item, ItemDragSource.Inventory, 0, new UiItemSlot()); + button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload)); + button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload)); + } + + Assert.Equal(new[] { (first, player, 0) }, puts); + Assert.True(interaction.TryGetPendingBackpackPlacement(first, out _)); + Assert.True(interaction.TryGetPendingInventoryRequest(out var request)); + Assert.Equal(first, request.ItemId); + Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages); + } + [Fact] public void InventoryButton_shortcutAliasDrop_staysNeutralAndDoesNotMovePhysicalItem() { diff --git a/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs index 3d5b179a..385b6b1c 100644 --- a/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs @@ -72,6 +72,38 @@ public sealed class RetailItemConfirmationControllerTests Assert.Equal(0, items.BusyCount); } + [Fact] + public void PositiveConfirmationRechecksGlobalInventoryGateBeforeUse() + { + var objects = BuildObjects(PublicWeenieFlags.VolatileRare); + var uses = new List(); + var messages = new List(); + var items = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: uses.Add, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + placeInBackpack: static (_, _, _) => { }, + systemMessage: messages.Add, + nowMs: () => 1000L); + var root = new UiRoot { Width = 800f, Height = 600f }; + ImportedLayout? shown = null; + var factory = new RetailDialogFactory(root, _ => + shown = FixtureLoader.LoadConfirmationDialog()); + using var confirmations = new RetailItemConfirmationController(factory, items); + + Assert.True(items.ActivateItem(Rare)); + Assert.True(items.PlaceWorldItemInBackpack(0x70000001u)); + Assert.IsType(shown!.FindElement( + RetailConfirmationDialogView.AcceptButtonId)).OnClick!(); + + Assert.Empty(uses); + Assert.Equal(0, items.BusyCount); + Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages); + } + private static ClientObjectTable BuildObjects(PublicWeenieFlags flags) { var objects = new ClientObjectTable(); diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs index 572f1865..83624df8 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs @@ -85,6 +85,27 @@ public sealed class MoveToManagerCompletionSeamTests Assert.Empty(h.MoveToCompleteCalls); } + [Fact] + public void CancelMoveTo_FiresCancellationCompanionOnlyForAnActiveMove() + { + var h = new MoveToManagerHarness(); + var cancellations = new List(); + h.Manager.MoveToCancelled = cancellations.Add; + + h.Manager.CancelMoveTo(WeenieError.ActionCancelled); + Assert.Empty(cancellations); + + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Manager.MoveToPosition( + new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), + new MovementParameters { UseSpheres = false }); + h.Manager.CancelMoveTo(WeenieError.ObjectGone); + h.Manager.CancelMoveTo(WeenieError.ActionCancelled); + + Assert.Equal(new[] { WeenieError.ObjectGone }, cancellations); + Assert.Empty(h.MoveToCompleteCalls); + } + [Fact] public void StickyCompletion_FiresMoveToComplete_AfterStickToHandoff() { From 9ad8113c79a032e202708a3a8e8e444702913bb6 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 09:04:31 +0200 Subject: [PATCH 18/98] docs(architecture): close selection extraction slice Record the measured GameWindow reduction, final review and test gates, durable selection/inventory ownership rules, and live animation presentation as Slice 2. Co-authored-by: OpenAI Codex --- AGENTS.md | 15 +++++--- CLAUDE.md | 15 +++++--- docs/ISSUES.md | 38 +++++++++++-------- docs/README.md | 7 ++-- docs/architecture/acdream-architecture.md | 5 ++- docs/architecture/code-structure.md | 36 +++++++++++------- docs/plans/2026-04-11-roadmap.md | 14 +++++-- docs/plans/2026-05-12-milestones.md | 7 ++-- ...amewindow-slice-1-selection-interaction.md | 23 ++++++++++- memory/project_ui_architecture.md | 33 ++++++++++++++++ 10 files changed, 141 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ccad370b..ce61024d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,13 +115,16 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 6,481 tests pass / 5 skip (17 tracked warnings, #228). +Release builds; 6,558 tests pass / 5 skip (17 tracked warnings, #228). -**Active prerequisite before new M4 subsystem work:** complete the -behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. The -next slice finishes selection/interaction ownership; later slices finish -animation presentation, live-session and live-entity App integration, -landblock presentation, update/render orchestration, and composition cleanup. +**Active prerequisite before new M4 subsystem work:** continue the +behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. +Selection/interaction Slice 1 is complete: `WorldSelectionQuery` and +`SelectionInteractionController` own the domain, exact-incarnation queued work, +and retail's shared inventory-request transaction. Slice 2 finishes live +animation presentation; later slices finish live-session and live-entity App +integration, landblock presentation, update/render orchestration, and +composition cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/CLAUDE.md b/CLAUDE.md index cd79a560..0ba9129b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,13 +113,16 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 6,481 tests pass / 5 skip (17 tracked warnings, #228). +Release builds; 6,558 tests pass / 5 skip (17 tracked warnings, #228). -**Active prerequisite before new M4 subsystem work:** complete the -behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. The -next slice finishes selection/interaction ownership; later slices finish -animation presentation, live-session and live-entity App integration, -landblock presentation, update/render orchestration, and composition cleanup. +**Active prerequisite before new M4 subsystem work:** continue the +behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. +Selection/interaction Slice 1 is complete: `WorldSelectionQuery` and +`SelectionInteractionController` own the domain, exact-incarnation queued work, +and retail's shared inventory-request transaction. Slice 2 finishes live +animation presentation; later slices finish live-session and live-entity App +integration, landblock presentation, update/render orchestration, and +composition cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 10a8dc8c..b988fa83 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,8 +26,8 @@ What does NOT go here: ## Current queue — 2026-07-21 -- **Active structural work:** `GameWindow` decomposition, beginning with the - selection/interaction ownership slice in +- **Active structural work:** `GameWindow` decomposition. Selection/interaction + ownership is complete; Slice 2 live animation presentation is next in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. - **Separate rendering gate:** `#225`, lifestone/particle alpha ordering. Its @@ -69,7 +69,7 @@ Copy this block when adding a new issue: ## #231 — F-key pickup omits retail pending destination-slot presentation -**Status:** FIXED (2026-07-21) — keyboard pickup uses the shared ItemHolder path +**Status:** DONE (2026-07-21, `52dbb574` + `5acc3f01`) **Severity:** MEDIUM **Filed:** 2026-07-21 **Component:** selection / inventory / retained UI @@ -86,9 +86,9 @@ always sends `CM_Item::SendNotice_ShowPendingInPlayer` before `ItemInteractionController` and raises `PendingBackpackPlacementRequested`; the F-key switch case bypasses that owner. -**Files:** `src/AcDream.App/Rendering/GameWindow.cs`; -`src/AcDream.App/UI/ItemInteractionController.cs`; -future `src/AcDream.App/Interaction/SelectionInteractionController.cs`. +**Files:** `src/AcDream.App/UI/ItemInteractionController.cs`; +`src/AcDream.App/Interaction/SelectionInteractionController.cs`; +`src/AcDream.App/UI/Layout/InventoryController.cs`. **Research:** [`docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md`](plans/2026-07-21-gamewindow-slice-1-selection-interaction.md). @@ -99,15 +99,20 @@ Server confirmation settles it; server failure removes it without speculative ownership mutation. **Resolution:** `ItemInteractionController.PlaceWorldItemInBackpack` now owns -the shared pending-placement notification and request dispatch. Both -double-click activation and the F-key route enter that method, so they publish -the same destination container and placement before the server request. +the exact-token pending destination projection. The F-key route reserves it +before immediate or deferred MoveTo transport, promotes that same reservation +only when the packet is actually sent, and withdraws it on cancel/lifetime +loss. The review correction also ported the single global +`ACCWeenieObject::prevRequest` owner across inventory, external-container, +toolbar, paperdoll, give, merge, split, and drop routes. Optimistic placement, +server rollback, and authoritative response notifications are distinct; a +response clears global and local state atomically before reentrant observers. --- ## #230 — Selection hits and deferred actions can cross live GUID incarnations -**Status:** FIXED (2026-07-21) — incarnation identity is carried and revalidated +**Status:** DONE (2026-07-21, `047a4c83` + `5acc3f01`) **Severity:** HIGH **Filed:** 2026-07-21 **Component:** selection / live entity lifetime / interaction @@ -127,9 +132,9 @@ pending action. `CombatTargetController` also treats SessionReset like an ordinary clear and can re-acquire before live records drain. **Files:** `src/AcDream.Core/Selection/RetailSelectionMesh.cs`; -`src/AcDream.App/Rendering/Selection/`; -`src/AcDream.App/Rendering/GameWindow.cs`; -`src/AcDream.App/Combat/CombatTargetController.cs`. +`src/AcDream.App/Interaction/WorldSelectionQuery.cs`; +`src/AcDream.App/Interaction/SelectionInteractionController.cs`; +`src/AcDream.App/Input/OutboundInteractionQueue.cs`. **Research:** [`docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md`](plans/2026-07-21-gamewindow-slice-1-selection-interaction.md). @@ -144,7 +149,10 @@ complete interaction lifetime and never auto-targets. current interaction-visible record. Deferred actions capture the same identity and teardown clears the captured action even after GUID replacement. Session reset now clears published selection geometry, lighting, ItemHolder target and -throttle state, and cannot trigger combat auto-target acquisition. +throttle state, and cannot trigger combat auto-target acquisition. Queued +input also captures the exact `ClientObject` reference, deferred movement is a +cancel-before-arm transaction, and session queue epochs prevent pre-reset work +from crossing into a new world. --- @@ -179,7 +187,7 @@ production runtime failure. **Acceptance:** A non-incremental Release solution build reports zero warnings and zero errors without suppressing analyzers or weakening nullable checking; -the full 6,481-pass / 5-skip suite remains green. +the full 6,558-pass / 5-skip suite remains green. --- diff --git a/docs/README.md b/docs/README.md index 049cac76..58380370 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,8 +10,9 @@ banner cannot silently override the current program state. world,” is next. - **Active program:** behavior-preserving `GameWindow` decomposition before new M4 subsystem work. The reconciled ownership ledger and slice order are - in [`architecture/code-structure.md`](architecture/code-structure.md); the - next slice finishes selection/interaction ownership. + in [`architecture/code-structure.md`](architecture/code-structure.md). + Selection/interaction Slice 1 is complete; live animation presentation is + next. - **Completed gates:** R6 locomotion/collision/projectile/teleport/radar and the final two-client portal-out/materialization observer comparison (`#218`). - **Separate visual verification:** the shared-alpha lifestone/particle result @@ -23,7 +24,7 @@ banner cannot silently override the current program state. - **Divergence audit:** 178 active rows — IA 17, AD 37, AP 85, TS 34, and UN 5 — plus the retained retired TS-37 history note. - **Latest automated baseline:** Release build succeeds with 17 known - test-project warnings (`#228`); 6,481 tests passed and 5 intentionally + test-project warnings (`#228`); 6,558 tests passed and 5 intentionally skipped. The unattended connected R6 route completed seven portal materializations, production input exercises, and a graceful close. See diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4c3b6449..97e0556f 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -833,7 +833,10 @@ Current movement/collision ownership: two-client observer gate; Track LH is the future presentation-independent Linux/headless extraction. - **The behavior-preserving thin-`GameWindow` ownership campaign is the active - prerequisite before new M4 subsystem bodies.** Its reconciled ledger and + prerequisite before new M4 subsystem bodies.** Selection/interaction Slice 1 + landed 2026-07-21 with `WorldSelectionQuery`, + `SelectionInteractionController`, and the shared retail inventory-request + owner; live animation presentation is next. The reconciled ledger and sequence live in `docs/architecture/code-structure.md`. This decision does not reclassify the remaining L.2 conformance work or Track LH. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 5f1d379f..633ea2b4 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -1,8 +1,8 @@ # acdream — code structure & extraction sequence **Status:** Living document. Created 2026-05-16; implementation reconciliation -completed 2026-07-21. This is the active structural program before new M4 -subsystems enter the App layer. +completed 2026-07-21; Slice 1 landed the same day. This is the active +structural program before new M4 subsystems enter the App layer. **Purpose:** Describe the desired structural state of the App layer, explain the rules we've adopted, and lay out the safe extraction sequence from today's reality (one 15,723-line `GameWindow.cs` at the @@ -22,8 +22,8 @@ layer is wire-compatible, the UI has a stable contract, plugins load. The structural debt is concentrated in **one file**: ``` -src/AcDream.App/Rendering/GameWindow.cs 15,723 lines (2026-07-21) - 258 fields / ~259 methods +baseline cf50ee3d 15,723 lines / 278 fields / 205 methods +after Slice 1 14,912 lines / 278 fields / 191 methods ``` `GameWindow` is the single object that: @@ -35,9 +35,9 @@ src/AcDream.App/Rendering/GameWindow.cs 15,723 lines (2026-07-21) `LiveSessionController` owns the socket/session shell. - Owns the adapters that hydrate canonical `LiveEntityRuntime` records into animation, collision, rendering, and DAT-backed appearance resources. -- Still drives selection / interaction (`WorldPicker`, target classification, - `SendUse`, `SendPickUp`, auto-walk deferral) despite the shipped item and - combat controllers. +- Composes the shipped `WorldSelectionQuery` and + `SelectionInteractionController`; it no longer owns world-picking, + selection intent, Use/PickUp, or auto-walk deferral algorithms. - Drives per-frame render orchestration (sky → terrain → opaque mesh → transparent mesh → particles → debug lines → UI). - Builds and applies streamed landblock presentation (DAT decode, scenery, @@ -391,7 +391,7 @@ useful ordering seam, but its ownership status is **partial**. | Inbound/object-frame order | **Partial** | `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveEntityAnimationScheduler`, static scheduler, remote/projectile updaters, and teleport owners are shipped. `AdvanceLiveObjectRuntimeCore`, final part/mesh/effect composition, and several integration tails remain in `GameWindow`. | | World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. | | Retained gameplay UI | **Mostly complete feature ownership** | `RetailUiRuntime` and focused panel/controllers own layout and behavior. `GameWindow.OnLoad` still performs substantial service composition, which is allowed until the final composition cleanup. | -| Selection/interaction | **Partial** | `ItemInteractionController`, `CombatTargetController`, and `CombatAttackController` own focused rules. `GameWindow` still owns picking, target queries, selection intent, Use/PickUp sends, range/auto-walk deferral, and related input cases. | +| Selection/interaction | **Complete** | `WorldSelectionQuery` owns read-only picking/classification/range queries; `SelectionInteractionController` owns selection intent, Use/PickUp transport, exact-incarnation queues, and auto-walk deferral; `ItemInteractionController` owns ItemHolder policy plus the shared retail inventory-request transaction. `GameWindow` retains construction and narrow lifecycle forwarding only. | | Landblock presentation | **Not extracted** | Residency scheduling is extracted, but DAT build, scenery/EnvCell construction, apply, collision/resource publication, and retirement glue remain a large `GameWindow` body. | | Render-frame orchestration | **Not extracted** | `OnRender` plus its portal/PView/alpha/particle helpers still own the draw graph and frame-local scratch state. There is no `RenderFrameOrchestrator` yet. | | Unified `GameEntity` | **Deferred target** | `LiveEntityRuntime` already supplies the critical canonical owner. Full type aggregation is a separate high-risk migration and is not required to make `GameWindow` thin. | @@ -409,7 +409,7 @@ two-client portal observer gate form the pre-refactor baseline. Each later slice runs the subset capable of detecting its risk; render/session slices run the complete connected lifecycle gate. -#### Slice 1 — finish selection/interaction ownership — NEXT +#### Slice 1 — finish selection/interaction ownership — COMPLETE 2026-07-21 Detailed execution plan: [`docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md`](../plans/2026-07-21-gamewindow-slice-1-selection-interaction.md). @@ -429,11 +429,21 @@ Split the old Step 4 into three reviewable commits: Tests cover read-only query classification separately from stateful intents, including direct use, distant auto-walk completion, corpse/container opening, pickup placement, hostile-only targeting, Hidden objects, and same-GUID reuse. -The connected gate is select → use door/NPC → distant corpse → loot/pickup → -combat target. This is the safest next slice because its outbound boundaries -are typed and existing controllers already hold most retail rules. +The cutover landed in `047a4c83`, `52dbb574`, `e74f2ca9`, `fa8d5232`, +`d2bb5af4`, and review-correction commit `5acc3f01`. The final review also +ported the shared `ACCWeenieObject::prevRequest` transaction shape across +inventory, toolbar, paperdoll, external-container, give, split, merge, and +drop routes. Queued actions, pending placements, and responses are bound to +exact object incarnations/tokens; optimistic projection, rollback projection, +and authoritative response notices are distinct. -#### Slice 2 — finish live animation presentation +Measured against `cf50ee3d`, `GameWindow` fell from 15,723 to 14,912 lines and +from 205 to 191 methods; field count stayed at 278 because the slice exchanged +legacy state for composed owners. Three independent retail, architecture, and +adversarial review loops finished clean. Release build passed; 6,558 tests +passed and five fixture/conformance tests skipped intentionally. + +#### Slice 2 — finish live animation presentation — NEXT Move `TickAnimations`, final rigid/visual part composition, effect-pose publication, and motion-done binding into `LiveEntityAnimationPresenter`. diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 2b122386..8c5ca825 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -16,9 +16,15 @@ orchestration. **Current sequence:** The implementation audit, exact ownership ledger, slice boundaries, tests, connected gates, and exit criteria live in [`docs/architecture/code-structure.md`](../architecture/code-structure.md) §4. -The next slice is selection/interaction: extract the read-only world query, -move stateful Use/PickUp/auto-walk intent into one controller, then cut the -related input cases over and delete the old `GameWindow` bodies. +Slice 1 selection/interaction landed 2026-07-21: the read-only world query and +stateful intent owner are extracted, exact-incarnation lifetime is enforced, +and retail's global inventory-request transaction is shared across every UI +mutation surface. `GameWindow` is 811 lines and 14 methods smaller. + +**Next:** Slice 2 finishes live animation presentation by moving final +part/mesh/effect-pose composition and motion-done binding behind +`LiveEntityAnimationPresenter`, while preserving the shipped scheduler and +retail object-frame order. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 @@ -1473,7 +1479,7 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen. ## Cross-cutting work tracked in parallel -- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,481 passed / 5 intentional skips (2026-07-21). +- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,558 passed / 5 intentional skips (2026-07-21). - **Memory files.** Live subsystem state and digests are indexed by `claude-memory/MEMORY.md`; stable engineering references remain under `memory/`. Canonical program status stays in milestones/roadmap/issues. - **`CLAUDE.md` discipline.** Check all four references (ACE, ACViewer, WorldBuilder, Chorizite) before committing to an approach. WorldBuilder is the closest stack match and should be checked first. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 1819bd27..ac36e633 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -16,14 +16,15 @@ Before new M4 quest/emote/character-creation subsystem bodies enter the App layer, the active structural prerequisite is the behavior-preserving `GameWindow` decomposition in [`docs/architecture/code-structure.md`](../architecture/code-structure.md). -The 2026-07-21 audit found 15,723 lines, 258 fields, and about 259 methods; -the next slice finishes selection/interaction ownership. +The 2026-07-21 audit baseline was 15,723 lines, 278 fields, and 205 methods. +Selection/interaction Slice 1 is complete at 14,912 lines, 278 fields, and 191 +methods. Slice 2 — final live animation presentation ownership — is next. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus registered TS-50/TS-51 timing residuals (the complete-root-Frame/object-workset cutover is automated- and visual-gate complete; Release builds with 17 known -test-project warnings tracked by #228, and 6,481 tests pass / 5 skip), and +test-project warnings tracked by #228, and 6,558 tests pass / 5 skip), and the deferred Modern Pipeline track (MP1b+). The separate #225 shared-alpha implementation has passed connected lifetime/performance gates and awaits only its lifestone/particle visual comparison. diff --git a/docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md b/docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md index 8a4629da..a61e1b58 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md +++ b/docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md @@ -1,12 +1,33 @@ # GameWindow Slice 1 — selection and interaction ownership -**Status:** Approved for execution 2026-07-21. +**Status:** COMPLETE — landed 2026-07-21. **Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 1. **Baseline:** `cf50ee3d`; `GameWindow.cs` is 15,723 lines before this slice. **Behavior rule:** Existing retail behavior moves unchanged. Any defect found by the extraction audit is fixed in a separately identified commit before the mechanical cutover. +## Landing record + +- Plan/oracle: `c2713837`. +- Exact-incarnation lifetime hardening: `047a4c83`. +- Retail pending keyboard-pickup presentation: `52dbb574`. +- Read-only query extraction: `e74f2ca9`. +- Stateful interaction owner: `fa8d5232`. +- `GameWindow` cutover: `d2bb5af4`. +- Three-review correction pass: `5acc3f01`. + +The correction pass unified retail's one-inventory-request-at-a-time owner +across every mutation surface, separated optimistic/rollback/authoritative +placement notices, made response completion atomic and reentrancy-safe, and +bound pending work to exact object identities and placement tokens. + +Final metrics: `GameWindow.cs` 15,723 → 14,912 lines and 205 → 191 methods; +fields remained 278. Release build passed, the focused selection/inventory +gate passed, the full suite passed 6,558 tests with five intentional skips, +and the retail-conformance, architecture/integration, and adversarial reviews +all finished with no actionable findings. + ## 1. Outcome `GameWindow` stops owning world selection and selection-driven interaction. diff --git a/memory/project_ui_architecture.md b/memory/project_ui_architecture.md index 3a575105..85879794 100644 --- a/memory/project_ui_architecture.md +++ b/memory/project_ui_architecture.md @@ -213,3 +213,36 @@ plus optional stack size, palette, and shade. Healing kit WCIDs are 628 crude, 629 plain, 630 good, 631 excellent, and 632 peerless. The `+Acdream` developer character can therefore create the best kit with either `/ci 632` or `@ci 632`; both forms must produce canonical `@ci 632` Talk text. + +## 2026-07-21 selection extraction and inventory transaction ownership + +`WorldSelectionQuery` is the read-only App boundary over canonical live state, +the completed render-selection frame, object metadata, camera/cursor inputs, +DAT selection bounds, hostile lookup, and Use/PickUp range classification. It +never mutates `SelectionState`, starts movement, or sends packets. + +`SelectionInteractionController` is the corresponding intent owner. It owns +world-click routing, closest/previous selection, the outbound frame-boundary +queue, Use/PickUp transport, and MoveTo deferral. Queued work captures both the +live local entity ID and exact `ClientObject` reference where available; +deferred movement uses cancel-before-arm ordering and a queue epoch prevents +pre-reset work from crossing session lifetime. + +`ItemInteractionController` owns the one global inventory transaction, shaped +after retail `ACCWeenieObject::prevRequest`. Inventory, external-container, +toolbar, paperdoll, give, merge, split, drop, and deferred keyboard pickup all +acquire the same owner. A destination waiting projection carries an exact token +and item identity. Keyboard pickup may remain reserved-but-undispatched while +MoveTo runs, then promotes that exact token only when the wire request is sent. + +`ClientObjectMoveOrigin` distinguishes immediate optimistic repaint, server +rollback repaint, and authoritative response. Only authoritative move/failure/ +remove/stack responses complete the transaction. Completion clears the global +owner and local waiting projection atomically, publishes the old token-specific +projection resolution, then advertises readiness. This order is essential: +reentrant observers may start the next request without the old response tail +rejecting, hiding, or consuming it. Do not regress to GUID-only cleanup or let +ordinary `ObjectUpdated` events complete inventory work. + +`GameWindow` now constructs and forwards narrow lifecycle edges to these +owners; no selection algorithm or interaction request body belongs there. From 9760ff5a8d597eadad52df77211816f0b3762b3f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 09:30:04 +0200 Subject: [PATCH 19/98] docs(animation): plan GameWindow live presentation slice Define the exact extraction boundary, incarnation-safe handoff, retail trailing-part correction, and review gates before moving the final PartArray presentation path out of GameWindow. Co-Authored-By: OpenAI Codex --- ...dow-slice-2-live-animation-presentation.md | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 docs/plans/2026-07-21-gamewindow-slice-2-live-animation-presentation.md diff --git a/docs/plans/2026-07-21-gamewindow-slice-2-live-animation-presentation.md b/docs/plans/2026-07-21-gamewindow-slice-2-live-animation-presentation.md new file mode 100644 index 00000000..f7cbac56 --- /dev/null +++ b/docs/plans/2026-07-21-gamewindow-slice-2-live-animation-presentation.md @@ -0,0 +1,381 @@ +# GameWindow Slice 2 — live animation presentation ownership + +**Status:** Approved for execution 2026-07-21. +**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 2. +**Baseline:** `9ad8113c`; `GameWindow.cs` is 14,912 lines, 278 fields, and +191 methods before this slice. +**Behavior rule:** This is an ownership extraction over the accepted R6 object +frame plus one named-retail conformance correction: short AnimFrames retain +the prior/rest pose of trailing CPartArray parts. It must not otherwise change +sequence time, root motion, collision, manager order, hook semantics, part +transforms, render eligibility, or effect attachment. + +## 1. Outcome + +`GameWindow` stops owning final animated-part presentation. A focused +`LiveEntityAnimationPresenter` consumes the already-completed scheduler output +and publishes the exact same draw and effect state: + +- final drawable `MeshRef` transforms; +- indexed rigid part poses and availability; +- `EntityEffectPoseRegistry` publication; +- live static-animation frame handoff; +- legacy animation interpolation state; +- one-time `MotionDone` binding; +- presentation-specific sequence/part diagnostics. + +The shipped owners remain unchanged: + +- `LiveEntityAnimationScheduler` owns ordinary-object clocks, PartArray + advance, root motion, one movement owner, control-hook capture, and the + retail manager tail; +- `RetailStaticAnimatingObjectScheduler` owns the separate retail + `static_animating_objects` time/workset and its deferred process-hooks tail; +- `EquippedChildRenderController` owns equipped-child composition; +- `AnimationHookFrameQueue` owns incarnation-scoped semantic/presentation-hook + delivery; +- `LiveEntityRuntime` remains the only identity, incarnation, component, and + spatial-lifetime owner. + +`GameWindow.AdvanceLiveObjectRuntimeCore` retains only the explicit frame-order +calls between these owners. + +## 2. Retail and project oracles + +No new AC algorithm is introduced. The extraction is pinned to: + +- [`2026-07-19-r6-update-object-order-pseudocode.md`](../research/2026-07-19-r6-update-object-order-pseudocode.md) + — `UpdatePositionInternal`, `process_hooks`, and manager-tail ordering. +- [`2026-07-19-r6-complete-root-frame-pseudocode.md`](../research/2026-07-19-r6-complete-root-frame-pseudocode.md) + — complete root `Frame`, ordinary/static worksets, part/effect publication. +- [`memory/project_animation_runtime.md`](../../memory/project_animation_runtime.md) + — appearance-update and description-before-enter-world lifetime invariants. +- `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`. +- `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`. +- `CPhysicsObj::process_hooks @ 0x00511550`. +- `CPhysicsObj::animate_static_object @ 0x00513DF0`. +- `CPartArray::Update @ 0x00517DB0`. +- `CPartArray::UpdateParts @ 0x005190F0`. +- `CPartArray::AnimationDone @ 0x00517D30`. +- `CPhysicsObj::MotionDone @ 0x0050FDB0`. +- `Frame::combine @ 0x005122E0`. + +ACE/DatReaderWriter and the installed retail DAT remain secondary fixtures for +motion-table/animation schema interpretation. Named retail is authoritative +for ordering and transform semantics. + +## 3. Fixed frame-order contract + +One ordinary live-object frame remains: + +```text +local player pre-network object phase +selection outbound boundary +publish PhysicsScript clock +LiveEntityAnimationScheduler.Tick +RetailStaticAnimatingObjectScheduler.Tick +LiveEntityAnimationPresenter.Present +EquippedChildRenderController.Tick +RetailStaticAnimatingObjectScheduler.ProcessHooks +translucency advance +AnimationHookFrameQueue.Drain +effect-root refresh +attached-emitter refresh +live-light refresh +particle visibility +particle simulation +PhysicsScript simulation +``` + +The presenter advances no clock and drains no hook. It only consumes poses +produced earlier in the same frame. Static `process_hooks` remains after live +parts and equipped children, as retail's `animate_static_object` requires. + +## 4. Ownership and interfaces + +### 4.1 Animation component state + +Move `GameWindow.AnimatedEntity` and `GameWindow.AnimatedPartTemplate` to +top-level App rendering types: + +```text +LiveEntityAnimationState +LiveAnimationPartTemplate +``` + +`LiveEntityAnimationState` remains the exact component stored in +`LiveEntityRecord.AnimationRuntime`. It owns no GUID map and no lifetime. It +retains: + +- the exact `WorldEntity`, Setup, Animation, and optional sequencer; +- legacy low/high/current frame and framerate; +- ObjScale, part template, and part availability; +- prepared local-player pose and complete root-frame scratch; +- reusable `MeshRefs`, effect-part-pose, and diagnostics scratch. + +Appearance updates mutate this same component and preserve sequencer/playback +state. Logical replacement creates a new component. + +### 4.2 Presentation context + +Define a narrow App interface equivalent to: + +```csharp +interface ILiveAnimationPresentationContext +{ + uint LocalPlayerGuid { get; } + MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record); +} +``` + +`GameWindow` implements only these constant-time lookups. The presenter cannot +reach windowing, GL, networking, input, streaming, physics, or arbitrary +`GameWindow` methods. + +### 4.3 Presenter + +Use an API equivalent to: + +```csharp +void Present( + IReadOnlyDictionary schedules); + +void PrepareAnimation( + LiveEntityRecord record, + LiveEntityAnimationState state); +``` + +The presenter receives: + +- canonical `LiveEntityRuntime` provider for exact-record validation; +- `EntityEffectPoseRegistry`; +- `ILiveAnimationPresentationContext`; +- a late-bound `ILiveStaticPartFrameSource` (the static scheduler is created + after the presenter); +- startup-resolved hidden-part diagnostic index; +- typed animation diagnostic flags. + +The presenter owns its own reusable `LiveEntityRecord` snapshot. It does not +borrow `LiveEntityAnimationRuntimeView`'s reusable enumerator snapshot across +the synchronous `EffectPoseChanged` callback boundary. Nested enumeration by a +subscriber therefore cannot invalidate the outer presentation pass. + +Each schedule carries the exact `LiveEntityRecord`, `WorldEntity`, animation +component, `ObjectClockEpoch`, and projection-mutation version which produced +it. The presenter validates all of those identities before consuming ordinary +or static frames and again before each externally observable publication. A +dictionary key/local ID is an index only; it is never proof of incarnation. +The static prepared-frame source is queried late, only after this validation. +Its take operation receives the exact record, entity, animation component, +object-clock epoch, and projection/residency versions; it validates those +tokens before clearing and returning prepared frames. + +It keeps no GUID map, no animation clock, no packet/session reference, and no +render-backend object. + +### 4.4 Diagnostics + +Move the surviving `SEQSTATE`, `CURRNODE`, `PARTSDIAG`, and `MOTIONDONE` gates +behind one typed animation diagnostic owner. Environment variables are read +once; the presenter does not perform per-frame environment lookups. Throttle +state follows the exact animation component lifetime instead of living on the +remote-motion component. + +## 5. Transform invariants + +For every part index, preserve these two deliberately different transforms. + +### Drawable visual transform + +```text +Setup DefaultScale +× animated part orientation +× animated part translation +× entity ObjScale +``` + +This is the existing renderer/hydration contract. It scales geometry and the +part's translated placement. + +### Rigid effect/attachment transform + +```text +animated part orientation +× translation(animated origin × entity ObjScale) +``` + +Retail `CPartArray::UpdateParts` scales the part-frame origin only. Setup +DefaultScale is `gfxobj_scale`, not rigid part-frame state, so particles, +lights, attachments, and multipart collision must never inherit it. + +Additional invariants: + +- Sequencer frames win when present. Retail updates only + `min(CPartArray.num_parts, AnimFrame.num_parts)`; missing trailing frames + retain both their previous drawable visual transform and their previous + indexed rigid/effect pose (the two distinct initial rest transforms on first + presentation) instead of snapping either channel to identity. +- Legacy animation interpolates origin linearly and orientation spherically, + clamps interpolation to `[0,1]`, and wraps over the inclusive frame span. +- Live static prepared frames override the ordinary schedule for that owner. +- `ComposeParts == false` changes no mesh or indexed pose. +- Non-drawable/debug-hidden parts remain in indexed effect poses but not + drawable `MeshRef`s. +- `PartAvailability` is published unchanged and controls effect part lookup. +- Mesh identity, surface overrides, and list reuse remain unchanged. +- Entity mesh/indexed-part state is installed before effect-pose publication. + +## 6. MotionDone invariants + +- Bind before any sequencer can complete; repeated calls are idempotent. +- The scheduler validates the exact owner before invoking `PrepareAnimation`. +- Resolve the player/remote `MotionInterpreter` at callback time because that + consumer may be created after the animation component. +- Never capture a legitimate null interpreter permanently. +- Never complete a motion from pose publication; `AnimationHookFrameQueue` + remains the semantic `AnimationDone` boundary. +- Logical removal/replacement stops the old component from advancing; no + presenter-owned callback queue survives it. +- The callback revalidates the stable captured record, entity, and animation + component before resolving a same-GUID interpreter. It deliberately does + not capture object-clock/projection epochs: the same retail CPartArray and + completion consumer survive legitimate rebucketing and leave/re-enter. + GUID reuse still cannot deliver an old completion to a replacement. +- `MOTIONDONE` diagnostics observe but never alter queue state. + +## 7. Execution sequence + +### Commit 0 — plan and baseline + +- Land this plan. +- Record exact baseline metrics and current frame order. +- Register no new divergence; this slice moves an accepted mechanism. + +### Commit 1 — extract component types and pure presenter + +- Move animation component/template types out of `GameWindow`. +- Add the presentation context and typed diagnostics owner. +- Implement `LiveEntityAnimationPresenter` by moving the current composition + body line-for-line, retaining the baseline trailing-part fallback initially. +- Add focused tests before runtime cutover. + +### Commit 2 — cut over runtime ownership + +- Construct the presenter beside the scheduler. +- Route scheduler preparation, local-player pre-advance, spawn-time binding, + and final presentation through it. +- Delete `TickAnimations`, `EnsureMotionDoneBinding`, nested animation types, + and presentation-only remote diagnostic fields from `GameWindow`. +- Keep child/static-hook/effect ordering visible in + `AdvanceLiveObjectRuntimeCore`. + +### Commit 3 — retail trailing-part conformance + +- Record the `CPartArray::UpdateParts` minimum-count/retain behavior in the + R6 pseudocode note. +- Preserve both visual and rigid prior/rest trailing transforms. +- Add first-presentation and later-shorter-frame conformance tests plus + ordinary/live-static/DAT-static parity coverage. +- Update the divergence register in this commit if the audited register + contains a corresponding row; introduce no silent behavior delta. + +### Commit 4 — review, gate, and ledger close + +- Run retail-conformance, architecture/integration, and adversarial read-only + reviews against the complete slice. +- Fix every confirmed finding and repeat review until clean. +- Run focused animation, scheduler, static, effect, projectile, remote-motion, + and local-player tests. +- Run Release solution build and full Release suite. +- Record new `GameWindow` line/field/method counts. +- Update code structure, roadmap, milestones, issues if any, durable animation + memory, `AGENTS.md`, and `CLAUDE.md`. + +## 8. Automated acceptance matrix + +### Composition + +- Sequencer pose composes every part and preserves surface overrides. +- Missing sequencer trailing parts retain their previous/rest indexed pose. +- Legacy current/next frame interpolation and inclusive wrapping are exact. +- Legacy hidden/single-frame owners compose only when scheduled. +- Static prepared frames override ordinary frames exactly once. +- `ComposeParts == false` preserves prior mesh/pose state. + +### Visual versus rigid transforms + +- Setup nonuniform DefaultScale affects drawable geometry only. +- ObjScale affects drawable geometry and translated placement. +- Rigid effect poses scale only the translated origin. +- Nondrawable and debug-hidden parts remain available to effects. +- Unavailable parts remain indexed but fail effect-part lookup. + +### Lifetime and ordering + +- A removed/replaced animation component is skipped by the runtime-view + snapshot and cannot publish into its replacement. +- A schedule prepared for incarnation A is rejected if A is withdrawn, + replaced, or reprojected before presentation, including same-local-ID ABA. +- Reentrant deletion/replacement during pose publication prevents every later + write for the stale owner; no borrowed scratch buffer crosses a callback. +- Effect-pose publication sees the entity's already-installed indexed parts. +- Same-frame animation hooks drain only after current parts and children. +- Static process-hooks runs only after static parts and children are current. +- Presentation allocates no per-frame mesh/effect lists in steady state. + +### Motion completion + +- Binding is idempotent. +- A consumer created after binding receives completion. +- Player and remote resolution select the correct interpreter. +- Missing consumer is a no-op. +- Diagnostics do not duplicate `MotionDone`. +- A legitimate rebucket/object-clock rebase preserves the binding. +- Delete plus GUID/local-ID reuse rejects completion from the old component. + +### Cross-owner order and reconciliation + +- One instrumented object-frame test pins presenter, children, static hooks, + fades, ordinary hook drain, effect roots, emitters, lights, particles, and + scripts. A PES-created particle first simulates on the following frame. +- Authoritative spatial reconciliation performs only root/effect refresh, + child composition, emitter refresh, and light refresh; it advances no + animation, hook, fade, particle, script, or object clock. +- Appearance shrink from three parts to one preserves playback while removing + stale MeshRefs, indexed poses, availability, and effect-registry entries. +- Ordinary, live-static handoff, and DAT-static composition share conformance + fixtures for scale, sparse availability, and surface overrides. +- An equipped child consumes the just-published parent indexed pose in the + same frame, including ObjScale and nested attachment composition. +- An animated projectile's trail/light anchor observes the same-quantum root + and part pose, never the previous frame. + +`EquippedChildRenderController` callback-mutation hardening is an adjacent +Slice-6 frame-orchestration concern. Slice 2 does not claim it: the known +reentrancy case is recorded as a follow-up unless a focused test demonstrates +the existing traversal is already safe. +- A nested animation-runtime enumeration triggered by `EffectPoseChanged` + cannot overwrite or truncate the presenter's private outer snapshot. + +## 9. Connected gate + +Run one normal capped Release client and verify behavior remains unchanged: + +1. local idle, walk, run, strafe, turn, jump, combat, and stop transitions; +2. observe a remote player walk/run/turn without pose stalls or blips; +3. open/close a door and kill a creature; static/reactive final poses persist; +4. equip/unequip armor and switch melee/bow/wand without animation loss; +5. fire an arrow and cast a projectile spell; trails remain attached to the + current missile/hand pose; +6. cast a protection and recall/portal; body effects follow current parts; +7. portal/relog and confirm no old pose or completion reaches a replacement. + +Any failure returns to the owning commit. No fallback pose cache, delayed +retry, grace period, or GUID-only suppression is acceptable. + +## 10. Subagent policy + +One primary agent performs all edits. Read-only agents independently audit +retail conformance, architecture/lifetime, and adversarial tests before the +plan and after every implementation correction. No subagent edits the shared +slice concurrently. From f004ac5562b068d96c9797abb3727df3ac3631b7 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 09:37:02 +0200 Subject: [PATCH 20/98] refactor(animation): extract live PartArray presenter Move final live part composition, exact-incarnation schedule handoff, MotionDone binding, and presentation diagnostics out of GameWindow while preserving the retail frame order. Reject stale schedule and completion ABA at the new owner boundary. Co-Authored-By: OpenAI Codex --- .../Physics/RemotePhysicsUpdater.cs | 16 +- .../AnimationPresentationDiagnostics.cs | 21 + src/AcDream.App/Rendering/GameWindow.cs | 445 ++---------------- .../ILiveAnimationPresentationContext.cs | 10 + .../Rendering/ILiveStaticPartFrameSource.cs | 21 + .../Rendering/LiveEntityAnimationPresenter.cs | 403 ++++++++++++++++ .../Rendering/LiveEntityAnimationScheduler.cs | 43 +- .../Rendering/LiveEntityAnimationState.cs | 63 +++ .../RetailStaticAnimatingObjectScheduler.cs | 42 +- .../Physics/RemotePhysicsUpdaterTests.cs | 12 +- .../Rendering/LiveAppearanceAnimationTests.cs | 8 +- .../LiveEntityAnimationPresenterTests.cs | 295 ++++++++++++ .../LiveEntityAnimationSchedulerTests.cs | 12 +- ...tailStaticAnimatingObjectSchedulerTests.cs | 10 +- 14 files changed, 963 insertions(+), 438 deletions(-) create mode 100644 src/AcDream.App/Rendering/AnimationPresentationDiagnostics.cs create mode 100644 src/AcDream.App/Rendering/ILiveAnimationPresentationContext.cs create mode 100644 src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs create mode 100644 src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs create mode 100644 src/AcDream.App/Rendering/LiveEntityAnimationState.cs create mode 100644 tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index 4a4078e0..4c6b039b 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -1,5 +1,5 @@ using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion; -using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity; +using LiveEntityAnimationState = AcDream.App.Rendering.LiveEntityAnimationState; namespace AcDream.App.Physics; @@ -40,13 +40,13 @@ internal sealed class RemotePhysicsUpdater private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine; private readonly System.Func _getSetupCylinder; - private readonly System.Action _applyServerControlledVelocityCycle; + private readonly System.Action _applyServerControlledVelocityCycle; private readonly List _spatialRemoteSnapshot = new(); internal RemotePhysicsUpdater( AcDream.Core.Physics.PhysicsEngine physicsEngine, System.Func getSetupCylinder, - System.Action applyServerControlledVelocityCycle) + System.Action applyServerControlledVelocityCycle) { _physicsEngine = physicsEngine; _getSetupCylinder = getSetupCylinder; @@ -58,7 +58,7 @@ internal sealed class RemotePhysicsUpdater /// /// Advances the retained PositionManager path for every hidden remote, - /// including objects without an AnimatedEntity (missiles commonly + /// including objects without an LiveEntityAnimationState (missiles commonly /// have no PartArray). Retail gates this path on the live CPhysicsObj, not /// on whether a render-animation owner exists. /// @@ -93,7 +93,7 @@ internal sealed class RemotePhysicsUpdater || record.WorldEntity is not { } entity) continue; - AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity; + LiveEntityAnimationState? animation = record.AnimationRuntime as LiveEntityAnimationState; AcDream.Core.Physics.RetailObjectActivityResult activity = AcDream.Core.Physics.RetailObjectActivityGate.Evaluate( record.ObjectClock, @@ -165,7 +165,7 @@ internal sealed class RemotePhysicsUpdater /// public bool Tick( RemoteMotion rm, - AnimatedEntity ae, + LiveEntityAnimationState ae, float dt, AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame, int liveCenterX, @@ -188,14 +188,14 @@ internal sealed class RemotePhysicsUpdater /// Canonical ordinary-object tick. Render animation is optional: retail /// walks CPhysics::object_maint, so a live object with a /// MovementManager or PositionManager must continue even when it has no - /// AnimatedEntity presentation component. + /// LiveEntityAnimationState presentation component. /// public bool Tick( RemoteMotion rm, AcDream.Core.World.WorldEntity entity, float objectScale, AcDream.Core.Physics.AnimationSequencer? sequencer, - AnimatedEntity? animationForVelocityCycle, + LiveEntityAnimationState? animationForVelocityCycle, float dt, AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame, int liveCenterX, diff --git a/src/AcDream.App/Rendering/AnimationPresentationDiagnostics.cs b/src/AcDream.App/Rendering/AnimationPresentationDiagnostics.cs new file mode 100644 index 00000000..bf947aa4 --- /dev/null +++ b/src/AcDream.App/Rendering/AnimationPresentationDiagnostics.cs @@ -0,0 +1,21 @@ +namespace AcDream.App.Rendering; + +/// Startup-resolved diagnostic gates for final PartArray presentation. +internal sealed record AnimationPresentationDiagnostics( + bool RemoteVelocityEnabled, + bool DumpMotionEnabled, + Func Now) +{ + public static AnimationPresentationDiagnostics FromEnvironment() => new( + RemoteVelocityEnabled: + Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1", + DumpMotionEnabled: + Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1", + Now: static () => + (DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds); + + public static AnimationPresentationDiagnostics Disabled { get; } = new( + false, + false, + static () => 0d); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index cef7eb2e..4efb9c41 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -9,7 +9,7 @@ using Silk.NET.Windowing; namespace AcDream.App.Rendering; -public sealed class GameWindow : IDisposable +public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { private static double ClientTimerNow() => System.Diagnostics.Stopwatch.GetTimestamp() @@ -182,6 +182,7 @@ public sealed class GameWindow : IDisposable private readonly AcDream.App.World.RetailInboundEventDispatcher _inboundEntityEvents = new(); private readonly LiveEntityAnimationScheduler _liveAnimationScheduler; + private readonly LiveEntityAnimationPresenter _animationPresenter; private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; @@ -278,7 +279,7 @@ public sealed class GameWindow : IDisposable /// Static decorations and entities with no motion table never /// appear in this map. /// - private readonly LiveEntityAnimationRuntimeView _animatedEntities; + private readonly LiveEntityAnimationRuntimeView _animatedEntities; // MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys. // WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically @@ -298,73 +299,9 @@ public sealed class GameWindow : IDisposable /// private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new(); - // #184 Slice 2a: internal (was private) so the extracted - // RemotePhysicsUpdater in AcDream.App.Physics can take it by type. Matches - // RemoteMotion's existing internal visibility. - internal sealed class AnimatedEntity : AcDream.App.World.ILiveEntityAnimationRuntime - { - public required AcDream.Core.World.WorldEntity Entity; - AcDream.Core.World.WorldEntity AcDream.App.World.ILiveEntityAnimationRuntime.Entity => Entity; - uint AcDream.App.World.ILiveEntityAnimationRuntime.CurrentMotion => - Sequencer?.CurrentMotion ?? 0u; - public required DatReaderWriter.DBObjs.Setup Setup; - public required DatReaderWriter.DBObjs.Animation Animation; - public required int LowFrame; - public required int HighFrame; - public required float Framerate; // frames per second - public required float Scale; // server ObjScale baked into part transforms each tick - /// - /// Per-part identity carried over from the hydration pass: the - /// (post-AnimPartChanges) GfxObjId and the (post-resolution) - /// surface override map. The transform is recomputed every tick - /// from the current animation frame; only these two fields are - /// reused unchanged. - /// - public required IReadOnlyList PartTemplate; - public required IReadOnlyList PartAvailability; - public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame] - public AcDream.Core.Physics.AnimationSequencer? Sequencer; - - // The local player's CPartArray is advanced from - // PlayerMovementController before its 30 Hz physics integration so - // the exact authored root delta enters the collision sweep. Retain - // that same advance's part pose for TickAnimations; advancing the - // sequencer a second time would double both animation time and hooks. - public IReadOnlyList? PreparedSequenceFrames; - public bool SequenceAdvancedBeforeAnimationPass; - public readonly DatReaderWriter.Types.Frame RootMotionScratch = new(); - public readonly AcDream.Core.Physics.Motion.MotionDeltaFrame - RootMotionDeltaScratch = new(); - - /// - /// MP-Alloc (2026-07-05): reusable per-entity MeshRefs buffer. Every - /// animated entity (including idle NPCs on a breathe cycle) used to - /// get a fresh `List<MeshRef>(partCount)` reassigned to - /// every tick — - /// the old list became garbage every frame. This buffer is cleared - /// and refilled in place instead; Entity.MeshRefs is set to - /// this SAME list reference once it's populated (assigning the - /// unchanged reference back is a no-op cost-wise). Safe because - /// TickAnimations runs single-threaded to completion before any - /// draw-side consumer reads MeshRefs, and the only cross-frame - /// consumer (RefreshPaperdollDoll) takes an explicit - /// `new List<MeshRef>(pe.MeshRefs)` VALUE copy — MeshRef is a - /// readonly record struct, so that copy is unaffected by later - /// in-place mutation of this list. - /// - public readonly List MeshRefsScratch = new(); - /// Indexed final part poses, including debug-hidden parts. - public readonly List EffectPartPosesScratch = new(); - } - - internal readonly record struct AnimatedPartTemplate( - uint GfxObjId, - IReadOnlyDictionary? SurfaceOverrides, - bool IsDrawable); - private sealed record AppearanceUpdateState( AcDream.Core.World.WorldEntity Entity, - AnimatedEntity? Animation); + LiveEntityAnimationState? Animation); private AcDream.Core.Physics.IAnimationLoader? _animLoader; @@ -1203,7 +1140,7 @@ public sealed class GameWindow : IDisposable _worldEvents = worldEvents; _selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _uiRegistry = uiRegistry; - _animatedEntities = new LiveEntityAnimationRuntimeView(() => _liveEntities); + _animatedEntities = new LiveEntityAnimationRuntimeView(() => _liveEntities); _remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView(() => _liveEntities); SpellBook = new AcDream.Core.Spells.Spellbook(); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); @@ -1229,6 +1166,13 @@ public sealed class GameWindow : IDisposable () => _projectileController, entity => _effectPoses.UpdateRoot(entity), CaptureAnimationHooks); + _animationPresenter = new LiveEntityAnimationPresenter( + () => _liveEntities, + () => _staticAnimationScheduler, + _effectPoses, + this, + AnimationPresentationDiagnostics.FromEnvironment(), + options.HidePartIndex); _localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController( resolveEntity: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) @@ -4140,7 +4084,7 @@ public sealed class GameWindow : IDisposable var meshRefs = new List(); var indexedPartTransforms = new System.Numerics.Matrix4x4[parts.Count]; var indexedPartAvailable = new bool[parts.Count]; - var animatedPartTemplate = new AnimatedPartTemplate[parts.Count]; + var animatedPartTemplate = new LiveAnimationPartTemplate[parts.Count]; var liveBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); int dumpClothingTotalTris = 0; for (int partIdx = 0; partIdx < parts.Count; partIdx++) @@ -4160,7 +4104,7 @@ public sealed class GameWindow : IDisposable var gfx = _dats.Get(mr.GfxObjId); bool isDrawable = gfx is not null; indexedPartAvailable[partIdx] = isDrawable; - animatedPartTemplate[partIdx] = new AnimatedPartTemplate( + animatedPartTemplate[partIdx] = new LiveAnimationPartTemplate( mr.GfxObjId, surfaceOverrides, isDrawable); @@ -4419,7 +4363,7 @@ public sealed class GameWindow : IDisposable // Snapshot per-part identity from the hydrated meshRefs so the // tick can rebuild MeshRefs without redoing AnimPartChanges or // texture-override resolution every frame. - AnimatedPartTemplate[] template = animatedPartTemplate; + LiveAnimationPartTemplate[] template = animatedPartTemplate; // Create an AnimationSequencer if we can load the MotionTable. AcDream.Core.Physics.AnimationSequencer? sequencer = null; @@ -4437,7 +4381,7 @@ public sealed class GameWindow : IDisposable } } - _animatedEntities[entity.Id] = new AnimatedEntity + _animatedEntities[entity.Id] = new LiveEntityAnimationState { Entity = entity, Setup = setup, @@ -4492,9 +4436,9 @@ public sealed class GameWindow : IDisposable var sequencer = SpawnMotionInitializer.Create( setup, mtable, _animLoader, spawn.MotionState); - AnimatedPartTemplate[] template = animatedPartTemplate; + LiveAnimationPartTemplate[] template = animatedPartTemplate; - _animatedEntities[entity.Id] = new AnimatedEntity + _animatedEntities[entity.Id] = new LiveEntityAnimationState { Entity = entity, Setup = setup, @@ -4541,7 +4485,7 @@ public sealed class GameWindow : IDisposable setupDefaultLoader); if (sequencer.HasCurrentNode) { - _animatedEntities[entity.Id] = new AnimatedEntity + _animatedEntities[entity.Id] = new LiveEntityAnimationState { Entity = entity, Setup = setup, @@ -4565,7 +4509,8 @@ public sealed class GameWindow : IDisposable // unmatched node that permanently starves later MoveTo/TurnTo work. if (_animatedEntities.TryGetValue(entity.Id, out var registeredAnimation)) { - EnsureMotionDoneBinding(spawn.Guid, registeredAnimation); + if (_liveEntities.TryGetRecord(spawn.Guid, out LiveEntityRecord bindingRecord)) + _animationPresenter.PrepareAnimation(bindingRecord, registeredAnimation); if (isPhysicsStatic && registeredAnimation.Sequencer is { } staticSequencer @@ -4736,11 +4681,11 @@ public sealed class GameWindow : IDisposable } internal static void RebindAnimatedEntityForAppearance( - AnimatedEntity animation, + LiveEntityAnimationState animation, AcDream.Core.World.WorldEntity entity, DatReaderWriter.DBObjs.Setup setup, float scale, - IReadOnlyList partTemplate, + IReadOnlyList partTemplate, IReadOnlyList partAvailability) { animation.Entity = entity; @@ -4748,6 +4693,7 @@ public sealed class GameWindow : IDisposable animation.Scale = scale; animation.PartTemplate = partTemplate; animation.PartAvailability = partAvailability; + animation.InvalidatePresentationPoses(); } /// @@ -5208,7 +5154,7 @@ public sealed class GameWindow : IDisposable /// the VectorUpdate path regardless of arrival order. /// private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings( - RemoteMotion rm, AnimatedEntity? ae, uint serverGuid) + RemoteMotion rm, LiveEntityAnimationState? ae, uint serverGuid) { AcDream.Core.Physics.AnimationSequencer? sequencer = ae?.Sequencer; if (sequencer is not null && rm.Sink is null) @@ -5812,7 +5758,7 @@ public sealed class GameWindow : IDisposable /// /// Phase 6.6: the server says an entity's motion has changed. Look up - /// the AnimatedEntity for that guid, re-resolve the idle cycle with the + /// the LiveEntityAnimationState for that guid, re-resolve the idle cycle with the /// new (stance, forward-command) override, and if the cycle is still /// animated, swap in the new animation/frame range. Entities not in /// the animated map (static props, entities rejected at spawn time) @@ -6220,7 +6166,7 @@ public sealed class GameWindow : IDisposable DispatchRemoteInboundMotion( AcDream.Core.Net.WorldSession.EntityMotionUpdate update, AcDream.Core.World.WorldEntity entity, - AnimatedEntity? ae, + LiveEntityAnimationState? ae, LiveEntityRecord acceptedRecord, ulong acceptedMovementAuthorityVersion, ulong acceptedVelocityAuthorityVersion) @@ -6557,7 +6503,7 @@ public sealed class GameWindow : IDisposable private void ApplyServerControlledVelocityCycle( uint serverGuid, - AnimatedEntity ae, + LiveEntityAnimationState ae, RemoteMotion rm, System.Numerics.Vector3 velocity) { @@ -7355,7 +7301,7 @@ public sealed class GameWindow : IDisposable // Enqueue only if the canonical ordinary-object workset // will consume the queue. Animation is optional in retail; // LiveEntityAnimationScheduler advances a spatial - // RemoteMotion even when no AnimatedEntity exists. + // RemoteMotion even when no LiveEntityAnimationState exists. bool willBeDrTickedNpc = WillAdvanceRemoteMotion( update.Guid, rmState); @@ -11505,12 +11451,12 @@ public sealed class GameWindow : IDisposable _localPlayerFrame.HiddenPartPoseDirty, _liveCenterX, _liveCenterY, - EnsureMotionDoneBinding); + _animationPresenter.PrepareAnimation); // CPhysics::UseTime (0x00509950) processes the ordinary object table // first, then its distinct static_animating_objects workset. _staticAnimationScheduler?.Tick(dt); if (_animatedEntities.Count > 0) - TickAnimations(animationSchedules); + _animationPresenter.Present(animationSchedules); _equippedChildRenderer?.Tick(); // CPhysicsObj::animate_static_object (0x00513DF0) reaches @@ -11569,7 +11515,7 @@ public sealed class GameWindow : IDisposable /// * Framerate, wrapping around the cycle's /// [LowFrame..HighFrame] interval, then rebuild that entity's /// MeshRefs from the new frame's per-part transforms. Static - /// entities (no AnimatedEntity record) are untouched. The static + /// entities (no LiveEntityAnimationState record) are untouched. The static /// renderer reads the new MeshRefs on the next Draw call. /// private void AdvanceLocalPlayerAnimationRoot( @@ -11588,7 +11534,8 @@ public sealed class GameWindow : IDisposable return; } - EnsureMotionDoneBinding(_playerServerGuid, ae); + if (_liveEntities?.TryGetRecord(_playerServerGuid, out LiveEntityRecord bindingRecord) == true) + _animationPresenter.PrepareAnimation(bindingRecord, ae); DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch; rootFrame.Origin = System.Numerics.Vector3.Zero; @@ -11615,320 +11562,22 @@ public sealed class GameWindow : IDisposable AcDream.Core.Physics.AnimationSequencer sequencer) => _animationHookFrames?.Capture(ownerLocalId, sequencer); - private void TickAnimations( - IReadOnlyDictionary animationSchedules) + uint ILiveAnimationPresentationContext.LocalPlayerGuid => _playerServerGuid; + + AcDream.Core.Physics.MotionInterpreter? + ILiveAnimationPresentationContext.ResolveMotionInterpreter( + LiveEntityRecord record) { - // Retail has NO stop-detection heuristic — it relies on the - // server sending explicit UpdateMotion(Ready). The server-side - // stop signal flows the same way as any other motion change: - // alt releases W → MoveToState(Ready) → ACE broadcasts - // UpdateMotion(Ready) + UpdatePosition with zero velocity. - // On our side, only UpdateMotion changes interpreted state; - // UpdatePosition updates physics pose/velocity and never selects an - // animation. Anything else is server buggery (packet loss, ACE bug) - // — don't guess client-side. - foreach (var kv in _animatedEntities) + if (_liveEntities?.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) != true + || !ReferenceEquals(current, record)) { - var ae = kv.Value; - - // The server guid is carried on the entity itself - // (WorldEntity.ServerGuid, set == the _entitiesByServerGuid key at - // construction — see OnLiveEntitySpawnedLocked: `ServerGuid = spawn.Guid` - // and `_entitiesByServerGuid[spawn.Guid] = entity`). Read it directly — - // O(1) — instead of the former reverse ReferenceEquals scan over ALL of - // _entitiesByServerGuid, which made this loop O(animated × all-entities). - // That super-linear cost is the FPS-drops-per-teleport sink: world - // entities accumulate without bound (pruned only on DeleteObject, and ACE - // does not re-send / clear KnownObjects across a teleport), so N climbs - // every hop. ServerGuid defaults to 0 for entities not server-tracked, - // exactly matching the old scan's miss case. - uint serverGuid = ae.Entity.ServerGuid; - - // Defensive for a runtime retained across visual rehydration. - // This is deliberately before every early-out and before - // Sequencer.Advance: no completion may precede its consumer. - EnsureMotionDoneBinding(serverGuid, ae); - - if (_liveEntities?.TryGetRecord( - serverGuid, - out LiveEntityRecord liveRecord) != true) - { - continue; - } - animationSchedules.TryGetValue(kv.Key, out LiveEntityAnimationSchedule schedule); - IReadOnlyList? seqFrames = - schedule.SequenceFrames; - bool composeParts = schedule.ComposeParts; - if (_staticAnimationScheduler?.TryTakeLivePartFrames( - kv.Key, - out var staticPartFrames) == true) - { - seqFrames = staticPartFrames; - composeParts = true; - } - - // ── Get per-part (origin, orientation) from either sequencer or legacy ── - if (ae.Sequencer is not null) - { - // Per-tick sequencer-state diag: prove whether the sequencer - // for the observed retail char actually holds the latest - // motion (= SetCycle landed) OR is stuck on an old motion - // (= something elsewhere is reverting). Throttled to once - // per second per remote. - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" - && serverGuid != 0 - && serverGuid != _playerServerGuid) - { - double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmDiag) - && nowSec - rmDiag.LastSeqStateLogTime > 1.0) - { - // D1 (2026-05-03): SEQSTATE has its own throttle clock - // (LastSeqStateLogTime) so it isn't silently swallowed by - // OMEGA_DIAG resetting LastOmegaDiagLogTime when the - // observed remote happens to be turning. - System.Console.WriteLine( - $"[SEQSTATE] guid={serverGuid:X8} CurrentMotion=0x{ae.Sequencer.CurrentMotion:X8} " - + $"CurrentSpeedMod={ae.Sequencer.CurrentSpeedMod:F3}"); - - // #39 fix-3 evidence (2026-05-06): CURRNODE proves - // whether _currNode is actually on the cycle (anim - // ref hash matches FirstCyclic) or stuck somewhere - // else. SCFULL captures _currNode==_firstCyclic only - // at SetCycle return; this captures it per render - // tick so we can see if something resets it later. - var d = ae.Sequencer.CurrentNodeDiag; - int firstHash = ae.Sequencer.FirstCyclicAnimRefHash; - System.Console.WriteLine( - $"[CURRNODE] guid={serverGuid:X8} " - + $"animRef=0x{d.AnimRefHash:X8} firstCyclicAnimRef=0x{firstHash:X8} " - + $"isOnCyclic={d.AnimRefHash == firstHash && firstHash != 0} " - + $"isLooping={d.IsLooping} fr={d.Framerate:F2} " - + $"frame=[{d.StartFrame}..{d.EndFrame}] " - + $"pos={d.FramePosition:F2} qCount={d.QueueCount}"); - - rmDiag.LastSeqStateLogTime = nowSec; - } - } - } - else - { - // Legacy path (entities without a MotionTable / sequencer). - int span = ae.HighFrame - ae.LowFrame; - bool hiddenLegacy = (liveRecord.FinalPhysicsState - & AcDream.Core.Physics.PhysicsStateFlags.Hidden) != 0; - if (span <= 0 && !hiddenLegacy) continue; - if (span > 0 && schedule.LegacyAdvanceSeconds > 0f) - { - ae.CurrFrame += schedule.LegacyAdvanceSeconds * ae.Framerate; - if (ae.CurrFrame > ae.HighFrame) - { - float over = ae.CurrFrame - ae.LowFrame; - ae.CurrFrame = ae.LowFrame + (over % (span + 1)); - } - else if (ae.CurrFrame < ae.LowFrame) - ae.CurrFrame = ae.LowFrame; - } - } - - int partCount = ae.PartTemplate.Count; - - // D5 (Commit A 2026-05-03): PARTSDIAG — proves whether - // PartTemplate.Count diverges from seqFrames.Count (silent - // identity-quat fallback freezes parts → H5) and whether the - // per-part frames returned by Advance actually change between - // Walk and Run cycles. The seqFrames hash is a sum-of-components - // proxy: cheap, unitless, monotonically distinct between cycles - // for any non-degenerate animation. If [PARTSDIAG] shows the - // hash unchanged across a Walk→Run transition while [SEQSTATE] - // shows CurrentMotion flipping, the sequencer is serving stale - // frames despite the cycle being correct. - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" - && serverGuid != 0 - && serverGuid != _playerServerGuid - && _remoteDeadReckon.TryGetValue(serverGuid, out var rmParts)) - { - double nowSecParts = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - if (nowSecParts - rmParts.LastPartsDiagLogTime > 1.0) - { - int seqCount = seqFrames?.Count ?? -1; - int setupParts = ae.Setup.Parts.Count; - // #62: B.4c introduced `Animation = null!` for sequencer-driven - // door entities (parallel branch sites at line 2867 + 7892). - // Doors don't currently enter _remoteDeadReckon, so this - // path isn't reachable for them today — but the read was - // unguarded and would NRE the moment something flips. - // Local-var + `is not null` keeps the guard scoped: the - // legacy slerp branch below treats ae.Animation as non- - // null (per its non-nullable declared type) unchanged. - var animMaybeNull = ae.Animation; - int animFrame0Parts = - animMaybeNull is not null && animMaybeNull.PartFrames.Count > 0 - ? animMaybeNull.PartFrames[0].Frames.Count - : -1; - double seqHash = 0.0; - if (seqFrames is not null) - { - for (int hi = 0; hi < seqFrames.Count; hi++) - { - var f = seqFrames[hi]; - seqHash += f.Origin.X + f.Origin.Y + f.Origin.Z - + f.Orientation.X + f.Orientation.Y - + f.Orientation.Z + f.Orientation.W; - } - } - System.Console.WriteLine( - $"[PARTSDIAG] guid={serverGuid:X8} " - + $"pt.Count={partCount} seqFrames.Count={seqCount} " - + $"setup.Parts.Count={setupParts} " - + $"anim.PartFrames[0].Frames.Count={animFrame0Parts} " - + $"seqHash={seqHash:F4}"); - rmParts.LastPartsDiagLogTime = nowSecParts; - } - } - - if (!composeParts) - continue; - - // MP-Alloc (2026-07-05): reuse the entity's cached MeshRefs list - // instead of allocating a fresh List(partCount) every - // tick (see AnimatedEntity.MeshRefsScratch for the safety - // argument — single-threaded tick-before-draw ordering, no - // consumer caches a stale reference or diffs list identity). - var newMeshRefs = ae.MeshRefsScratch; - newMeshRefs.Clear(); - var effectPartPoses = ae.EffectPartPosesScratch; - effectPartPoses.Clear(); - var scaleMat = ae.Scale == 1.0f - ? System.Numerics.Matrix4x4.Identity - : System.Numerics.Matrix4x4.CreateScale(ae.Scale); - - for (int i = 0; i < partCount; i++) - { - System.Numerics.Vector3 origin; - System.Numerics.Quaternion orientation; - - if (seqFrames is not null) - { - // Sequencer path. - if (i < seqFrames.Count) - { - origin = seqFrames[i].Origin; - orientation = seqFrames[i].Orientation; - } - else - { - origin = System.Numerics.Vector3.Zero; - orientation = System.Numerics.Quaternion.Identity; - } - } - else - { - // Legacy slerp path. - int frameIdx = (int)Math.Floor(ae.CurrFrame); - if (frameIdx < ae.LowFrame || frameIdx > ae.HighFrame - || frameIdx >= ae.Animation.PartFrames.Count) - frameIdx = ae.LowFrame; - int nextIdx = frameIdx + 1; - if (nextIdx > ae.HighFrame || nextIdx >= ae.Animation.PartFrames.Count) - nextIdx = ae.LowFrame; - float t = ae.CurrFrame - frameIdx; - if (t < 0f) t = 0f; else if (t > 1f) t = 1f; - var partFrames = ae.Animation.PartFrames[frameIdx].Frames; - var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames; - if (i < partFrames.Count) - { - var f0 = partFrames[i]; - var f1 = i < partFramesNext.Count ? partFramesNext[i] : f0; - origin = System.Numerics.Vector3.Lerp(f0.Origin, f1.Origin, t); - orientation = System.Numerics.Quaternion.Slerp(f0.Orientation, f1.Orientation, t); - } - else - { - origin = System.Numerics.Vector3.Zero; - orientation = System.Numerics.Quaternion.Identity; - } - } - - // Per-part default scale from the Setup, matching SetupMesh.Flatten's - // composition order: scale → rotate → translate. - var defaultScale = i < ae.Setup.DefaultScale.Count - ? ae.Setup.DefaultScale[i] - : System.Numerics.Vector3.One; - - var visualPartTransform = - System.Numerics.Matrix4x4.CreateScale(defaultScale) * - System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) * - System.Numerics.Matrix4x4.CreateTranslation(origin); - - // Bake the entity's ObjScale on top, matching the hydration - // order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned. - if (ae.Scale != 1.0f) - visualPartTransform *= scaleMat; - - // Retail CPartArray::UpdateParts (0x005190F0) scales only the - // frame origin. Setup DefaultScale is visual gfxobj_scale, - // not part-frame state (SetScaleInternal 0x00518A00). - var rigidPartTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) * - System.Numerics.Matrix4x4.CreateTranslation(origin * ae.Scale); - effectPartPoses.Add(rigidPartTransform); - - var template = ae.PartTemplate[i]; - if (!template.IsDrawable - || (_options.HidePartIndex >= 0 && i == _options.HidePartIndex && partCount >= 10)) - { - continue; - } - newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, visualPartTransform) - { - SurfaceOverrides = template.SurfaceOverrides, - }); - } - - // Re-assigning the SAME list reference every tick is cheap (a - // property store) and keeps this line's shape identical to the - // pre-MP-Alloc code for anyone grepping the history. - ae.Entity.MeshRefs = newMeshRefs; - ae.Entity.SetIndexedPartPoses(effectPartPoses, ae.PartAvailability); - _effectPoses.Publish(ae.Entity, effectPartPoses, ae.PartAvailability); + return null; } - } - - /// - /// R3-W2: binds the animation queue's completion seam to the entity's - /// real CMotionInterp::MotionDone consumer - /// (CPhysicsObj::MotionDone 0x0050FDB0). Retail owns both queues - /// under one CPhysicsObj/CPartArray lifetime; acdream's split owners must - /// establish the relationship before the first animation can finish. - /// - private void EnsureMotionDoneBinding(uint serverGuid, AnimatedEntity ae) - { - if (ae.Sequencer is not { MotionDoneTarget: null } sequencer) - return; - - uint ownerGuid = serverGuid; - // Resolve at callback time: a remote MotionInterpreter can be created - // after its animation runtime, and the player controller is created - // during EnterPlayerModeNow. Eagerly capturing either would preserve - // a legitimate null forever. - sequencer.MotionDoneTarget = (motion, success) => - { - AcDream.Core.Physics.MotionInterpreter? interpreter = null; - if (ownerGuid != 0 && ownerGuid == _playerServerGuid) - interpreter = _playerController?.Motion; - else if (ownerGuid != 0 - && _remoteDeadReckon.TryGetValue(ownerGuid, out var remote)) - interpreter = remote.Motion; - - interpreter?.MotionDone(motion, success); - if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - { - System.Console.WriteLine( - $"[MOTIONDONE] guid={ownerGuid:X8} motion=0x{motion:X8} " - + $"success={success} pending={(interpreter?.MotionsPending() ?? false)}"); - } - }; + if (record.ServerGuid == _playerServerGuid) + return _playerController?.Motion; + return record.RemoteMotionRuntime is RemoteMotion remote + ? remote.Motion + : null; } // IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass diff --git a/src/AcDream.App/Rendering/ILiveAnimationPresentationContext.cs b/src/AcDream.App/Rendering/ILiveAnimationPresentationContext.cs new file mode 100644 index 00000000..c12236b3 --- /dev/null +++ b/src/AcDream.App/Rendering/ILiveAnimationPresentationContext.cs @@ -0,0 +1,10 @@ +using AcDream.App.World; +using AcDream.Core.Physics; + +namespace AcDream.App.Rendering; + +internal interface ILiveAnimationPresentationContext +{ + uint LocalPlayerGuid { get; } + MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record); +} diff --git a/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs b/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs new file mode 100644 index 00000000..58f20787 --- /dev/null +++ b/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs @@ -0,0 +1,21 @@ +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Late presentation handoff from retail's separate +/// static_animating_objects workset. Every take is bound to the exact +/// live incarnation that prepared the frames. +/// +internal interface ILiveStaticPartFrameSource +{ + bool TryTakeLivePartFrames( + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation, + ulong objectClockEpoch, + ulong projectionMutationVersion, + out IReadOnlyList frames); +} diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs new file mode 100644 index 00000000..e5673aea --- /dev/null +++ b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs @@ -0,0 +1,403 @@ +using System.Numerics; +using AcDream.App.Rendering.Vfx; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Publishes the final visual and rigid part channels after retail object and +/// static-animation scheduling. It advances no time and drains no hooks. +/// +internal sealed class LiveEntityAnimationPresenter +{ + private readonly Func _liveEntities; + private readonly Func _staticFrames; + private readonly EntityEffectPoseRegistry _effectPoses; + private readonly ILiveAnimationPresentationContext _context; + private readonly AnimationPresentationDiagnostics _diagnostics; + private readonly int _hidePartIndex; + private readonly List _snapshot = new(); + + public LiveEntityAnimationPresenter( + Func liveEntities, + Func staticFrames, + EntityEffectPoseRegistry effectPoses, + ILiveAnimationPresentationContext context, + AnimationPresentationDiagnostics diagnostics, + int hidePartIndex) + { + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _staticFrames = staticFrames ?? throw new ArgumentNullException(nameof(staticFrames)); + _effectPoses = effectPoses ?? throw new ArgumentNullException(nameof(effectPoses)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _hidePartIndex = hidePartIndex; + } + + public void PrepareAnimation( + LiveEntityRecord record, + LiveEntityAnimationState animation) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(animation); + LiveEntityRuntime? runtime = _liveEntities(); + if (runtime is null + || !runtime.IsCurrentSpatialAnimation(record, animation) + || !ReferenceEquals(record.WorldEntity, animation.Entity) + || animation.Sequencer is not { MotionDoneTarget: null } sequencer) + { + return; + } + + WorldEntity capturedEntity = animation.Entity; + sequencer.MotionDoneTarget = (motion, success) => + { + LiveEntityRuntime? current = _liveEntities(); + if (current is null + || !current.IsCurrentSpatialAnimation(record, animation) + || !ReferenceEquals(record.WorldEntity, capturedEntity) + || !ReferenceEquals(animation.Entity, capturedEntity)) + { + return; + } + + MotionInterpreter? interpreter = _context.ResolveMotionInterpreter(record); + interpreter?.MotionDone(motion, success); + if (_diagnostics.DumpMotionEnabled) + { + Console.WriteLine( + $"[MOTIONDONE] guid={record.ServerGuid:X8} motion=0x{motion:X8} " + + $"success={success} pending={(interpreter?.MotionsPending() ?? false)}"); + } + }; + } + + public void Present( + IReadOnlyDictionary schedules) + { + ArgumentNullException.ThrowIfNull(schedules); + LiveEntityRuntime? runtime = _liveEntities(); + if (runtime is null) + return; + + // Private snapshot: EffectPoseChanged is synchronous and may perform a + // nested runtime-view enumeration. Never borrow that view's scratch. + runtime.CopySpatialRootObjectRecordsTo(_snapshot); + foreach (LiveEntityRecord record in _snapshot) + { + if (!TryGetCurrent(runtime, record, out WorldEntity entity, out LiveEntityAnimationState animation)) + continue; + + PrepareAnimation(record, animation); + if (!TryGetCurrent(runtime, record, entity, animation)) + continue; + + schedules.TryGetValue(entity.Id, out LiveEntityAnimationSchedule schedule); + bool hasOrdinarySchedule = IsCurrentSchedule(runtime, schedule, record, entity, animation); + IReadOnlyList? sequenceFrames = hasOrdinarySchedule + ? schedule.SequenceFrames + : null; + bool composeParts = hasOrdinarySchedule && schedule.ComposeParts; + float legacyAdvanceSeconds = hasOrdinarySchedule + ? schedule.LegacyAdvanceSeconds + : 0f; + ulong objectClockEpoch = record.ObjectClockEpoch; + ulong projectionVersion = record.ProjectionMutationVersion; + + if (_staticFrames()?.TryTakeLivePartFrames( + record, + entity, + animation, + objectClockEpoch, + projectionVersion, + out IReadOnlyList staticPartFrames) == true) + { + if (!TryGetCurrent( + runtime, + record, + entity, + animation, + objectClockEpoch, + projectionVersion)) + { + continue; + } + sequenceFrames = staticPartFrames; + composeParts = true; + } + + if (animation.Sequencer is not null) + { + EmitSequenceDiagnostics(record, animation, sequenceFrames); + } + else + { + int span = animation.HighFrame - animation.LowFrame; + bool hiddenLegacy = (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0; + if (span <= 0 && !hiddenLegacy) + continue; + if (span > 0 && legacyAdvanceSeconds > 0f) + { + animation.CurrFrame += legacyAdvanceSeconds * animation.Framerate; + if (animation.CurrFrame > animation.HighFrame) + { + float over = animation.CurrFrame - animation.LowFrame; + animation.CurrFrame = animation.LowFrame + (over % (span + 1)); + } + else if (animation.CurrFrame < animation.LowFrame) + { + animation.CurrFrame = animation.LowFrame; + } + } + } + + if (!composeParts + || !TryGetCurrent( + runtime, + record, + entity, + animation, + objectClockEpoch, + projectionVersion)) + { + continue; + } + + ComposeAndPublish(runtime, record, entity, animation, sequenceFrames, + objectClockEpoch, projectionVersion); + } + } + + private void ComposeAndPublish( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation, + IReadOnlyList? sequenceFrames, + ulong objectClockEpoch, + ulong projectionVersion) + { + int partCount = animation.PartTemplate.Count; + EmitPartDiagnostics(record, animation, sequenceFrames, partCount); + List meshRefs = animation.MeshRefsScratch; + meshRefs.Clear(); + List rigidPoses = animation.EffectPartPosesScratch; + rigidPoses.Clear(); + Matrix4x4 scale = animation.Scale == 1f + ? Matrix4x4.Identity + : Matrix4x4.CreateScale(animation.Scale); + + for (int i = 0; i < partCount; i++) + { + ResolvePartFrame(animation, sequenceFrames, i, out Vector3 origin, out Quaternion orientation); + Vector3 defaultScale = i < animation.Setup.DefaultScale.Count + ? animation.Setup.DefaultScale[i] + : Vector3.One; + Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin); + if (animation.Scale != 1f) + visual *= scale; + + Matrix4x4 rigid = Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin * animation.Scale); + rigidPoses.Add(rigid); + + LiveAnimationPartTemplate template = animation.PartTemplate[i]; + if (!template.IsDrawable + || (_hidePartIndex >= 0 && i == _hidePartIndex && partCount >= 10)) + { + continue; + } + meshRefs.Add(new MeshRef(template.GfxObjId, visual) + { + SurfaceOverrides = template.SurfaceOverrides, + }); + } + + if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion)) + return; + entity.MeshRefs = meshRefs; + entity.SetIndexedPartPoses(rigidPoses, animation.PartAvailability); + if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion)) + return; + _effectPoses.Publish(entity, rigidPoses, animation.PartAvailability); + } + + private static void ResolvePartFrame( + LiveEntityAnimationState animation, + IReadOnlyList? sequenceFrames, + int partIndex, + out Vector3 origin, + out Quaternion orientation) + { + if (sequenceFrames is not null) + { + if (partIndex < sequenceFrames.Count) + { + origin = sequenceFrames[partIndex].Origin; + orientation = sequenceFrames[partIndex].Orientation; + } + else + { + origin = Vector3.Zero; + orientation = Quaternion.Identity; + } + return; + } + + int frameIndex = (int)Math.Floor(animation.CurrFrame); + if (frameIndex < animation.LowFrame + || frameIndex > animation.HighFrame + || frameIndex >= animation.Animation.PartFrames.Count) + { + frameIndex = animation.LowFrame; + } + int nextIndex = frameIndex + 1; + if (nextIndex > animation.HighFrame + || nextIndex >= animation.Animation.PartFrames.Count) + { + nextIndex = animation.LowFrame; + } + float t = Math.Clamp(animation.CurrFrame - frameIndex, 0f, 1f); + var frames = animation.Animation.PartFrames[frameIndex].Frames; + var nextFrames = animation.Animation.PartFrames[nextIndex].Frames; + if (partIndex < frames.Count) + { + var first = frames[partIndex]; + var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first; + origin = Vector3.Lerp(first.Origin, next.Origin, t); + orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t); + } + else + { + origin = Vector3.Zero; + orientation = Quaternion.Identity; + } + } + + private void EmitSequenceDiagnostics( + LiveEntityRecord record, + LiveEntityAnimationState animation, + IReadOnlyList? sequenceFrames) + { + if (!_diagnostics.RemoteVelocityEnabled + || record.ServerGuid == 0 + || record.ServerGuid == _context.LocalPlayerGuid + || record.RemoteMotionRuntime is null) + { + return; + } + double now = _diagnostics.Now(); + if (now - animation.LastSequenceDiagnosticTime <= 1d) + return; + AnimationSequencer sequencer = animation.Sequencer!; + Console.WriteLine( + $"[SEQSTATE] guid={record.ServerGuid:X8} CurrentMotion=0x{sequencer.CurrentMotion:X8} " + + $"CurrentSpeedMod={sequencer.CurrentSpeedMod:F3}"); + var node = sequencer.CurrentNodeDiag; + int firstHash = sequencer.FirstCyclicAnimRefHash; + Console.WriteLine( + $"[CURRNODE] guid={record.ServerGuid:X8} animRef=0x{node.AnimRefHash:X8} " + + $"firstCyclicAnimRef=0x{firstHash:X8} " + + $"isOnCyclic={node.AnimRefHash == firstHash && firstHash != 0} " + + $"isLooping={node.IsLooping} fr={node.Framerate:F2} " + + $"frame=[{node.StartFrame}..{node.EndFrame}] " + + $"pos={node.FramePosition:F2} qCount={node.QueueCount}"); + animation.LastSequenceDiagnosticTime = now; + } + + private void EmitPartDiagnostics( + LiveEntityRecord record, + LiveEntityAnimationState animation, + IReadOnlyList? sequenceFrames, + int partCount) + { + if (!_diagnostics.RemoteVelocityEnabled + || record.ServerGuid == 0 + || record.ServerGuid == _context.LocalPlayerGuid + || record.RemoteMotionRuntime is null) + { + return; + } + double now = _diagnostics.Now(); + if (now - animation.LastPartDiagnosticTime <= 1d) + return; + int animationPartCount = animation.Animation is not null + && animation.Animation.PartFrames.Count > 0 + ? animation.Animation.PartFrames[0].Frames.Count + : -1; + double hash = 0d; + if (sequenceFrames is not null) + { + foreach (PartTransform frame in sequenceFrames) + { + hash += frame.Origin.X + frame.Origin.Y + frame.Origin.Z + + frame.Orientation.X + frame.Orientation.Y + + frame.Orientation.Z + frame.Orientation.W; + } + } + Console.WriteLine( + $"[PARTSDIAG] guid={record.ServerGuid:X8} pt.Count={partCount} " + + $"seqFrames.Count={sequenceFrames?.Count ?? -1} " + + $"setup.Parts.Count={animation.Setup.Parts.Count} " + + $"anim.PartFrames[0].Frames.Count={animationPartCount} seqHash={hash:F4}"); + animation.LastPartDiagnosticTime = now; + } + + private static bool IsCurrentSchedule( + LiveEntityRuntime runtime, + LiveEntityAnimationSchedule schedule, + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation) => + schedule.ComposeParts + && ReferenceEquals(schedule.Record, record) + && ReferenceEquals(schedule.Entity, entity) + && ReferenceEquals(schedule.Animation, animation) + && TryGetCurrent( + runtime, + record, + entity, + animation, + schedule.ObjectClockEpoch, + schedule.ProjectionMutationVersion); + + private static bool TryGetCurrent( + LiveEntityRuntime runtime, + LiveEntityRecord record, + out WorldEntity entity, + out LiveEntityAnimationState animation) + { + WorldEntity? currentEntity = record.WorldEntity; + LiveEntityAnimationState? currentAnimation = + record.AnimationRuntime as LiveEntityAnimationState; + entity = currentEntity!; + animation = currentAnimation!; + return currentEntity is not null + && currentAnimation is not null + && TryGetCurrent(runtime, record, entity, animation); + } + + private static bool TryGetCurrent( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation) => + runtime.IsCurrentSpatialAnimation(record, animation) + && ReferenceEquals(record.WorldEntity, entity) + && ReferenceEquals(animation.Entity, entity); + + private static bool TryGetCurrent( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation, + ulong objectClockEpoch, + ulong projectionVersion) => + TryGetCurrent(runtime, record, entity, animation) + && record.ObjectClockEpoch == objectClockEpoch + && record.ProjectionMutationVersion == projectionVersion; +} diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs index 62857f63..7ff42e05 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs @@ -5,7 +5,6 @@ using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.Core.World; using DatReaderWriter.Types; -using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity; using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion; namespace AcDream.App.Rendering; @@ -67,7 +66,7 @@ internal sealed class LiveEntityAnimationScheduler bool localHiddenPartPoseDirty, int liveCenterX, int liveCenterY, - Action? prepareAnimation = null) + Action? prepareAnimation = null) { _schedules.Clear(); LiveEntityRuntime? runtime = _liveEntities(); @@ -83,14 +82,12 @@ internal sealed class LiveEntityAnimationScheduler continue; } - AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity; + LiveEntityAnimationState? animation = record.AnimationRuntime as LiveEntityAnimationState; RemoteMotion? remote = record.RemoteMotionRuntime as RemoteMotion; ProjectileController.Runtime? projectile = record.ProjectileRuntime as ProjectileController.Runtime; ulong objectClockEpoch = record.ObjectClockEpoch; - if (animation is not null) - prepareAnimation?.Invoke(record.ServerGuid, animation); if (!IsCurrent( runtime, record, @@ -101,6 +98,22 @@ internal sealed class LiveEntityAnimationScheduler objectClockEpoch)) continue; + if (animation is not null) + { + prepareAnimation?.Invoke(record, animation); + if (!IsCurrent( + runtime, + record, + entity, + animation, + remote, + projectile, + objectClockEpoch)) + { + continue; + } + } + LiveEntityAnimationSchedule schedule = AdvanceRecord( runtime, record, @@ -126,7 +139,14 @@ internal sealed class LiveEntityAnimationScheduler projectile, objectClockEpoch)) { - _schedules[entity.Id] = schedule; + _schedules[entity.Id] = schedule with + { + Record = record, + Entity = entity, + Animation = animation, + ObjectClockEpoch = objectClockEpoch, + ProjectionMutationVersion = record.ProjectionMutationVersion, + }; } } @@ -137,7 +157,7 @@ internal sealed class LiveEntityAnimationScheduler LiveEntityRuntime runtime, LiveEntityRecord record, WorldEntity entity, - AnimatedEntity? animation, + LiveEntityAnimationState? animation, RemoteMotion? remote, ProjectileController.Runtime? projectile, float elapsedSeconds, @@ -472,7 +492,7 @@ internal sealed class LiveEntityAnimationScheduler LiveEntityRuntime runtime, LiveEntityRecord record, WorldEntity entity, - AnimatedEntity? animation, + LiveEntityAnimationState? animation, RemoteMotion? remote, ProjectileController.Runtime? projectile, ulong objectClockEpoch) => @@ -506,4 +526,9 @@ internal sealed class LiveEntityAnimationScheduler internal readonly record struct LiveEntityAnimationSchedule( IReadOnlyList? SequenceFrames, float LegacyAdvanceSeconds, - bool ComposeParts); + bool ComposeParts, + LiveEntityRecord? Record = null, + WorldEntity? Entity = null, + LiveEntityAnimationState? Animation = null, + ulong ObjectClockEpoch = 0UL, + ulong ProjectionMutationVersion = 0UL); diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationState.cs b/src/AcDream.App/Rendering/LiveEntityAnimationState.cs new file mode 100644 index 00000000..1d145e85 --- /dev/null +++ b/src/AcDream.App/Rendering/LiveEntityAnimationState.cs @@ -0,0 +1,63 @@ +using System.Numerics; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Rendering; + +/// +/// Incarnation-owned live CPartArray presentation state. Time, root +/// motion, and hooks remain owned by ; +/// this component retains the final part-presentation channels only. +/// +internal sealed class LiveEntityAnimationState : ILiveEntityAnimationRuntime +{ + public required WorldEntity Entity; + WorldEntity ILiveEntityAnimationRuntime.Entity => Entity; + uint ILiveEntityAnimationRuntime.CurrentMotion => Sequencer?.CurrentMotion ?? 0u; + + public required Setup Setup; + public required Animation Animation; + public required int LowFrame; + public required int HighFrame; + public required float Framerate; + public required float Scale; + public required IReadOnlyList PartTemplate; + public required IReadOnlyList PartAvailability; + public float CurrFrame; + public AnimationSequencer? Sequencer; + + public IReadOnlyList? PreparedSequenceFrames; + public bool SequenceAdvancedBeforeAnimationPass; + public readonly Frame RootMotionScratch = new(); + public readonly MotionDeltaFrame RootMotionDeltaScratch = new(); + + /// Reusable compact drawable channel, consumed before draw. + public readonly List MeshRefsScratch = new(); + /// Reusable indexed rigid/effect channel. + public readonly List EffectPartPosesScratch = new(); + /// + /// Indexed visible transforms retained separately from the rigid channel + /// because Setup DefaultScale affects geometry but not attachments. + /// + public readonly List VisualPartPosesScratch = new(); + public bool PresentationPosesInitialized; + + public double LastSequenceDiagnosticTime; + public double LastPartDiagnosticTime; + + public void InvalidatePresentationPoses() + { + PresentationPosesInitialized = false; + VisualPartPosesScratch.Clear(); + EffectPartPosesScratch.Clear(); + } +} + +internal readonly record struct LiveAnimationPartTemplate( + uint GfxObjId, + IReadOnlyDictionary? SurfaceOverrides, + bool IsDrawable); diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index 534ba13f..89367ae6 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.Rendering.Vfx; +using AcDream.App.World; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.Core.World; @@ -17,7 +18,7 @@ namespace AcDream.App.Rendering; /// resource lifetime. Setup default installation itself is unconditional and /// belongs to PartArray construction; this class owns only the Static workset. /// -internal sealed class RetailStaticAnimatingObjectScheduler +internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFrameSource { private const double FrameEpsilon = 0.000199999995; private const float MaximumElapsed = 2f; @@ -162,10 +163,24 @@ internal sealed class RetailStaticAnimatingObjectScheduler /// the single owner of mesh identity, overrides, and appearance rebinding. /// public bool TryTakeLivePartFrames( - uint ownerId, + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation, + ulong objectClockEpoch, + ulong projectionMutationVersion, out IReadOnlyList frames) { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(animation); + uint ownerId = entity.Id; if (_owners.TryGetValue(ownerId, out Owner? owner) + && ReferenceEquals(record.WorldEntity, entity) + && ReferenceEquals(record.AnimationRuntime, animation) + && record.ObjectClockEpoch == objectClockEpoch + && record.ProjectionMutationVersion == projectionMutationVersion + && ReferenceEquals(owner.Entity, entity) + && ReferenceEquals(owner.Sequencer, animation.Sequencer) && owner.Entity.ServerGuid != 0 && owner.PreparedLivePartFrames is { } prepared && IsResidentAtVersion(owner, owner.PendingResidencyVersion)) @@ -182,6 +197,29 @@ internal sealed class RetailStaticAnimatingObjectScheduler return false; } + /// + /// Scheduler-only test seam for DAT/static timing tests which deliberately + /// do not construct a . Runtime integration + /// must use the exact-incarnation overload above. + /// + internal bool TryTakePreparedFramesForTest( + uint ownerId, + out IReadOnlyList frames) + { + if (_owners.TryGetValue(ownerId, out Owner? owner) + && owner.PreparedLivePartFrames is { } prepared + && IsResidentAtVersion(owner, owner.PendingResidencyVersion)) + { + owner.PreparedLivePartFrames = null; + frames = prepared; + return true; + } + if (_owners.TryGetValue(ownerId, out owner)) + InvalidatePending(owner); + frames = Array.Empty(); + return false; + } + public void Unregister(uint ownerId) => _owners.Remove(ownerId); public void CopyAnimatedEntityIdsTo(HashSet destination) diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs index 0f984d79..afc96a3d 100644 --- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -139,7 +139,7 @@ public sealed class RemotePhysicsUpdaterTests -MathF.PI / 2f), MeshRefs = Array.Empty(), }; - var animated = new GameWindow.AnimatedEntity + var animated = new LiveEntityAnimationState { Entity = entity, Setup = new Setup(), @@ -148,7 +148,7 @@ public sealed class RemotePhysicsUpdaterTests HighFrame = 0, Framerate = 0f, Scale = 1f, - PartTemplate = Array.Empty(), + PartTemplate = Array.Empty(), PartAvailability = Array.Empty(), }; var motion = new GameWindow.RemoteMotion(); @@ -205,7 +205,7 @@ public sealed class RemotePhysicsUpdaterTests Rotation = initial, MeshRefs = Array.Empty(), }; - var animated = new GameWindow.AnimatedEntity + var animated = new LiveEntityAnimationState { Entity = entity, Setup = new Setup(), @@ -214,7 +214,7 @@ public sealed class RemotePhysicsUpdaterTests HighFrame = 0, Framerate = 0f, Scale = 1f, - PartTemplate = Array.Empty(), + PartTemplate = Array.Empty(), PartAvailability = Array.Empty(), }; var motion = new GameWindow.RemoteMotion @@ -260,7 +260,7 @@ public sealed class RemotePhysicsUpdaterTests Rotation = initial, MeshRefs = Array.Empty(), }; - var animated = new GameWindow.AnimatedEntity + var animated = new LiveEntityAnimationState { Entity = entity, Setup = new Setup(), @@ -269,7 +269,7 @@ public sealed class RemotePhysicsUpdaterTests HighFrame = 0, Framerate = 0f, Scale = 1f, - PartTemplate = Array.Empty(), + PartTemplate = Array.Empty(), PartAvailability = Array.Empty(), }; var motion = new GameWindow.RemoteMotion(); diff --git a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs index 37fc47ce..4dcda425 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs @@ -15,7 +15,7 @@ public sealed class LiveAppearanceAnimationTests var animation = new Animation(); var sequencer = new AnimationSequencer(setup, new MotionTable(), new NullLoader()); var oldEntity = Entity(0x70000001u, 0x01000001u); - var state = new GameWindow.AnimatedEntity + var state = new LiveEntityAnimationState { Entity = oldEntity, Setup = setup, @@ -24,7 +24,7 @@ public sealed class LiveAppearanceAnimationTests HighFrame = 9, Framerate = 30f, Scale = 1f, - PartTemplate = [new GameWindow.AnimatedPartTemplate(0x01000001u, null, true)], + PartTemplate = [new LiveAnimationPartTemplate(0x01000001u, null, true)], PartAvailability = [true], CurrFrame = 6.5f, Sequencer = sequencer, @@ -42,8 +42,8 @@ public sealed class LiveAppearanceAnimationTests setup, 1.25f, [ - new GameWindow.AnimatedPartTemplate(0x01000002u, null, true), - new GameWindow.AnimatedPartTemplate(0x01000003u, null, true), + new LiveAnimationPartTemplate(0x01000002u, null, true), + new LiveAnimationPartTemplate(0x01000003u, null, true), ], [true, true]); diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs new file mode 100644 index 00000000..9eb93aa2 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs @@ -0,0 +1,295 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Rendering; + +public sealed class LiveEntityAnimationPresenterTests +{ + private const uint Guid = 0x70000031u; + private const uint Cell = 0x01010001u; + + [Fact] + public void Present_ComposesDistinctVisualAndRigidChannels() + { + var fixture = Build(partCount: 2, scale: 2f); + fixture.State.Setup.DefaultScale[0] = new Vector3(3f, 4f, 5f); + fixture.State.PartTemplate = + [ + new LiveAnimationPartTemplate(0x01000001u, new Dictionary { [1] = 2 }, true), + new LiveAnimationPartTemplate(0x01000002u, null, false), + ]; + Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f); + PartTransform[] frames = + [ + new(new Vector3(1f, 2f, 3f), rotation), + new(new Vector3(4f, 5f, 6f), Quaternion.Identity), + ]; + var poses = new EntityEffectPoseRegistry(); + var presenter = Presenter(fixture.Live, poses, new Context()); + + presenter.Present(Schedules(fixture, frames)); + + Assert.Single(fixture.Entity.MeshRefs); + Matrix4x4 expectedVisual = Matrix4x4.CreateScale(3f, 4f, 5f) + * Matrix4x4.CreateFromQuaternion(rotation) + * Matrix4x4.CreateTranslation(1f, 2f, 3f) + * Matrix4x4.CreateScale(2f); + Matrix4x4 expectedRigid = Matrix4x4.CreateFromQuaternion(rotation) + * Matrix4x4.CreateTranslation(2f, 4f, 6f); + Assert.Equal(expectedVisual, fixture.Entity.MeshRefs[0].PartTransform); + Assert.Equal(expectedRigid, fixture.Entity.IndexedPartTransforms[0]); + Assert.Equal(2, fixture.Entity.IndexedPartTransforms.Count); + Assert.True(poses.TryGetPartPose(fixture.Entity.Id, 0, out Matrix4x4 effectPose)); + Assert.Equal(expectedRigid, effectPose); + Assert.Equal(2u, fixture.Entity.MeshRefs[0].SurfaceOverrides![1]); + } + + [Fact] + public void OldSchedule_IsRejectedForCurrentOwnerEvenWhenIndexedByItsLocalId() + { + var old = Build(partCount: 1); + LiveEntityAnimationSchedule stale = Schedule(old, + [new PartTransform(new Vector3(99f, 0f, 0f), Quaternion.Identity)]); + Assert.True(old.Live.ClearAnimationRuntime(Guid)); + LiveEntityAnimationState replacement = State(old.Entity, partCount: 1, scale: 1f); + old.Live.SetAnimationRuntime(Guid, replacement); + var presenter = Presenter(old.Live, new EntityEffectPoseRegistry(), new Context()); + + presenter.Present(new Dictionary + { + [old.Entity.Id] = stale, + }); + + Assert.Empty(replacement.MeshRefsScratch); + Assert.Empty(replacement.EffectPartPosesScratch); + } + + [Fact] + public void MotionDone_OldComponentCannotResolveReplacementByGuid() + { + var fixture = Build(partCount: 1, withSequencer: true); + var context = new Context(); + var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), context); + presenter.PrepareAnimation(fixture.Record, fixture.State); + Action oldCallback = fixture.State.Sequencer!.MotionDoneTarget!; + + Assert.True(fixture.Live.ClearAnimationRuntime(Guid)); + fixture.Live.SetAnimationRuntime(Guid, State(fixture.Entity, 1, 1f)); + oldCallback(0x10000001u, true); + + Assert.Equal(0, context.ResolveCount); + } + + [Fact] + public void EffectPoseCallback_NestedRuntimeSnapshotDoesNotTruncatePresentation() + { + var first = Build(partCount: 1, guid: Guid); + var second = Add(first.Live, Guid + 1, partCount: 1); + var poses = new EntityEffectPoseRegistry(); + poses.EffectPoseChanged += _ => + { + var nested = new List(); + first.Live.CopySpatialRootObjectRecordsTo(nested); + Assert.Equal(2, nested.Count); + }; + var presenter = Presenter(first.Live, poses, new Context()); + var schedules = new Dictionary + { + [first.Entity.Id] = Schedule(first, + [new PartTransform(Vector3.UnitX, Quaternion.Identity)]), + [second.Entity.Id] = Schedule(second, + [new PartTransform(Vector3.UnitY, Quaternion.Identity)]), + }; + + presenter.Present(schedules); + + Assert.Single(first.Entity.MeshRefs); + Assert.Single(second.Entity.MeshRefs); + } + + private static LiveEntityAnimationPresenter Presenter( + LiveEntityRuntime live, + EntityEffectPoseRegistry poses, + Context context) => new( + () => live, + () => null, + poses, + context, + AnimationPresentationDiagnostics.Disabled, + hidePartIndex: -1); + + private static IReadOnlyDictionary Schedules( + Fixture fixture, + IReadOnlyList frames) => + new Dictionary + { + [fixture.Entity.Id] = Schedule(fixture, frames), + }; + + private static LiveEntityAnimationSchedule Schedule( + Fixture fixture, + IReadOnlyList frames) => new( + frames, + 0f, + true, + fixture.Record, + fixture.Entity, + fixture.State, + fixture.Record.ObjectClockEpoch, + fixture.Record.ProjectionMutationVersion); + + private static Fixture Build( + int partCount, + float scale = 1f, + bool withSequencer = true, + uint guid = Guid) + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new LandBlock(), + Array.Empty())); + var live = new LiveEntityRuntime( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + return Add(live, guid, partCount, scale, withSequencer); + } + + private static Fixture Add( + LiveEntityRuntime live, + uint guid, + int partCount, + float scale = 1f, + bool withSequencer = true) + { + LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid)).Record!; + WorldEntity entity = live.MaterializeLiveEntity( + guid, + Cell, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = Cell, + })!; + LiveEntityAnimationState state = State(entity, partCount, scale, withSequencer); + entity.SetIndexedPartPoses( + Enumerable.Repeat(Matrix4x4.Identity, partCount).ToArray(), + Enumerable.Repeat(true, partCount).ToArray()); + record.HasPartArray = true; + live.SetAnimationRuntime(guid, state); + return new Fixture(live, record, entity, state); + } + + private static LiveEntityAnimationState State( + WorldEntity entity, + int partCount, + float scale, + bool withSequencer = false) + { + var setup = new Setup(); + for (int i = 0; i < partCount; i++) + { + setup.Parts.Add(0x01000001u + (uint)i); + setup.DefaultScale.Add(Vector3.One); + } + return new LiveEntityAnimationState + { + Entity = entity, + Setup = setup, + Animation = new Animation(), + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = scale, + PartTemplate = Enumerable.Range(0, partCount) + .Select(i => new LiveAnimationPartTemplate(0x01000001u + (uint)i, null, true)) + .ToArray(), + PartAvailability = Enumerable.Repeat(true, partCount).ToArray(), + Sequencer = withSequencer + ? new AnimationSequencer(setup, new MotionTable(), new NullLoader()) + : null, + }; + } + + private static WorldSession.EntitySpawn Spawn(uint guid) + { + var position = new CreateObject.ServerPosition(Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1); + 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: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + [], + [], + [], + null, + null, + "presenter fixture", + null, + null, + null, + PhysicsState: 0u, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class Context : ILiveAnimationPresentationContext + { + public uint LocalPlayerGuid => 0x50000001u; + public int ResolveCount { get; private set; } + public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record) + { + ResolveCount++; + return null; + } + } + + private sealed class NullLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } + + private sealed record Fixture( + LiveEntityRuntime Live, + LiveEntityRecord Record, + WorldEntity Entity, + LiveEntityAnimationState State); +} diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs index fab06134..1199c2ac 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs @@ -615,7 +615,7 @@ public sealed class LiveEntityAnimationSchedulerTests } private static (LiveEntityRuntime Live, LiveEntityRecord Record, - GameWindow.AnimatedEntity Animation) BuildHiddenAnimated(uint guid) + LiveEntityAnimationState Animation) BuildHiddenAnimated(uint guid) { var spatial = new GpuWorldState(); spatial.AddLandblock(new LoadedLandblock( @@ -640,7 +640,7 @@ public sealed class LiveEntityAnimationSchedulerTests ParentCellId = Cell, })!; var setup = new Setup(); - var animation = new GameWindow.AnimatedEntity + var animation = new LiveEntityAnimationState { Entity = entity, Setup = setup, @@ -649,7 +649,7 @@ public sealed class LiveEntityAnimationSchedulerTests HighFrame = 0, Framerate = 0f, Scale = 1f, - PartTemplate = Array.Empty(), + PartTemplate = Array.Empty(), PartAvailability = Array.Empty(), Sequencer = new AnimationSequencer( setup, @@ -662,7 +662,7 @@ public sealed class LiveEntityAnimationSchedulerTests } private static (LiveEntityRuntime Live, LiveEntityRecord Record, - GameWindow.AnimatedEntity Animation) BuildVisibleAnimatedWithPosFrames( + LiveEntityAnimationState Animation) BuildVisibleAnimatedWithPosFrames( uint guid, Quaternion? rootOrientation = null, Vector3? rootOrigin = null) @@ -694,7 +694,7 @@ public sealed class LiveEntityAnimationSchedulerTests var loader = new DictionaryAnimationLoader(animationId, datAnimation); var sequencer = new AnimationSequencer(setup, new MotionTable(), loader); Assert.True(sequencer.InitializeSetupDefaultAnimation(animationId)); - var animation = new GameWindow.AnimatedEntity + var animation = new LiveEntityAnimationState { Entity = entity, Setup = setup, @@ -705,7 +705,7 @@ public sealed class LiveEntityAnimationSchedulerTests Scale = 1f, PartTemplate = new[] { - new GameWindow.AnimatedPartTemplate( + new LiveAnimationPartTemplate( 0x0100AA01u, SurfaceOverrides: null, IsDrawable: true), diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs index 03b6fd83..eaa181d7 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -150,7 +150,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests scheduler.Tick(0.02f); scheduler.ProcessHooks(); Assert.Equal(1, captures); - Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out var frames)); + Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out var frames)); var reference = new AnimationSequencer( setup, @@ -269,7 +269,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests Assert.True(sequencer.InitializeSetupDefaultAnimation(alternateAnimationId)); scheduler.Tick(0.01f); - Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out var frames)); + Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out var frames)); Assert.True(frames[0].Origin.X >= 20f); Vector3 rotatedX = Vector3.Transform(Vector3.UnitX, entity.Rotation); Assert.InRange(rotatedX.X, -0.001f, 0.001f); @@ -312,7 +312,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests scheduler.Tick(0.1f); Assert.Equal(0, rootCommits); - Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out _)); + Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out _)); } [Fact] @@ -349,7 +349,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests scheduler.Tick(0.1f); scheduler.ProcessHooks(); - Assert.False(scheduler.TryTakeLivePartFrames(OwnerId, out _)); + Assert.False(scheduler.TryTakePreparedFramesForTest(OwnerId, out _)); Assert.Equal(0, captures); } @@ -422,7 +422,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests scheduler.Tick(0.2f); Assert.DoesNotContain("animation_done", order); - Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out _)); + Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out _)); order.Add("parts_published"); order.Add("children_updated"); scheduler.ProcessHooks(); From a2fd61684af99dfabebaf117f9a70aab3b72620c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 09:41:15 +0200 Subject: [PATCH 21/98] fix(animation): retain trailing PartArray poses Port CPartArray::UpdateParts minimum-count behavior: preserve the prior visual and rigid transforms for setup parts absent from a short authored AnimFrame, including the initial hydrated rest pose. Co-Authored-By: OpenAI Codex --- ...07-19-r6-complete-root-frame-pseudocode.md | 23 ++++ .../Rendering/LiveEntityAnimationPresenter.cs | 102 +++++++++++++----- .../RetailStaticAnimatingObjectScheduler.cs | 81 ++++++++++---- .../Physics/AnimationSequencer.cs | 56 +++++++--- .../LiveEntityAnimationPresenterTests.cs | 84 +++++++++++++++ ...tailStaticAnimatingObjectSchedulerTests.cs | 39 +++++++ 6 files changed, 326 insertions(+), 59 deletions(-) diff --git a/docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md b/docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md index b81635fc..2d3116dd 100644 --- a/docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md +++ b/docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md @@ -194,6 +194,29 @@ InterpolationManager.adjust_offset(delta, dt): relative.frame.set_heading(0) delta = relative.frame // replaces both origin and orientation +## Part-count retention (`CPartArray::UpdateParts` @ `0x005190F0`) + +```text +count = min(partArray.num_parts, animationFrame.num_parts) +for i in 0 .. count - 1: + part[i].frame = combine_scaled(animationFrame.part[i], partArray.scale) + +// No loop touches [count .. partArray.num_parts). +// Those CPhysicsPart frames therefore retain their complete prior state. +``` + +The retained state has two presentation channels in acdream: + +- the visible mesh transform retains Setup `DefaultScale`, animated frame, and + object `ObjScale`; +- the indexed rigid/effect pose retains animated orientation and the + ObjScale-adjusted origin, without Setup `DefaultScale`. + +On the first presentation, "prior" means the already-hydrated Setup/rest +frames installed by PartArray construction. A short later AnimFrame retains +the most recently presented transform in both channels. Resetting missing +parts to origin/identity is not retail behavior. + CPhysicsObj.SetPositionInternal(transition): if transition.current_cell is null: prepare_to_leave_visibility() diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs index e5673aea..5725c65c 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs @@ -181,29 +181,36 @@ internal sealed class LiveEntityAnimationPresenter { int partCount = animation.PartTemplate.Count; EmitPartDiagnostics(record, animation, sequenceFrames, partCount); + EnsureRetainedPoses(animation); List meshRefs = animation.MeshRefsScratch; meshRefs.Clear(); List rigidPoses = animation.EffectPartPosesScratch; - rigidPoses.Clear(); + List visualPoses = animation.VisualPartPosesScratch; Matrix4x4 scale = animation.Scale == 1f ? Matrix4x4.Identity : Matrix4x4.CreateScale(animation.Scale); for (int i = 0; i < partCount; i++) { - ResolvePartFrame(animation, sequenceFrames, i, out Vector3 origin, out Quaternion orientation); - Vector3 defaultScale = i < animation.Setup.DefaultScale.Count - ? animation.Setup.DefaultScale[i] - : Vector3.One; - Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale) - * Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(origin); - if (animation.Scale != 1f) - visual *= scale; - - Matrix4x4 rigid = Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(origin * animation.Scale); - rigidPoses.Add(rigid); + if (TryResolvePartFrame( + animation, + sequenceFrames, + i, + out Vector3 origin, + out Quaternion orientation)) + { + Vector3 defaultScale = i < animation.Setup.DefaultScale.Count + ? animation.Setup.DefaultScale[i] + : Vector3.One; + Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin); + if (animation.Scale != 1f) + visual *= scale; + visualPoses[i] = visual; + rigidPoses[i] = Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin * animation.Scale); + } LiveAnimationPartTemplate template = animation.PartTemplate[i]; if (!template.IsDrawable @@ -211,7 +218,7 @@ internal sealed class LiveEntityAnimationPresenter { continue; } - meshRefs.Add(new MeshRef(template.GfxObjId, visual) + meshRefs.Add(new MeshRef(template.GfxObjId, visualPoses[i]) { SurfaceOverrides = template.SurfaceOverrides, }); @@ -226,7 +233,7 @@ internal sealed class LiveEntityAnimationPresenter _effectPoses.Publish(entity, rigidPoses, animation.PartAvailability); } - private static void ResolvePartFrame( + private static bool TryResolvePartFrame( LiveEntityAnimationState animation, IReadOnlyList? sequenceFrames, int partIndex, @@ -239,13 +246,11 @@ internal sealed class LiveEntityAnimationPresenter { origin = sequenceFrames[partIndex].Origin; orientation = sequenceFrames[partIndex].Orientation; + return true; } - else - { - origin = Vector3.Zero; - orientation = Quaternion.Identity; - } - return; + origin = default; + orientation = default; + return false; } int frameIndex = (int)Math.Floor(animation.CurrFrame); @@ -270,12 +275,59 @@ internal sealed class LiveEntityAnimationPresenter var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first; origin = Vector3.Lerp(first.Origin, next.Origin, t); orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t); + return true; } - else + origin = default; + orientation = default; + return false; + } + + private static void EnsureRetainedPoses(LiveEntityAnimationState animation) + { + int partCount = animation.PartTemplate.Count; + if (animation.PresentationPosesInitialized + && animation.VisualPartPosesScratch.Count == partCount + && animation.EffectPartPosesScratch.Count == partCount) { - origin = Vector3.Zero; - orientation = Quaternion.Identity; + return; } + + List visual = animation.VisualPartPosesScratch; + List rigid = animation.EffectPartPosesScratch; + visual.Clear(); + rigid.Clear(); + bool[] consumed = new bool[animation.Entity.MeshRefs.Count]; + for (int i = 0; i < partCount; i++) + { + Matrix4x4 rigidRest = i < animation.Entity.IndexedPartTransforms.Count + ? animation.Entity.IndexedPartTransforms[i] + : Matrix4x4.Identity; + rigid.Add(rigidRest); + + LiveAnimationPartTemplate template = animation.PartTemplate[i]; + Matrix4x4 visualRest = default; + bool found = false; + for (int meshIndex = 0; meshIndex < animation.Entity.MeshRefs.Count; meshIndex++) + { + MeshRef mesh = animation.Entity.MeshRefs[meshIndex]; + if (consumed[meshIndex] || mesh.GfxObjId != template.GfxObjId) + continue; + consumed[meshIndex] = true; + visualRest = mesh.PartTransform; + found = true; + break; + } + if (!found) + { + Vector3 defaultScale = i < animation.Setup.DefaultScale.Count + ? animation.Setup.DefaultScale[i] + : Vector3.One; + visualRest = Matrix4x4.CreateScale(defaultScale * animation.Scale) + * rigidRest; + } + visual.Add(visualRest); + } + animation.PresentationPosesInitialized = true; } private void EmitSequenceDiagnostics( diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index 89367ae6..7f7cb659 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -39,6 +39,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram public readonly Frame RootFrameScratch = new(); public readonly List MeshRefs = new(); public readonly List PartPoses = new(); + public readonly List VisualPartPoses = new(); } private readonly IAnimationLoader _animationLoader; @@ -389,8 +390,8 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram private static void Compose(Owner owner, IReadOnlyList frames) { + EnsureRetainedPoses(owner); owner.MeshRefs.Clear(); - owner.PartPoses.Clear(); Matrix4x4 objectScale = owner.Entity.Scale == 1f ? Matrix4x4.Identity : Matrix4x4.CreateScale(owner.Entity.Scale); @@ -398,28 +399,27 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram int partCount = owner.Setup.Parts.Count; for (int i = 0; i < partCount; i++) { - Vector3 origin = i < frames.Count ? frames[i].Origin : Vector3.Zero; - Quaternion orientation = i < frames.Count - ? frames[i].Orientation - : Quaternion.Identity; - Vector3 defaultScale = i < owner.Setup.DefaultScale.Count - ? owner.Setup.DefaultScale[i] - : Vector3.One; + if (i < frames.Count) + { + Vector3 origin = frames[i].Origin; + Quaternion orientation = frames[i].Orientation; + Vector3 defaultScale = i < owner.Setup.DefaultScale.Count + ? owner.Setup.DefaultScale[i] + : Vector3.One; - Matrix4x4 visual = - Matrix4x4.CreateScale(defaultScale) - * Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(origin); - if (owner.Entity.Scale != 1f) - visual *= objectScale; - - owner.PartPoses.Add( - Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(origin * owner.Entity.Scale)); + Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin); + if (owner.Entity.Scale != 1f) + visual *= objectScale; + owner.VisualPartPoses[i] = visual; + owner.PartPoses[i] = Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin * owner.Entity.Scale); + } if (!owner.PartAvailable[i]) continue; - owner.MeshRefs.Add(new MeshRef(owner.PartGfxIds[i], visual) + owner.MeshRefs.Add(new MeshRef(owner.PartGfxIds[i], owner.VisualPartPoses[i]) { SurfaceOverrides = owner.SurfaceOverrides[i], }); @@ -429,6 +429,49 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram owner.Entity.SetIndexedPartPoses(owner.PartPoses, owner.PartAvailable); } + private static void EnsureRetainedPoses(Owner owner) + { + int partCount = owner.Setup.Parts.Count; + if (owner.VisualPartPoses.Count == partCount + && owner.PartPoses.Count == partCount) + { + return; + } + + owner.VisualPartPoses.Clear(); + owner.PartPoses.Clear(); + bool[] consumed = new bool[owner.Entity.MeshRefs.Count]; + for (int i = 0; i < partCount; i++) + { + Matrix4x4 rigid = i < owner.Entity.IndexedPartTransforms.Count + ? owner.Entity.IndexedPartTransforms[i] + : Matrix4x4.Identity; + owner.PartPoses.Add(rigid); + + Matrix4x4 visual = default; + bool found = false; + for (int meshIndex = 0; meshIndex < owner.Entity.MeshRefs.Count; meshIndex++) + { + MeshRef mesh = owner.Entity.MeshRefs[meshIndex]; + if (consumed[meshIndex] || mesh.GfxObjId != owner.PartGfxIds[i]) + continue; + consumed[meshIndex] = true; + visual = mesh.PartTransform; + found = true; + break; + } + if (!found) + { + Vector3 defaultScale = i < owner.Setup.DefaultScale.Count + ? owner.Setup.DefaultScale[i] + : Vector3.One; + visual = Matrix4x4.CreateScale(defaultScale * owner.Entity.Scale) + * rigid; + } + owner.VisualPartPoses.Add(visual); + } + } + private static uint[] BuildPartGfxIds(Setup setup, WorldEntity entity) { var result = new uint[setup.Parts.Count]; diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index b8abad35..f1bcb1b5 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -253,6 +253,7 @@ public sealed class AnimationSequencer // consume the returned IReadOnlyList synchronously within // the same loop iteration and never cache it across Advance() calls. private readonly PartTransform[] _partTransformScratch; + private readonly PartTransformBuffer _partTransformView; // R1-P6: root motion flows through the Advance(dt, Frame) overload // (retail CSequence::update(Frame*), 0x00525b80) — the old adapter @@ -279,6 +280,7 @@ public sealed class AnimationSequencer _mtable = motionTable; _loader = loader; _partTransformScratch = new PartTransform[_setup.Parts.Count]; + _partTransformView = new PartTransformBuffer(_partTransformScratch); _core = new CSequence(loader); _core.HookObj = new AdapterHookQueue(this); _table = new CMotionTable(motionTable); @@ -714,25 +716,20 @@ public sealed class AnimationSequencer // instead of allocating a fresh PartTransform[partCount] every call. // Sized once in the constructor to _setup.Parts.Count, which never // changes, so partCount here always matches the buffer length. + int authoredPartCount = Math.Min(partCount, f0Parts.Count); var result = _partTransformScratch; - for (int i = 0; i < partCount; i++) + for (int i = 0; i < authoredPartCount; i++) { - if (i < f0Parts.Count) - { - var p0 = f0Parts[i]; - var p1 = i < f1Parts.Count ? f1Parts[i] : p0; + var p0 = f0Parts[i]; + var p1 = i < f1Parts.Count ? f1Parts[i] : p0; - result[i] = new PartTransform( - Vector3.Lerp(p0.Origin, p1.Origin, t), - SlerpRetailClient(p0.Orientation, p1.Orientation, t)); - } - else - { - result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity); - } + result[i] = new PartTransform( + Vector3.Lerp(p0.Origin, p1.Origin, t), + SlerpRetailClient(p0.Orientation, p1.Orientation, t)); } - return result; + _partTransformView.SetCount(authoredPartCount); + return _partTransformView; } private IReadOnlyList BuildIdentityFrame(int partCount) @@ -742,7 +739,36 @@ public sealed class AnimationSequencer var result = _partTransformScratch; for (int i = 0; i < partCount; i++) result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity); - return result; + _partTransformView.SetCount(partCount); + return _partTransformView; + } + + /// + /// Allocation-free view whose Count preserves the authored AnimFrame part + /// count. Retail UpdateParts touches only that prefix and retains every + /// trailing CPhysicsPart pose. + /// + private sealed class PartTransformBuffer : IReadOnlyList + { + private readonly PartTransform[] _items; + + public PartTransformBuffer(PartTransform[] items) => _items = items; + + public int Count { get; private set; } + public PartTransform this[int index] => index >= 0 && index < Count + ? _items[index] + : throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetCount(int count) => Count = count; + + public IEnumerator GetEnumerator() + { + for (int i = 0; i < Count; i++) + yield return _items[i]; + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => + GetEnumerator(); } // R2-Q4: IsLocomotionCycleLowByte DELETED with Fix B - the cyclic-to- diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs index 9eb93aa2..2420f877 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs @@ -73,6 +73,90 @@ public sealed class LiveEntityAnimationPresenterTests Assert.Empty(replacement.EffectPartPosesScratch); } + [Fact] + public void ShortFirstFrame_RetainsDistinctVisualAndRigidRestPoses() + { + var fixture = Build(partCount: 2, scale: 2f); + Matrix4x4 visualRest = Matrix4x4.CreateScale(7f) + * Matrix4x4.CreateTranslation(8f, 9f, 10f); + Matrix4x4 rigidRest = Matrix4x4.CreateTranslation(11f, 12f, 13f); + fixture.Entity.MeshRefs = + [ + new MeshRef(0x01000001u, Matrix4x4.Identity), + new MeshRef(0x01000002u, visualRest), + ]; + fixture.Entity.SetIndexedPartPoses( + [Matrix4x4.Identity, rigidRest], + [true, true]); + var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), new Context()); + + presenter.Present(Schedules(fixture, + [new PartTransform(Vector3.UnitX, Quaternion.Identity)])); + + Assert.Equal(visualRest, fixture.Entity.MeshRefs[1].PartTransform); + Assert.Equal(rigidRest, fixture.Entity.IndexedPartTransforms[1]); + } + + [Fact] + public void LaterShortFrame_RetainsPreviouslyPresentedTrailingPart() + { + var fixture = Build(partCount: 2); + var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), new Context()); + presenter.Present(Schedules(fixture, + [ + new PartTransform(Vector3.UnitX, Quaternion.Identity), + new PartTransform(new Vector3(4f, 5f, 6f), Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f)), + ])); + Matrix4x4 visual = fixture.Entity.MeshRefs[1].PartTransform; + Matrix4x4 rigid = fixture.Entity.IndexedPartTransforms[1]; + + presenter.Present(Schedules(fixture, + [new PartTransform(Vector3.UnitY, Quaternion.Identity)])); + + Assert.Equal(visual, fixture.Entity.MeshRefs[1].PartTransform); + Assert.Equal(rigid, fixture.Entity.IndexedPartTransforms[1]); + } + + [Fact] + public void AppearanceShrink_ClearsAllTrailingPresentationState() + { + var fixture = Build(partCount: 3); + var poses = new EntityEffectPoseRegistry(); + var presenter = Presenter(fixture.Live, poses, new Context()); + presenter.Present(Schedules(fixture, + [ + new PartTransform(Vector3.UnitX, Quaternion.Identity), + new PartTransform(Vector3.UnitY, Quaternion.Identity), + new PartTransform(Vector3.UnitZ, Quaternion.Identity), + ])); + var replacementSetup = new Setup(); + replacementSetup.Parts.Add(0x01000009u); + replacementSetup.DefaultScale.Add(Vector3.One); + fixture.Entity.MeshRefs = [new MeshRef(0x01000009u, Matrix4x4.Identity)]; + fixture.Entity.SetIndexedPartPoses([Matrix4x4.Identity], [true]); + GameWindow.RebindAnimatedEntityForAppearance( + fixture.State, + fixture.Entity, + replacementSetup, + 1f, + [new LiveAnimationPartTemplate(0x01000009u, null, true)], + [true]); + + presenter.Present(Schedules(fixture, + [new PartTransform(new Vector3(9f, 0f, 0f), Quaternion.Identity)])); + + Assert.Single(fixture.Entity.MeshRefs); + Assert.Single(fixture.Entity.IndexedPartTransforms); + Assert.Single(fixture.State.VisualPartPosesScratch); + Assert.Single(fixture.State.EffectPartPosesScratch); + Assert.True(poses.TryGetPartPoseSnapshot( + fixture.Entity.Id, + out IReadOnlyList published, + out IReadOnlyList available)); + Assert.Single(published); + Assert.Single(available); + } + [Fact] public void MotionDone_OldComponentCannotResolveReplacementByGuid() { diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs index eaa181d7..2a6b903e 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -61,6 +61,45 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests Assert.Equal(2, posePublishes); } + [Fact] + public void ShortStaticAnimFrame_RetainsCompleteTrailingPartState() + { + const uint trailingGfx = GfxId; + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => { }, + (_, _, _) => { }); + Setup setup = MakeSetup(); + setup.Parts.Add(trailingGfx); + setup.DefaultScale.Add(new Vector3(3f, 4f, 5f)); + Matrix4x4 visualRest = Matrix4x4.CreateScale(6f) + * Matrix4x4.CreateTranslation(7f, 8f, 9f); + Matrix4x4 rigidRest = Matrix4x4.CreateTranslation(10f, 11f, 12f); + WorldEntity entity = MakeEntity(); + entity.MeshRefs = + [ + entity.MeshRefs[0], + new MeshRef(trailingGfx, visualRest), + ]; + entity.SetIndexedPartPoses( + [Matrix4x4.Identity, rigidRest], + [true, true]); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + + scheduler.Tick(1f / 60f); + + Assert.Equal(visualRest, entity.MeshRefs[1].PartTransform); + Assert.Equal(rigidRest, entity.IndexedPartTransforms[1]); + } + [Fact] public void SubEpsilonElapsed_IsDiscardedInsteadOfAccumulated() { From 833520253a90e8e6dd0da80ac2b014a203b8b4ea Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 10:01:25 +0200 Subject: [PATCH 22/98] fix(animation): harden presentation phase handoffs Own every borrowed PartArray frame across callbacks, version presentation state across appearance rebinding, reject recursive phase consumption, and preserve static MotionDone when only a stale visual pose is discarded. --- src/AcDream.App/Rendering/GameWindow.cs | 23 +- .../Rendering/ILiveStaticPartFrameSource.cs | 1 + .../Rendering/LiveEntityAnimationPresenter.cs | 178 +++++++------ .../Rendering/LiveEntityAnimationScheduler.cs | 20 +- .../Rendering/LiveEntityAnimationState.cs | 26 ++ .../RetailStaticAnimatingObjectScheduler.cs | 117 ++++++--- .../Physics/AnimationSequencer.cs | 7 +- .../LiveEntityAnimationPresenterTests.cs | 165 +++++++++++- .../LiveEntityAnimationSchedulerTests.cs | 25 ++ ...tailStaticAnimatingObjectSchedulerTests.cs | 235 +++++++++++++++++- 10 files changed, 657 insertions(+), 140 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4efb9c41..835d3dc8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -671,23 +671,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext public System.Numerics.Vector3 PrevServerPos; public double PrevServerPosTime; public double LastOmegaDiagLogTime; - /// - /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): own - /// throttle clock for the SEQSTATE log line in TickAnimations. - /// Previously SEQSTATE shared with - /// the OMEGA_DIAG block, which fires at 0.5s and resets the clock — - /// any remote that turned during a transition silently swallowed - /// SEQSTATE for 0.5–1.5s, masking the bug we're trying to diagnose - /// (walk↔run leg-cycle sticking on observed retail chars). Split - /// 2026-05-03 (Commit A). - /// - public double LastSeqStateLogTime; - /// - /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): own - /// throttle clock for the PARTSDIAG log line in TickAnimations - /// (D5). One log per remote per ~1s. - /// - public double LastPartsDiagLogTime; + /// /// Diagnostic-only: maximum scaled CSequence root-motion speed /// observed since the last UpdatePosition arrival. The next UP @@ -4539,7 +4523,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext }); _staticAnimationScheduler?.BindLiveOwner( entity, - staticSequencer, + registeredAnimation, staticBody); } } @@ -11540,7 +11524,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch; rootFrame.Origin = System.Numerics.Vector3.Zero; rootFrame.Orientation = System.Numerics.Quaternion.Identity; - ae.PreparedSequenceFrames = sequencer.Advance(dt, rootFrame); + ae.PreparedSequenceFrames = ae.CaptureSequenceFrames( + sequencer.Advance(dt, rootFrame)); ae.SequenceAdvancedBeforeAnimationPass = true; output.Origin = rootFrame.Origin; diff --git a/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs b/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs index 58f20787..aa511fb1 100644 --- a/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs +++ b/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs @@ -17,5 +17,6 @@ internal interface ILiveStaticPartFrameSource LiveEntityAnimationState animation, ulong objectClockEpoch, ulong projectionMutationVersion, + ulong presentationRevision, out IReadOnlyList frames); } diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs index 5725c65c..3bc51b3a 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs @@ -19,6 +19,7 @@ internal sealed class LiveEntityAnimationPresenter private readonly AnimationPresentationDiagnostics _diagnostics; private readonly int _hidePartIndex; private readonly List _snapshot = new(); + private bool _isPresenting; public LiveEntityAnimationPresenter( Func liveEntities, @@ -79,94 +80,107 @@ internal sealed class LiveEntityAnimationPresenter { ArgumentNullException.ThrowIfNull(schedules); LiveEntityRuntime? runtime = _liveEntities(); - if (runtime is null) + if (runtime is null || _isPresenting) return; - // Private snapshot: EffectPoseChanged is synchronous and may perform a - // nested runtime-view enumeration. Never borrow that view's scratch. - runtime.CopySpatialRootObjectRecordsTo(_snapshot); - foreach (LiveEntityRecord record in _snapshot) + // EffectPoseChanged is synchronous. Presentation is one consuming + // object-frame phase; recursive entry cannot consume legacy elapsed or + // a static prepared frame a second time. + _isPresenting = true; + try { - if (!TryGetCurrent(runtime, record, out WorldEntity entity, out LiveEntityAnimationState animation)) - continue; - - PrepareAnimation(record, animation); - if (!TryGetCurrent(runtime, record, entity, animation)) - continue; - - schedules.TryGetValue(entity.Id, out LiveEntityAnimationSchedule schedule); - bool hasOrdinarySchedule = IsCurrentSchedule(runtime, schedule, record, entity, animation); - IReadOnlyList? sequenceFrames = hasOrdinarySchedule - ? schedule.SequenceFrames - : null; - bool composeParts = hasOrdinarySchedule && schedule.ComposeParts; - float legacyAdvanceSeconds = hasOrdinarySchedule - ? schedule.LegacyAdvanceSeconds - : 0f; - ulong objectClockEpoch = record.ObjectClockEpoch; - ulong projectionVersion = record.ProjectionMutationVersion; - - if (_staticFrames()?.TryTakeLivePartFrames( - record, - entity, - animation, - objectClockEpoch, - projectionVersion, - out IReadOnlyList staticPartFrames) == true) + runtime.CopySpatialRootObjectRecordsTo(_snapshot); + foreach (LiveEntityRecord record in _snapshot) { - if (!TryGetCurrent( + if (!TryGetCurrent(runtime, record, out WorldEntity entity, out LiveEntityAnimationState animation)) + continue; + + PrepareAnimation(record, animation); + if (!TryGetCurrent(runtime, record, entity, animation)) + continue; + + schedules.TryGetValue(entity.Id, out LiveEntityAnimationSchedule schedule); + bool hasOrdinarySchedule = IsCurrentSchedule(runtime, schedule, record, entity, animation); + IReadOnlyList? sequenceFrames = hasOrdinarySchedule + ? schedule.SequenceFrames + : null; + bool composeParts = hasOrdinarySchedule && schedule.ComposeParts; + float legacyAdvanceSeconds = hasOrdinarySchedule + ? schedule.LegacyAdvanceSeconds + : 0f; + ulong objectClockEpoch = record.ObjectClockEpoch; + ulong projectionVersion = record.ProjectionMutationVersion; + ulong presentationRevision = animation.PresentationRevision; + + if (_staticFrames()?.TryTakeLivePartFrames( + record, + entity, + animation, + objectClockEpoch, + projectionVersion, + presentationRevision, + out IReadOnlyList staticPartFrames) == true) + { + if (!TryGetCurrent( + runtime, + record, + entity, + animation, + objectClockEpoch, + projectionVersion, + presentationRevision)) + { + continue; + } + sequenceFrames = staticPartFrames; + composeParts = true; + } + + if (animation.Sequencer is not null) + { + EmitSequenceDiagnostics(record, animation, sequenceFrames); + } + else + { + int span = animation.HighFrame - animation.LowFrame; + bool hiddenLegacy = (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0; + if (span <= 0 && !hiddenLegacy) + continue; + if (span > 0 && legacyAdvanceSeconds > 0f) + { + animation.CurrFrame += legacyAdvanceSeconds * animation.Framerate; + if (animation.CurrFrame > animation.HighFrame) + { + float over = animation.CurrFrame - animation.LowFrame; + animation.CurrFrame = animation.LowFrame + (over % (span + 1)); + } + else if (animation.CurrFrame < animation.LowFrame) + { + animation.CurrFrame = animation.LowFrame; + } + } + } + + if (!composeParts + || !TryGetCurrent( runtime, record, entity, animation, objectClockEpoch, - projectionVersion)) + projectionVersion, + presentationRevision)) { continue; } - sequenceFrames = staticPartFrames; - composeParts = true; - } - if (animation.Sequencer is not null) - { - EmitSequenceDiagnostics(record, animation, sequenceFrames); + ComposeAndPublish(runtime, record, entity, animation, sequenceFrames, + objectClockEpoch, projectionVersion, presentationRevision); } - else - { - int span = animation.HighFrame - animation.LowFrame; - bool hiddenLegacy = (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0; - if (span <= 0 && !hiddenLegacy) - continue; - if (span > 0 && legacyAdvanceSeconds > 0f) - { - animation.CurrFrame += legacyAdvanceSeconds * animation.Framerate; - if (animation.CurrFrame > animation.HighFrame) - { - float over = animation.CurrFrame - animation.LowFrame; - animation.CurrFrame = animation.LowFrame + (over % (span + 1)); - } - else if (animation.CurrFrame < animation.LowFrame) - { - animation.CurrFrame = animation.LowFrame; - } - } - } - - if (!composeParts - || !TryGetCurrent( - runtime, - record, - entity, - animation, - objectClockEpoch, - projectionVersion)) - { - continue; - } - - ComposeAndPublish(runtime, record, entity, animation, sequenceFrames, - objectClockEpoch, projectionVersion); + } + finally + { + _isPresenting = false; } } @@ -177,7 +191,8 @@ internal sealed class LiveEntityAnimationPresenter LiveEntityAnimationState animation, IReadOnlyList? sequenceFrames, ulong objectClockEpoch, - ulong projectionVersion) + ulong projectionVersion, + ulong presentationRevision) { int partCount = animation.PartTemplate.Count; EmitPartDiagnostics(record, animation, sequenceFrames, partCount); @@ -224,11 +239,11 @@ internal sealed class LiveEntityAnimationPresenter }); } - if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion)) + if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion, presentationRevision)) return; entity.MeshRefs = meshRefs; entity.SetIndexedPartPoses(rigidPoses, animation.PartAvailability); - if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion)) + if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion, presentationRevision)) return; _effectPoses.Publish(entity, rigidPoses, animation.PartAvailability); } @@ -415,7 +430,8 @@ internal sealed class LiveEntityAnimationPresenter entity, animation, schedule.ObjectClockEpoch, - schedule.ProjectionMutationVersion); + schedule.ProjectionMutationVersion, + schedule.PresentationRevision); private static bool TryGetCurrent( LiveEntityRuntime runtime, @@ -448,8 +464,10 @@ internal sealed class LiveEntityAnimationPresenter WorldEntity entity, LiveEntityAnimationState animation, ulong objectClockEpoch, - ulong projectionVersion) => + ulong projectionVersion, + ulong presentationRevision) => TryGetCurrent(runtime, record, entity, animation) && record.ObjectClockEpoch == objectClockEpoch - && record.ProjectionMutationVersion == projectionVersion; + && record.ProjectionMutationVersion == projectionVersion + && animation.PresentationRevision == presentationRevision; } diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs index 7ff42e05..5e156244 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs @@ -139,13 +139,18 @@ internal sealed class LiveEntityAnimationScheduler projectile, objectClockEpoch)) { + IReadOnlyList? ownedFrames = schedule.SequenceFrames is { } produced + ? animation.CaptureScheduleFrames(produced) + : null; _schedules[entity.Id] = schedule with { + SequenceFrames = ownedFrames, Record = record, Entity = entity, Animation = animation, ObjectClockEpoch = objectClockEpoch, ProjectionMutationVersion = record.ProjectionMutationVersion, + PresentationRevision = animation.PresentationRevision, }; } } @@ -217,7 +222,9 @@ internal sealed class LiveEntityAnimationScheduler } return new LiveEntityAnimationSchedule( - prepared ?? (composeHidden ? sequencer?.SampleCurrentPose() : null), + prepared ?? (composeHidden && sequencer is not null + ? animation?.CaptureSequenceFrames(sequencer.SampleCurrentPose()) + : null), 0f, ComposeParts: advanced || composeHidden); } @@ -311,7 +318,9 @@ internal sealed class LiveEntityAnimationScheduler rootFrame.Origin = Vector3.Zero; rootFrame.Orientation = Quaternion.Identity; if (sequencer is not null) - frames = sequencer.Advance(quantum, rootFrame); + frames = animation?.CaptureSequenceFrames( + sequencer.Advance(quantum, rootFrame)) + ?? sequencer.Advance(quantum, rootFrame); else if (animation is not null) legacyElapsed += quantum; @@ -441,7 +450,9 @@ internal sealed class LiveEntityAnimationScheduler if (hidden) { return new LiveEntityAnimationSchedule( - sequencer?.SampleCurrentPose(), + sequencer is not null && animation is not null + ? animation.CaptureSequenceFrames(sequencer.SampleCurrentPose()) + : null, 0f, ComposeParts: animation is not null); } @@ -531,4 +542,5 @@ internal readonly record struct LiveEntityAnimationSchedule( WorldEntity? Entity = null, LiveEntityAnimationState? Animation = null, ulong ObjectClockEpoch = 0UL, - ulong ProjectionMutationVersion = 0UL); + ulong ProjectionMutationVersion = 0UL, + ulong PresentationRevision = 0UL); diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationState.cs b/src/AcDream.App/Rendering/LiveEntityAnimationState.cs index 1d145e85..ebf23377 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationState.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationState.cs @@ -34,6 +34,8 @@ internal sealed class LiveEntityAnimationState : ILiveEntityAnimationRuntime public bool SequenceAdvancedBeforeAnimationPass; public readonly Frame RootMotionScratch = new(); public readonly MotionDeltaFrame RootMotionDeltaScratch = new(); + public readonly List SequenceFramesScratch = new(); + public readonly List ScheduleFramesScratch = new(); /// Reusable compact drawable channel, consumed before draw. public readonly List MeshRefsScratch = new(); @@ -45,16 +47,40 @@ internal sealed class LiveEntityAnimationState : ILiveEntityAnimationRuntime /// public readonly List VisualPartPosesScratch = new(); public bool PresentationPosesInitialized; + public ulong PresentationRevision { get; private set; } = 1UL; public double LastSequenceDiagnosticTime; public double LastPartDiagnosticTime; public void InvalidatePresentationPoses() { + PresentationRevision++; + if (PresentationRevision == 0UL) + PresentationRevision++; PresentationPosesInitialized = false; VisualPartPosesScratch.Clear(); EffectPartPosesScratch.Clear(); } + + public IReadOnlyList CaptureSequenceFrames( + IReadOnlyList source) + { + ArgumentNullException.ThrowIfNull(source); + SequenceFramesScratch.Clear(); + for (int i = 0; i < source.Count; i++) + SequenceFramesScratch.Add(source[i]); + return SequenceFramesScratch; + } + + public IReadOnlyList CaptureScheduleFrames( + IReadOnlyList source) + { + ArgumentNullException.ThrowIfNull(source); + ScheduleFramesScratch.Clear(); + for (int i = 0; i < source.Count; i++) + ScheduleFramesScratch.Add(source[i]); + return ScheduleFramesScratch; + } } internal readonly record struct LiveAnimationPartTemplate( diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index 7f7cb659..3c4ca4ea 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -34,7 +34,11 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram public PhysicsBody? Body; public AnimationSequencer? PendingProcessHooks; public ulong PendingResidencyVersion; - public IReadOnlyList? PreparedLivePartFrames; + public readonly List PreparedLivePartFrames = new(); + public bool HasPreparedLivePartFrames; + public AnimationSequencer? PreparedFrameSequencer; + public ulong PreparedPresentationRevision; + public LiveEntityAnimationState? LiveAnimation; public double ElapsedSinceUpdate; public readonly Frame RootFrameScratch = new(); public readonly List MeshRefs = new(); @@ -137,11 +141,11 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram /// public bool BindLiveOwner( WorldEntity entity, - AnimationSequencer sequencer, + LiveEntityAnimationState animation, PhysicsBody body) { ArgumentNullException.ThrowIfNull(entity); - ArgumentNullException.ThrowIfNull(sequencer); + ArgumentNullException.ThrowIfNull(animation); ArgumentNullException.ThrowIfNull(body); if (!_owners.TryGetValue(entity.Id, out Owner? owner)) return false; @@ -150,10 +154,12 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram throw new InvalidOperationException( $"Static animation owner 0x{entity.Id:X8} does not match this live incarnation."); } - if (!sequencer.HasCurrentNode) + if (animation.Sequencer is not { HasCurrentNode: true } sequencer) return false; + InvalidatePending(owner); owner.Sequencer = sequencer; + owner.LiveAnimation = animation; owner.Body = body; return true; } @@ -169,33 +175,64 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram LiveEntityAnimationState animation, ulong objectClockEpoch, ulong projectionMutationVersion, + ulong presentationRevision, out IReadOnlyList frames) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(animation); uint ownerId = entity.Id; - if (_owners.TryGetValue(ownerId, out Owner? owner) - && ReferenceEquals(record.WorldEntity, entity) + if (!_owners.TryGetValue(ownerId, out Owner? owner)) + { + frames = Array.Empty(); + return false; + } + + bool exactOwner = ReferenceEquals(record.WorldEntity, entity) && ReferenceEquals(record.AnimationRuntime, animation) && record.ObjectClockEpoch == objectClockEpoch && record.ProjectionMutationVersion == projectionMutationVersion + && animation.PresentationRevision == presentationRevision && ReferenceEquals(owner.Entity, entity) + && ReferenceEquals(owner.LiveAnimation, animation) && ReferenceEquals(owner.Sequencer, animation.Sequencer) - && owner.Entity.ServerGuid != 0 - && owner.PreparedLivePartFrames is { } prepared - && IsResidentAtVersion(owner, owner.PendingResidencyVersion)) + && owner.Entity.ServerGuid != 0; + if (!exactOwner) { - owner.PreparedLivePartFrames = null; - frames = prepared; - return true; + InvalidatePending(owner); + frames = Array.Empty(); + return false; } - if (_owners.TryGetValue(ownerId, out owner)) - InvalidatePending(owner); + // An already-consumed frame is a normal same-phase recursive query; + // process_hooks still belongs to the outer presentation tail. + if (!owner.HasPreparedLivePartFrames) + { + frames = Array.Empty(); + return false; + } - frames = Array.Empty(); - return false; + if (!ReferenceEquals(owner.PreparedFrameSequencer, animation.Sequencer) + || !IsResidentAtVersion(owner, owner.PendingResidencyVersion)) + { + InvalidatePending(owner); + frames = Array.Empty(); + return false; + } + + if (owner.PreparedPresentationRevision != presentationRevision) + { + // Appearance rebinding invalidates only the prepared visual pose. + // The exact live owner and sequencer still own process_hooks from + // this quantum, including MotionDone, so retain that semantic tail. + InvalidatePreparedFrame(owner); + frames = Array.Empty(); + return false; + } + + owner.HasPreparedLivePartFrames = false; + frames = owner.PreparedLivePartFrames; + return true; } /// @@ -207,18 +244,25 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram uint ownerId, out IReadOnlyList frames) { - if (_owners.TryGetValue(ownerId, out Owner? owner) - && owner.PreparedLivePartFrames is { } prepared - && IsResidentAtVersion(owner, owner.PendingResidencyVersion)) + if (!_owners.TryGetValue(ownerId, out Owner? owner)) { - owner.PreparedLivePartFrames = null; - frames = prepared; - return true; + frames = Array.Empty(); + return false; } - if (_owners.TryGetValue(ownerId, out owner)) + if (!owner.HasPreparedLivePartFrames) + { + frames = Array.Empty(); + return false; + } + if (!IsResidentAtVersion(owner, owner.PendingResidencyVersion)) + { InvalidatePending(owner); - frames = Array.Empty(); - return false; + frames = Array.Empty(); + return false; + } + owner.HasPreparedLivePartFrames = false; + frames = owner.PreparedLivePartFrames; + return true; } public void Unregister(uint ownerId) => _owners.Remove(ownerId); @@ -273,8 +317,10 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram continue; } - IReadOnlyList frames = sequencer.Advance( - (float)ownerElapsed); + IReadOnlyList frames = sequencer.Advance((float)ownerElapsed); + owner.PreparedLivePartFrames.Clear(); + for (int i = 0; i < frames.Count; i++) + owner.PreparedLivePartFrames.Add(frames[i]); if (owner.Body is { } body) { @@ -319,11 +365,14 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram if (owner.Entity.ServerGuid != 0) { - owner.PreparedLivePartFrames = frames; + owner.HasPreparedLivePartFrames = true; + owner.PreparedFrameSequencer = sequencer; + owner.PreparedPresentationRevision = + owner.LiveAnimation?.PresentationRevision ?? 0UL; } else { - Compose(owner, frames); + Compose(owner, owner.PreparedLivePartFrames); _publishPartPoses(owner.Entity, owner.PartPoses, owner.PartAvailable); if (!_owners.TryGetValue(owner.Entity.Id, out current) || !ReferenceEquals(current, owner) @@ -383,11 +432,19 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram private static void InvalidatePending(Owner owner) { - owner.PreparedLivePartFrames = null; + InvalidatePreparedFrame(owner); owner.PendingProcessHooks = null; owner.PendingResidencyVersion = 0UL; } + private static void InvalidatePreparedFrame(Owner owner) + { + owner.PreparedLivePartFrames.Clear(); + owner.HasPreparedLivePartFrames = false; + owner.PreparedFrameSequencer = null; + owner.PreparedPresentationRevision = 0UL; + } + private static void Compose(Owner owner, IReadOnlyList frames) { EnsureRetainedPoses(owner); diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index f1bcb1b5..676c20f8 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -248,10 +248,9 @@ public sealed class AnimationSequencer // allocation instead of one `new PartTransform[partCount]` per Advance() // call (every animated entity, every tick, including idle NPCs on a // breathe cycle). BuildBlendedFrame/BuildIdentityFrame overwrite every - // slot before returning this array, so the values are bit-identical to - // the old fresh-array version; callers (GameWindow.TickAnimations) - // consume the returned IReadOnlyList synchronously within - // the same loop iteration and never cache it across Advance() calls. + // authored slot before returning this view. The view is borrowed only for + // the producing call: App schedulers copy its authored prefix into their + // incarnation-owned handoff buffer before any callback or later phase. private readonly PartTransform[] _partTransformScratch; private readonly PartTransformBuffer _partTransformView; diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs index 2420f877..7c1dd26d 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs @@ -157,6 +157,36 @@ public sealed class LiveEntityAnimationPresenterTests Assert.Single(available); } + [Fact] + public void SchedulePreparedBeforeAppearanceRebind_IsRejected() + { + var fixture = Build(partCount: 2); + LiveEntityAnimationSchedule stale = Schedule(fixture, + [ + new PartTransform(Vector3.UnitX, Quaternion.Identity), + new PartTransform(Vector3.UnitY, Quaternion.Identity), + ]); + var replacement = new Setup(); + replacement.Parts.Add(0x01000099u); + replacement.DefaultScale.Add(Vector3.One); + GameWindow.RebindAnimatedEntityForAppearance( + fixture.State, + fixture.Entity, + replacement, + 1f, + [new LiveAnimationPartTemplate(0x01000099u, null, true)], + [true]); + var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), new Context()); + + presenter.Present(new Dictionary + { + [fixture.Entity.Id] = stale, + }); + + Assert.Empty(fixture.State.MeshRefsScratch); + Assert.Empty(fixture.State.EffectPartPosesScratch); + } + [Fact] public void MotionDone_OldComponentCannotResolveReplacementByGuid() { @@ -173,6 +203,22 @@ public sealed class LiveEntityAnimationPresenterTests Assert.Equal(0, context.ResolveCount); } + [Fact] + public void MotionDone_SameComponentStillResolvesAfterObjectClockRebase() + { + var fixture = Build(partCount: 1, withSequencer: true); + var context = new Context(); + var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), context); + presenter.PrepareAnimation(fixture.Record, fixture.State); + + fixture.Record.SuspendObjectClock(); + fixture.Record.ResetObjectClockForEnterWorld(isStatic: false); + fixture.State.Sequencer!.MotionDoneTarget!(0x10000001u, true); + + Assert.Equal(1, context.ResolveCount); + Assert.Same(fixture.Record, context.LastRecord); + } + [Fact] public void EffectPoseCallback_NestedRuntimeSnapshotDoesNotTruncatePresentation() { @@ -200,6 +246,120 @@ public sealed class LiveEntityAnimationPresenterTests Assert.Single(second.Entity.MeshRefs); } + [Fact] + public void EffectPoseCallback_ReplacesNextOwnerWithoutPublishingItsStaleSchedule() + { + var first = Build(partCount: 1, guid: Guid); + var second = Add(first.Live, Guid + 1, partCount: 1); + LiveEntityAnimationState replacement = State(second.Entity, 1, 1f, withSequencer: true); + var poses = new EntityEffectPoseRegistry(); + bool replaced = false; + poses.EffectPoseChanged += ownerId => + { + if (ownerId != first.Entity.Id || replaced) + return; + replaced = true; + Assert.True(first.Live.ClearAnimationRuntime(Guid + 1)); + first.Live.SetAnimationRuntime(Guid + 1, replacement); + }; + var presenter = Presenter(first.Live, poses, new Context()); + var schedules = new Dictionary + { + [first.Entity.Id] = Schedule(first, + [new PartTransform(Vector3.UnitX, Quaternion.Identity)]), + [second.Entity.Id] = Schedule(second, + [new PartTransform(new Vector3(99f, 0f, 0f), Quaternion.Identity)]), + }; + + presenter.Present(schedules); + + Assert.True(replaced); + Assert.Empty(replacement.MeshRefsScratch); + Assert.Empty(replacement.EffectPartPosesScratch); + } + + [Fact] + public void EffectPoseCallback_RecursivePresentDoesNotTruncateOuterPass() + { + var first = Build(partCount: 1, guid: Guid); + var second = Add(first.Live, Guid + 1, partCount: 1); + var poses = new EntityEffectPoseRegistry(); + var presenter = Presenter(first.Live, poses, new Context()); + var schedules = new Dictionary + { + [first.Entity.Id] = Schedule(first, + [new PartTransform(Vector3.UnitX, Quaternion.Identity)]), + [second.Entity.Id] = Schedule(second, + [new PartTransform(Vector3.UnitY, Quaternion.Identity)]), + }; + bool recursed = false; + poses.EffectPoseChanged += _ => + { + if (recursed) + return; + recursed = true; + presenter.Present(schedules); + }; + + presenter.Present(schedules); + + Assert.True(recursed); + Assert.Single(first.Entity.MeshRefs); + Assert.Single(second.Entity.MeshRefs); + } + + [Fact] + public void EffectPoseCallback_RecursivePresentConsumesLegacyElapsedOnce() + { + var fixture = Build(partCount: 1); + fixture.State.Sequencer = null; + fixture.State.LowFrame = 0; + fixture.State.HighFrame = 1; + fixture.State.Framerate = 1f; + fixture.State.CurrFrame = 0f; + var animation = new Animation(); + for (int i = 0; i < 2; i++) + { + var frame = new AnimationFrame(1); + frame.Frames.Add(new Frame + { + Origin = new Vector3(i, 0f, 0f), + Orientation = Quaternion.Identity, + }); + animation.PartFrames.Add(frame); + } + fixture.State.Animation = animation; + var schedule = new LiveEntityAnimationSchedule( + SequenceFrames: null, + LegacyAdvanceSeconds: 0.5f, + ComposeParts: true, + fixture.Record, + fixture.Entity, + fixture.State, + fixture.Record.ObjectClockEpoch, + fixture.Record.ProjectionMutationVersion, + fixture.State.PresentationRevision); + var schedules = new Dictionary + { + [fixture.Entity.Id] = schedule, + }; + var poses = new EntityEffectPoseRegistry(); + var presenter = Presenter(fixture.Live, poses, new Context()); + bool recursed = false; + poses.EffectPoseChanged += _ => + { + if (recursed) + return; + recursed = true; + presenter.Present(schedules); + }; + + presenter.Present(schedules); + + Assert.True(recursed); + Assert.Equal(0.5f, fixture.State.CurrFrame); + } + private static LiveEntityAnimationPresenter Presenter( LiveEntityRuntime live, EntityEffectPoseRegistry poses, @@ -229,7 +389,8 @@ public sealed class LiveEntityAnimationPresenterTests fixture.Entity, fixture.State, fixture.Record.ObjectClockEpoch, - fixture.Record.ProjectionMutationVersion); + fixture.Record.ProjectionMutationVersion, + fixture.State.PresentationRevision); private static Fixture Build( int partCount, @@ -359,9 +520,11 @@ public sealed class LiveEntityAnimationPresenterTests { public uint LocalPlayerGuid => 0x50000001u; public int ResolveCount { get; private set; } + public LiveEntityRecord? LastRecord { get; private set; } public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record) { ResolveCount++; + LastRecord = record; return null; } } diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs index 1199c2ac..ca14c642 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs @@ -110,6 +110,31 @@ public sealed class LiveEntityAnimationSchedulerTests Assert.True(animation.Entity.Position.X > startX + 1f); } + [Fact] + public void ScheduleOwnsPartFramesAcrossLaterSequencerSampling() + { + var (live, _, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid); + LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid); + LiveEntityAnimationSchedule schedule = scheduler.Tick( + 0.1f, + animation.Entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0)[animation.Entity.Id]; + PartTransform retained = schedule.SequenceFrames![0]; + animation.CaptureSequenceFrames( + [new PartTransform(new Vector3(77f, 76f, 75f), Quaternion.Identity)]); + foreach (AnimationFrame frame in animation.Animation.PartFrames) + frame.Frames[0].Origin = new Vector3(99f, 98f, 97f); + + IReadOnlyList borrowed = animation.Sequencer!.SampleCurrentPose(); + + Assert.Equal(new Vector3(99f, 98f, 97f), borrowed[0].Origin); + Assert.Equal(retained.Origin, schedule.SequenceFrames[0].Origin); + Assert.NotEqual(animation.SequenceFramesScratch[0].Origin, schedule.SequenceFrames[0].Origin); + Assert.NotEqual(borrowed[0].Origin, schedule.SequenceFrames[0].Origin); + } + [Fact] public void BodylessNonWalkableAnimation_SuppressesRootOriginButPreservesOrientation() { diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs index 2a6b903e..7d3954ea 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -1,6 +1,9 @@ using System.Numerics; using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using DatReaderWriter.DBObjs; @@ -451,7 +454,10 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests Omega = new Vector3(0f, 0f, MathF.PI / 2f), }; body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero); - Assert.True(scheduler.BindLiveOwner(entity, sequencer, body)); + Assert.True(scheduler.BindLiveOwner( + entity, + LiveState(entity, setup, sequencer), + body)); sequencer.MotionDoneTarget = (_, success) => { Assert.True(success); @@ -475,6 +481,154 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests Assert.Empty(sequencer.Manager.PendingAnimations); } + [Fact] + public void RebindingLiveOwner_DiscardsFramesPreparedByPriorSequencer() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => { }, + (_, _, _) => { }); + Setup setup = MakeSetup(); + WorldEntity entity = MakeEntity(serverGuid: 0x70000001u); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + AnimationSequencer first = BindLiveOwner( + scheduler, entity, setup, loader, out PhysicsBody body); + scheduler.Tick(0.02f); + AnimationSequencer second = new(setup, new MotionTable(), loader); + + Assert.NotSame(first, second); + Assert.True(scheduler.BindLiveOwner( + entity, + LiveState(entity, setup, second), + body)); + Assert.False(scheduler.TryTakePreparedFramesForTest(OwnerId, out _)); + } + + [Fact] + public void RepeatedTakeAfterConsumption_PreservesPendingProcessHooks() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + int captures = 0; + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => captures++, + (_, _, _) => { }); + Setup setup = MakeSetup(); + WorldEntity entity = MakeEntity(serverGuid: 0x70000001u); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + BindLiveOwner(scheduler, entity, setup, loader, out _); + scheduler.Tick(0.02f); + + Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out _)); + Assert.False(scheduler.TryTakePreparedFramesForTest(OwnerId, out _)); + scheduler.ProcessHooks(); + + Assert.Equal(1, captures); + } + + [Fact] + public void AppearanceRevisionRejectsPreparedPose_ButPreservesMotionDone() + { + const uint style = 0x8000003Eu; + const uint readyMotion = 0x41000003u; + const uint actionMotion = 0x10000058u; + const uint actionAnimationId = 0x0300AA03u; + const uint serverGuid = 0x70000001u; + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + loader.Add(actionAnimationId, OffsetAnimation(20f)); + Setup setup = MakeSetup(); + var table = new MotionTable + { + DefaultStyle = (DRWMotionCommand)style, + }; + table.StyleDefaults[(DRWMotionCommand)style] = + (DRWMotionCommand)readyMotion; + int readyKey = unchecked((int)((style << 16) | (readyMotion & 0xFFFFFFu))); + table.Cycles[readyKey] = MakeMotionData(AnimationId); + var links = new MotionCommandData(); + links.MotionData[unchecked((int)actionMotion)] = + MakeMotionData(actionAnimationId); + table.Links[readyKey] = links; + + WorldEntity entity = MakeEntity(serverGuid); + var sequencer = new AnimationSequencer(setup, table, loader); + sequencer.SetCycle(style, readyMotion); + sequencer.ConsumePendingHooks(); + sequencer.PlayAction(actionMotion); + var poses = new EntityEffectPoseRegistry(); + poses.Publish(entity, entity.IndexedPartTransforms, entity.IndexedPartAvailable); + var hookFrames = new AnimationHookFrameQueue( + new AnimationHookRouter(), + poses); + int captures = 0; + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (ownerId, ownerSequencer) => + { + captures++; + hookFrames.Capture(ownerId, ownerSequencer); + }, + (_, _, _) => { }); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + var body = new PhysicsBody + { + Orientation = entity.Rotation, + }; + body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero); + LiveEntityAnimationState state = LiveState(entity, setup, sequencer); + Assert.True(scheduler.BindLiveOwner(entity, state, body)); + var record = new LiveEntityRecord(Spawn(serverGuid)) + { + WorldEntity = entity, + AnimationRuntime = state, + }; + int motionDone = 0; + sequencer.MotionDoneTarget = (_, success) => + { + Assert.True(success); + motionDone++; + }; + + scheduler.Tick(0.2f); + state.InvalidatePresentationPoses(); + + Assert.False(scheduler.TryTakeLivePartFrames( + record, + entity, + state, + record.ObjectClockEpoch, + record.ProjectionMutationVersion, + state.PresentationRevision, + out _)); + scheduler.ProcessHooks(); + + Assert.Equal(1, captures); + Assert.True(motionDone > 0); + Assert.Empty(sequencer.Manager.PendingAnimations); + } + private static Setup MakeSetup() { var setup = new Setup(); @@ -503,6 +657,61 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests return entity; } + private static WorldSession.EntitySpawn Spawn(uint guid) + { + const uint cell = 0x01010001u; + var position = new CreateObject.ServerPosition( + cell, + 0f, + 0f, + 0f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1); + 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: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + [], + [], + [], + null, + null, + "static appearance fixture", + null, + null, + null, + PhysicsState: 0u, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + private static Animation TwoFrameAnimation() { var animation = new Animation(); @@ -565,10 +774,32 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests Orientation = entity.Rotation, }; body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero); - Assert.True(scheduler.BindLiveOwner(entity, sequencer, body)); + Assert.True(scheduler.BindLiveOwner( + entity, + LiveState(entity, setup, sequencer), + body)); return sequencer; } + private static LiveEntityAnimationState LiveState( + WorldEntity entity, + Setup setup, + AnimationSequencer sequencer) => new() + { + Entity = entity, + Setup = setup, + Animation = new Animation(), + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = entity.Scale, + PartTemplate = setup.Parts.Select( + part => new LiveAnimationPartTemplate((uint)part, null, true)) + .ToArray(), + PartAvailability = Enumerable.Repeat(true, setup.Parts.Count).ToArray(), + Sequencer = sequencer, + }; + private sealed class Loader : IAnimationLoader { private readonly Dictionary _animations = new(); From 9a150e242169600ef7753e34072c83d268800b14 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 10:03:20 +0200 Subject: [PATCH 23/98] docs(architecture): close live animation slice Record the shipped presentation owner, exact frame-handoff lifetime, retail short-frame behavior, current GameWindow metrics, and Slice 3 as the next structural boundary. --- AGENTS.md | 13 ++++--- CLAUDE.md | 13 ++++--- docs/architecture/code-structure.md | 19 +++++++--- docs/plans/2026-04-11-roadmap.md | 16 ++++++--- docs/plans/2026-05-12-milestones.md | 7 ++-- ...dow-slice-2-live-animation-presentation.md | 13 ++++++- memory/project_animation_runtime.md | 36 +++++++++++++++++++ 7 files changed, 94 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ce61024d..6a5b179b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,16 +115,19 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 6,558 tests pass / 5 skip (17 tracked warnings, #228). +Release builds; 6,575 tests pass / 5 skip (17 tracked warnings, #228). **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. Selection/interaction Slice 1 is complete: `WorldSelectionQuery` and `SelectionInteractionController` own the domain, exact-incarnation queued work, -and retail's shared inventory-request transaction. Slice 2 finishes live -animation presentation; later slices finish live-session and live-entity App -integration, landblock presentation, update/render orchestration, and -composition cleanup. +and retail's shared inventory-request transaction. Live-animation-presentation +Slice 2 is also complete: `LiveEntityAnimationPresenter` owns final visual and +rigid PartArray composition, effect poses, and MotionDone; scheduler handoffs +are incarnation-, clock-, projection-, and appearance-bound. `GameWindow` is +14,546 lines, 277 fields, and 190 methods. Slice 3 completes live-session +ownership next; later slices finish live-entity App integration, landblock +presentation, update/render orchestration, and composition cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/CLAUDE.md b/CLAUDE.md index 0ba9129b..2ed93be2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,16 +113,19 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 6,558 tests pass / 5 skip (17 tracked warnings, #228). +Release builds; 6,575 tests pass / 5 skip (17 tracked warnings, #228). **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. Selection/interaction Slice 1 is complete: `WorldSelectionQuery` and `SelectionInteractionController` own the domain, exact-incarnation queued work, -and retail's shared inventory-request transaction. Slice 2 finishes live -animation presentation; later slices finish live-session and live-entity App -integration, landblock presentation, update/render orchestration, and -composition cleanup. +and retail's shared inventory-request transaction. Live-animation-presentation +Slice 2 is also complete: `LiveEntityAnimationPresenter` owns final visual and +rigid PartArray composition, effect poses, and MotionDone; scheduler handoffs +are incarnation-, clock-, projection-, and appearance-bound. `GameWindow` is +14,546 lines, 277 fields, and 190 methods. Slice 3 completes live-session +ownership next; later slices finish live-entity App integration, landblock +presentation, update/render orchestration, and composition cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 633ea2b4..cb96795c 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -1,7 +1,7 @@ # acdream — code structure & extraction sequence **Status:** Living document. Created 2026-05-16; implementation reconciliation -completed 2026-07-21; Slice 1 landed the same day. This is the active +completed 2026-07-21; Slices 1 and 2 landed the same day. This is the active structural program before new M4 subsystems enter the App layer. **Purpose:** Describe the desired structural state of the App layer, explain the rules we've adopted, and lay out the safe extraction @@ -24,6 +24,7 @@ The structural debt is concentrated in **one file**: ``` baseline cf50ee3d 15,723 lines / 278 fields / 205 methods after Slice 1 14,912 lines / 278 fields / 191 methods +after Slice 2 14,546 lines / 277 fields / 190 methods ``` `GameWindow` is the single object that: @@ -388,7 +389,7 @@ useful ordering seam, but its ownership status is **partial**. | Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. | | Network session | **Partial** | `LiveSessionController` owns endpoint resolution, `WorldSession` construction, ticking, and disposal (`0b25df53`). `TryStartLiveSession`, character choice/entry, session clearing, about 440 lines of subscriptions, and most App packet handlers remain in `GameWindow`. | | Live identity/lifetime | **Complete core owner** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, logical/spatial lifetime, and teardown. `GameWindow` still owns large hydration and wire-to-presentation adapter bodies. | -| Inbound/object-frame order | **Partial** | `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveEntityAnimationScheduler`, static scheduler, remote/projectile updaters, and teleport owners are shipped. `AdvanceLiveObjectRuntimeCore`, final part/mesh/effect composition, and several integration tails remain in `GameWindow`. | +| Inbound/object-frame order | **Partial** | `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveEntityAnimationScheduler`, `LiveEntityAnimationPresenter`, static scheduler, remote/projectile updaters, and teleport owners are shipped. `AdvanceLiveObjectRuntimeCore` retains the visible cross-owner order until Slice 6. | | World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. | | Retained gameplay UI | **Mostly complete feature ownership** | `RetailUiRuntime` and focused panel/controllers own layout and behavior. `GameWindow.OnLoad` still performs substantial service composition, which is allowed until the final composition cleanup. | | Selection/interaction | **Complete** | `WorldSelectionQuery` owns read-only picking/classification/range queries; `SelectionInteractionController` owns selection intent, Use/PickUp transport, exact-incarnation queues, and auto-walk deferral; `ItemInteractionController` owns ItemHolder policy plus the shared retail inventory-request transaction. `GameWindow` retains construction and narrow lifecycle forwarding only. | @@ -443,7 +444,7 @@ legacy state for composed owners. Three independent retail, architecture, and adversarial review loops finished clean. Release build passed; 6,558 tests passed and five fixture/conformance tests skipped intentionally. -#### Slice 2 — finish live animation presentation — NEXT +#### Slice 2 — finish live animation presentation — COMPLETE 2026-07-21 Move `TickAnimations`, final rigid/visual part composition, effect-pose publication, and motion-done binding into `LiveEntityAnimationPresenter`. @@ -457,7 +458,17 @@ rigid effect poses, part availability, same-frame hook pose publication, and incarnation replacement. Run the R6 object-frame and projectile/effect suites plus the connected locomotion/projectile gate. -#### Slice 3 — complete live-session ownership +Result: `LiveEntityAnimationPresenter` owns final visual/rigid part +composition, effect-pose publication, MotionDone binding, and typed diagnostics. +Schedules carry exact record/entity/component/epoch/projection/presentation +identity and own their authored frame prefix until the next scheduler tick. +Appearance and static-owner rebinding invalidate stale work. Named-retail +`CPartArray::UpdateParts` short-frame retention now preserves both visual and +rigid trailing poses. `GameWindow` lost 366 lines, two methods, and one net +field. Three independent retail, architecture, and adversarial review loops +finished clean; focused App/Core tests and the complete App suite pass. + +#### Slice 3 — complete live-session ownership — NEXT Expand `LiveSessionController` to own connect, character-list validation, character selection, EnterWorld, graceful character logout, reconnect, and diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 8c5ca825..f9353755 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -21,10 +21,16 @@ stateful intent owner are extracted, exact-incarnation lifetime is enforced, and retail's global inventory-request transaction is shared across every UI mutation surface. `GameWindow` is 811 lines and 14 methods smaller. -**Next:** Slice 2 finishes live animation presentation by moving final -part/mesh/effect-pose composition and motion-done binding behind -`LiveEntityAnimationPresenter`, while preserving the shipped scheduler and -retail object-frame order. +Slice 2 live animation presentation landed 2026-07-21: final visual/rigid +PartArray composition, effect-pose publication, MotionDone binding, and +diagnostics now live in `LiveEntityAnimationPresenter`. Exact incarnation, +projection, object-clock, and appearance-revision tokens reject stale work; +ordinary/static frame handoffs own their buffers; retail short AnimFrames +retain trailing visible and rigid poses. `GameWindow` is now 14,546 lines. + +**Next:** Slice 3 completes live-session ownership by moving connection, +character entry, typed event subscriptions, graceful logout, reconnect, and +session-scoped cleanup behind `LiveSessionController`/`LiveSessionEventRouter`. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 @@ -1479,7 +1485,7 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen. ## Cross-cutting work tracked in parallel -- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,558 passed / 5 intentional skips (2026-07-21). +- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,575 passed / 5 intentional skips (2026-07-21). - **Memory files.** Live subsystem state and digests are indexed by `claude-memory/MEMORY.md`; stable engineering references remain under `memory/`. Canonical program status stays in milestones/roadmap/issues. - **`CLAUDE.md` discipline.** Check all four references (ACE, ACViewer, WorldBuilder, Chorizite) before committing to an approach. WorldBuilder is the closest stack match and should be checked first. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index ac36e633..009d6bc8 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -17,14 +17,15 @@ layer, the active structural prerequisite is the behavior-preserving `GameWindow` decomposition in [`docs/architecture/code-structure.md`](../architecture/code-structure.md). The 2026-07-21 audit baseline was 15,723 lines, 278 fields, and 205 methods. -Selection/interaction Slice 1 is complete at 14,912 lines, 278 fields, and 191 -methods. Slice 2 — final live animation presentation ownership — is next. +Selection/interaction Slice 1 and live-animation-presentation Slice 2 are +complete. `GameWindow` is now 14,546 lines, 277 fields, and 190 methods. Slice 3 +— complete live-session ownership — is next. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus registered TS-50/TS-51 timing residuals (the complete-root-Frame/object-workset cutover is automated- and visual-gate complete; Release builds with 17 known -test-project warnings tracked by #228, and 6,558 tests pass / 5 skip), and +test-project warnings tracked by #228, and 6,575 tests pass / 5 skip), and the deferred Modern Pipeline track (MP1b+). The separate #225 shared-alpha implementation has passed connected lifetime/performance gates and awaits only its lifestone/particle visual comparison. diff --git a/docs/plans/2026-07-21-gamewindow-slice-2-live-animation-presentation.md b/docs/plans/2026-07-21-gamewindow-slice-2-live-animation-presentation.md index f7cbac56..67ce907f 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-2-live-animation-presentation.md +++ b/docs/plans/2026-07-21-gamewindow-slice-2-live-animation-presentation.md @@ -1,9 +1,14 @@ # GameWindow Slice 2 — live animation presentation ownership -**Status:** Approved for execution 2026-07-21. +**Status:** Complete 2026-07-21. Automated and three-agent review gates pass; +connected visual regression gate is carried to the final campaign gate. **Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 2. **Baseline:** `9ad8113c`; `GameWindow.cs` is 14,912 lines, 278 fields, and 191 methods before this slice. +**Result:** `GameWindow.cs` is 14,546 lines, 277 fields, and 190 methods. +The slice also closed stale-schedule ABA across replacement, appearance +rebinding, static rebind, and borrowed sequencer buffers. Forty-one focused +Release tests and the full 6,575-pass / 5-skip Release suite are green. **Behavior rule:** This is an ownership extraction over the accepted R6 object frame plus one named-retail conformance correction: short AnimFrames retain the prior/rest pose of trailing CPartArray parts. It must not otherwise change @@ -335,6 +340,12 @@ Additional invariants: ### Cross-owner order and reconciliation +The presenter-level portions below are covered by Slice 2. The full +instrumented owner-order trace and non-advancing spatial-reconcile assertion +remain assigned to Slice 6, where the update sequence becomes one testable +orchestrator instead of a `GameWindow` method. Slice 2 preserves that order but +does not claim an end-to-end orchestrator test prematurely. + - One instrumented object-frame test pins presenter, children, static hooks, fades, ordinary hook drain, effect roots, emitters, lights, particles, and scripts. A PES-created particle first simulates on the following frame. diff --git a/memory/project_animation_runtime.md b/memory/project_animation_runtime.md index c20dee3b..1cb8fcd3 100644 --- a/memory/project_animation_runtime.md +++ b/memory/project_animation_runtime.md @@ -1,5 +1,41 @@ # Animation Runtime Crib +## Current live presentation ownership (2026-07-21) + +`LiveEntityAnimationScheduler` owns ordinary-object time, PartArray advance, +root motion, and the retail manager tail. `RetailStaticAnimatingObjectScheduler` +owns the separate retail static-object workset. `LiveEntityAnimationPresenter` +is the sole consumer that composes final drawable part transforms, publishes +rigid part/effect poses, and binds `MotionDone`. `GameWindow` wires these owners +but no longer contains the presentation body. + +The frame handoff is identity- and lifetime-bound. A schedule carries the exact +`LiveEntityRecord`, `WorldEntity`, animation state, sequencer, object-clock +epoch, projection mutation version, and presentation revision. Both ordinary +and static schedulers copy the sequencer's borrowed PartArray view into their +own buffers before invoking callbacks. Presentation is non-reentrant because +it consumes legacy elapsed time and static prepared frames exactly once. + +Appearance rebinding increments `PresentationRevision`. That rejects a pose +prepared against the old part template without replacing the live animation +component. For a static owner, rejecting only that stale visual pose must retain +the same sequencer's pending `process_hooks` tail so `MotionDone` is not lost; +owner, sequencer, residency, projection, or clock mismatches still invalidate +the entire handoff. + +Retail `CPartArray::UpdateParts` updates only the authored AnimFrame prefix. +When a frame contains fewer parts than the Setup, trailing parts keep their +previous pose (or their rest pose on first presentation) in both channels: + +- visual: `DefaultScale * rotation * translation(origin) * ObjScale`; +- rigid/effect: `rotation * translation(origin * ObjScale)`. + +Canonical per-frame order remains scheduler tick, static tick, live presenter, +equipped-child update, static `process_hooks`, deferred hook drain, attached +effects/lights, particles, and draw. The end-to-end order assertion belongs to +Slice 6's extracted update orchestrator; do not reintroduce local ordering into +the presenter. + ## Live entity lifecycle invariant `CreateObject` creates a live entity and a real `DeleteObject` destroys it. From bacc7e45a9760171b66f328ef95331005ba6357e Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 10:16:14 +0200 Subject: [PATCH 24/98] docs(architecture): plan live session ownership slice --- ...6-07-21-gamewindow-slice-3-live-session.md | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 docs/plans/2026-07-21-gamewindow-slice-3-live-session.md diff --git a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md new file mode 100644 index 00000000..cbed3d14 --- /dev/null +++ b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md @@ -0,0 +1,371 @@ +# GameWindow Slice 3 — complete live-session ownership + +**Status:** Active 2026-07-21. +**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 3. +**Baseline:** `9a150e24`; `GameWindow.cs` is 14,546 lines, 277 fields, and +190 methods before this slice. +**Behavior rule:** Preserve the accepted connect, EnterWorld, inbound dispatch, +chat/command, and retail graceful-close behavior while correcting the +named-retail character-list, unattended-selection, canonical-account, and +disconnect-state gaps discovered by the slice audit. This slice changes App +ownership and fixes proven session-lifetime defects; it does not add a retained +character-selection UI or redesign the transport. + +## 1. Outcome + +`LiveSessionController` becomes the sole App owner of one exact +`WorldSession` lifetime: + +- endpoint resolution and session construction; +- event routing before the first network receive; +- Connect → CharacterList validation → first-character selection → EnterWorld; +- active command-bus publication; +- per-frame Tick; +- graceful stop, replacement/reconnect, and disposal; +- session-generation and reentrant-operation gates; +- failure convergence back to a clean offline state. + +`LiveSessionEventRouter` owns typed session subscriptions and exact +unsubscription. It routes into focused entity/environment/chat/vital and Core +state handlers but owns no entity map, renderer, UI model, or gameplay state. +`LiveSessionResetPlan` owns the ordered, failure-isolated reset transaction but +does not become a second state owner. + +At slice exit, `GameWindow` retains one `_liveSessionController` field plus +narrow composition/lifecycle callbacks. `_liveSession`, `_commandBus`, +`_combatChatTranslator`, `TryStartLiveSession`, `ClearInboundEntityState`, and +`WireLiveSessionEvents` are removed. Every outbound caller resolves the current +borrowed session from the controller at call time; no feature caches it. + +## 2. Retail and shipped oracles + +The accepted transport behavior remains pinned to: + +- [`2026-07-20-retail-graceful-logout-pseudocode.md`](../research/2026-07-20-retail-graceful-logout-pseudocode.md); +- `Proto_UI::LogOffCharacter @ 0x00546A20`; +- `CharacterSet::UnPack @ 0x004FE340`; +- `gmCharacterManagementUI::EnterGame @ 0x004ED440`; +- `CPlayerSystem::LogOnCharacter @ 0x0055F890`; +- `CPlayerSystem::RequestLogOff @ 0x00562DD0`; +- inbound login-message dispatch `@ 0x0055C963`; +- `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; +- `ClientNet::ExitWorldDisconnect @ 0x00541E00`; +- `ClientNet::LogOffServer @ 0x00543EF0`; +- `SharedNet::SendOptionalHeader @ 0x00543160`; +- ACE `CharacterHandler.CharacterLogOff` and + `Session.SendFinalLogOffMessages`; +- Holtburger's connect/PlayerCreate/LoginComplete flow as a protocol + cross-check, not as the logout oracle where its shortcut differs from retail; +- [`2026-07-20-automated-world-lifecycle-gate.md`](2026-07-20-automated-world-lifecycle-gate.md), + whose accepted connected trace proves F653 confirmation, transport + disconnect, endpoint release, and fresh-process reconnect. + +The controller must preserve these fixed facts: + +1. Complete routing is installed before `Connect`; Connect can synchronously + publish initial server time before character selection. +2. Retail's CharacterList contains separate active and deleted collections. + EnterGame requires a selected GUID in the active collection whose + `GetGreyedOutFor` result is not positive. acdream's unattended auto-entry is + an explicit adaptation: select the first active, non-greyed character and + reject an empty/all-greyed list. A future retained character-select screen + is separate M4/UI work. +3. `WorldSession.EnterWorld` retains its shipped request → ServerReady → + CharacterEnterWorld order. F657 uses `CharacterList.AccountName`, the + canonical server-returned spelling, rather than the startup username. +4. Closing an in-world session sends the eight-byte `[0xF653, active GUID]` + request, drains without App world callbacks until the server's exact + four-byte opcode-only F653 confirmation, sends transport Disconnect, and + only then releases the socket. Once ConnectRequest negotiation has supplied + receiver id/iteration, character-select, entering-world, and failed + post-negotiation sessions also send transport Disconnect before socket + release. `WorldSession.Dispose` remains the one wire owner. +5. The existing close-only adaptation in divergence row AD-44 remains: acdream + exits instead of returning to a retained character-select connection. +6. The registered fixed ConnectResponse delay (UN-6) and initial + LoginComplete-readiness gap (TS-28) remain explicit Core.Net follow-ups. + They are not copied into, disguised by, or claimed by the App controller. + +No new automatic network retry or logout shortcut is introduced. Core.Net +changes stay surgical: retail CharacterSet parsing/selection data, exact F653 +confirmation, and negotiated-state Disconnect symmetry. The accepted packet +codec, handshake loops, confirmation wait, and receive-thread design are not +rewritten. + +## 3. Proven baseline defects closed by this slice + +The pre-slice audit found real lifetime defects, not merely style debt: + +- repeated startup can overwrite an active controller/session without stopping + the exact prior lifetime; +- no-character and failure paths can leave a `LiveCommandBus` whose delegates + capture a disposed session; +- the no-character path leaks `CombatChatTranslator` subscriptions; +- anonymous event wiring cannot be explicitly detached; +- `GameEventDispatcher.Unregister(type)` cannot safely distinguish an old + registration from a replacement; +- the reset boundary leaves player identity, Core character state, Turbine + rooms/cookie, shortcuts/components, combat/item mana/social state, motion + metadata, and player-mode references from the prior session; +- one reset-stage failure can prevent later network/effect cleanup; +- a synchronous handler can request stop/reconnect while `Tick`, Connect, or + EnterWorld is still unwinding. + +Each is fixed at its owner. No suppression flag, delay, retry loop, or stale-GUID +filter is acceptable. + +## 4. Architecture and interfaces + +### 4.1 Controller state + +`LiveSessionController` exposes: + +```csharp +LiveSessionStartResult Start(RuntimeOptions options, ILiveSessionLifecycleHost host); +LiveSessionStartResult Reconnect(RuntimeOptions options, ILiveSessionLifecycleHost host); +void Stop(); +void Tick(); + +WorldSession? CurrentSession { get; } // borrowed; never cache +ICommandBus Commands { get; } // NullCommandBus outside active InWorld +bool IsInWorld { get; } +ulong SessionGeneration { get; } +``` + +The controller uses an injected internal operations seam for deterministic +tests (resolve, create, connect, select, enter, tick, dispose). Production +operations call the real `WorldSession`; tests do not open UDP sockets. This is +an App orchestration seam, not a duplicate protocol implementation. + +Every start attempt and accepted session has a monotonically increasing +generation. Lifecycle calls made synchronously from a host/event callback mark +the current attempt non-accepting and defer stop/reconnect until the outer +operation unwinds. The outer attempt rechecks its generation after every +callback and blocking network phase, so it cannot publish an old session after +reentrant replacement. + +Replacement order is fixed: + +1. disable outbound commands and mark the router non-accepting; +2. detach exact subscriptions and session-scoped translators; +3. gracefully dispose the exact old `WorldSession`; +4. detach the borrowed session from the host; +5. execute the complete reset plan to convergence; +6. create and bind the new session transactionally; +7. Connect, validate/select, EnterWorld; +8. publish active commands only after success. + +### 4.2 Lifecycle host + +`ILiveSessionLifecycleHost` is a narrow composition boundary, not a context +bag. It has focused lifecycle calls only: + +- bind one exact session and return an owned `LiveSessionBinding`; +- reset session-scoped App state; +- report connecting/connected status; +- apply the selected character identity; +- apply post-EnterWorld UI/settings state; +- detach the exact session. + +Entity, environment, and effect packets continue through focused sink +interfaces. Later Slice 4 replaces the GameWindow entity sink with the two +planned live-entity integration controllers without changing the session +controller. + +### 4.3 Exact subscription ownership + +`LiveSessionEventRouter` stores named delegates against one exact event source, +sets `Accepting = false` before teardown, and unsubscribes in reverse order. +Construction is transactional: a partially created router unwinds every prior +registration. + +`ObjectTableWiring.Wire`, `CombatStateWiring.Wire`, and +`GameEventWiring.WireAll` return idempotent owned registrations. The GameEvent +dispatcher gains registration tokens that restore/remove only when their exact +handler is still current. Nested registration A → B is safe when A disposes +first: disposing B skips the retired A node and restores the session-owned +predecessor (not a dead callback). + +The router groups dependencies by focused responsibility: + +- live entity/physics/effect sink; +- environment/time sink; +- Core object/combat/spell/player/social state bridge; +- chat/vital bridge; +- command router. + +There is no `GameWindowContext`, GUID table, renderer registry, or generic +service locator. + +### 4.4 Command lifetime + +`LiveSessionCommandRouter` owns one `LiveCommandBus`, the server/chat routing, +and the exact session captured by its handlers. The controller exposes this bus +only while that session is the active in-world generation. Before stop or +replacement it returns `NullCommandBus`, so a retained panel can never send via +the displaced socket. + +Turbine channel resolution and labels move with the chat command/event owner. +`ClientCommandController` remains the retail command behavior owner; GameWindow +supplies its existing focused bindings during composition. + +### 4.5 Reset convergence + +`LiveSessionResetPlan` is an ordered list of named owner-reset operations. It +runs every stage even when one fails, aggregates errors, and forbids a new +session from starting until all stages converge. The plan clears: + +- mouse-look, player mode/cameras, auto-entry, and teleport/reveal transit; +- session origin/counters and selected-character identity; +- equipped/external-container projections; +- object, spell, magic, combat, item-mana, local-player, friends, squelch, + Turbine-chat, shortcut, and desired-component state; +- interaction/selection and selection-presentation state; +- liveness and canonical live entities (including GpuWorldState persistence); +- remote teleport, pending F754/F755 effects, and deferred animation hooks. + +`LocalPlayerState.Clear`, `TurbineChatState.Reset`, and `SquelchState.Clear` +are added to their real state owners. Chat history is intentionally retained +across an in-process reconnect, matching the existing UI behavior, but its +local-player GUID resets to zero. Vitals GUID, diagnostic player GUID, run/jump +skills, character options, motion table, movement truth/shadow, active toon, +shortcuts, and desired components reset to pre-login values. + +## 5. Implementation sequence + +### Commit A0 — retail lifecycle conformance corrections + +- Write one consolidated pseudocode note for `CharacterSet::UnPack`, + `gmCharacterManagementUI::EnterGame`, `CPlayerSystem::LogOnCharacter`, and + negotiated logout/disconnect ordering. +- Parse active and deleted CharacterList collections with cursor/truncation + tests and preserve status/account/slot/boolean fields. +- Add the unattended first-active-non-greyed selector and canonical-account + EnterWorld input. +- Require an exact four-byte F653 confirmation and send transport Disconnect + for every negotiated session state. +- Keep UN-6, TS-28, and AD-44 explicitly registered and unchanged. + +### Commit A — owned subscription primitives + +- Add nested-safe owned registrations to `GameEventDispatcher`. +- Make ObjectTable, CombatState, and GameEvent wiring return disposables. +- Add exact teardown, nested replacement, reverse disposal, and idempotence + tests in Core.Net. + +### Commit B — session event and command routers + +- Add focused live-session event source/sink contracts. +- Route every current direct WorldSession event exactly once. +- Move chat/Turbine/vital routing and the live command bus out of GameWindow. +- Own `CombatChatTranslator` and all wiring registrations transactionally. +- Test post-dispose silence, partial-construction unwind, nested events, and + router A/B replacement. + +### Commit C — convergent session reset + +- Add missing reset APIs to Core state owners. +- Add `LiveSessionResetPlan` with named, failure-isolated stages. +- Compose the complete current session manifest and test mutated A → reset → + clean B state, including one throwing stage. + +### Commit D — complete lifecycle controller + +- Move resolve/create/Connect/CharacterList/selection/EnterWorld/stop/reconnect + into `LiveSessionController`. +- Add generation and operation-depth gates. +- Add typed start results for Disabled, MissingCredentials, NoCharacters, + Connected, Deferred, and Failed. +- Test ordering, duplicate/reentrant start/stop/reconnect, Connect and + EnterWorld failures, tick generation, and exact-once cleanup. + +### Commit E — GameWindow cutover + +- Implement the narrow host and packet sink interfaces. +- Replace all direct session field reads with borrowed controller resolution. +- Replace both UI command-bus providers with `controller.Commands`. +- Delete the three old lifecycle/wiring methods and the displaced fields. +- Preserve OnLoad, frame Tick, and shutdown stage order. + +### Commit F — review corrections and documentation + +- Run three independent read-only reviews: retail conformance, + architecture/lifetime, and adversarial tests. +- Fix every confirmed issue and repeat review until clean. +- Run focused App/Core.Net tests, Release build, and the full Release suite. +- Run the existing connected login/command/chat/portal/graceful-close/ + fresh-process-reconnect gate when local ACE is available. +- Update code structure, roadmap, milestones, issues/divergence if needed, + durable session memory, `AGENTS.md`, and `CLAUDE.md`. + +## 6. Automated acceptance matrix + +### Controller + +- Routing is complete before Connect's synchronous server-time callback. +- First active non-greyed CharacterList entry, GUID, name, canonical account, + and active-list index are exact; deleted/greyed entries are rejected. +- Empty list, Connect failure, EnterWorld failure, and binding failure leave no + active session/router/bus and clean exactly once. +- Duplicate `Start` is idempotent and cannot disturb a healthy active scope; + explicit `Reconnect` gracefully stops A before B state is reset/wired. +- Stop/reconnect requested during bind, Connect, selected-character callback, + EnterWorld, or Tick cannot resurrect the outer generation. +- Tick reaches only the exact current session; stopped/disposed Tick is a no-op. +- Stop and Dispose are idempotent. + +### Router and command bus + +- Every direct session event routes once while accepting. +- Dispose is reverse-order, idempotent, and post-dispose events are ignored. +- Partial construction unwinds prior registrations. +- GameEvent A/B nested ownership restores the nearest live predecessor. +- Old command delegates are unreachable before old-session disposal and cannot + send after replacement. +- Domain-handler exceptions follow an explicit policy without corrupting + subscription ownership. + +### Reset + +- Every named stage is attempted once even when another throws. +- Failure blocks construction of session B and reports all stage errors. +- Player identity, Vitals/Chat identity, diagnostic GUID, selected-character + metadata, Core state, social/chat-room state, projections, live runtime, + pending effects, and hooks all return to pre-login values. +- Chat history is retained but no old own-GUID classification remains. +- Live runtime convergence removes the old persistent player classification. + +### Integration + +- `GameWindow` has no `_liveSession`, `_commandBus`, or + `_combatChatTranslator` field. +- `TryStartLiveSession`, `ClearInboundEntityState`, and + `WireLiveSessionEvents` no longer exist. +- No substantial session callback body remains in `GameWindow`. +- WorldSession retains the 8-byte request → exact 4-byte confirmation → + Disconnect order and extends Disconnect symmetry to every negotiated state. +- Release build and full suite are green. + +## 7. Connected gate + +Run a normal capped Release client against local ACE and prove: + +1. login selects the same first character and reveals a complete world; +2. system chat, say/tell/channel/server commands, vitals, combat state, and + shortcuts/components still update; +3. one portal/recall completes with the same reveal lifecycle; +4. native close logs the F653 request/confirmation and transport Disconnect; +5. ACE releases the exact endpoint; +6. a fresh process reconnects cleanly with no old character state, command + route, entities, effects, or persistent GUIDs. + +The gate is behavioral and resource-oriented. It does not create a new visual +approval pause; final campaign visual acceptance remains after Slice 8. + +## 8. Review policy + +One primary agent performs edits. Read-only agents independently audit retail +conformance, architecture/lifetime, and adversarial tests after each ownership +commit. Confirmed findings are fixed at their root and re-reviewed. No reviewer +edits the shared worktree, and no callback facade back into a substantial old +GameWindow body counts as extraction completion. From aea957f8450eac54e5825f66c2bf6c455d3ab5d5 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 10:33:03 +0200 Subject: [PATCH 25/98] fix(net): conform character entry and session shutdown --- .../retail-divergence-register.md | 2 +- ...etail-live-session-lifecycle-pseudocode.md | 157 ++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 16 +- .../Messages/CharacterList.cs | 116 +++++++-- .../Messages/CharacterLogOff.cs | 2 +- src/AcDream.Core.Net/WorldSession.cs | 242 ++++++++++++++---- .../LiveHandshakeTests.cs | 7 +- .../Messages/CharacterEnterWorldTests.cs | 62 +++++ .../Messages/CharacterListTests.cs | 196 +++++++++++++- .../Messages/CharacterLogOffTests.cs | 8 + .../WorldSessionNegotiationShutdownTests.cs | 77 ++++++ .../WorldSessionShutdownTests.cs | 226 ++++++++++++++++ 12 files changed, 1016 insertions(+), 95 deletions(-) create mode 100644 docs/research/2026-07-21-retail-live-session-lifecycle-pseudocode.md create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionNegotiationShutdownTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b51c6997..a4ce63c6 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -102,7 +102,7 @@ accepted-divergence entries (#96, #49, #50). | AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.App/Input/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-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 | Native-window close performs retail's complete character-logoff handshake and transport disconnect, then exits the process instead of retaining the authenticated logon connection and returning to character selection. acdream has one active `ReceiverData` equivalent, so `ClientNet::LogOffServer`'s per-receiver disconnect loop sends one header. | `src/AcDream.Core.Net/WorldSession.cs` (`Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | The application currently auto-selects one character and has no character-selection presentation/state owner to receive the returned authenticated session. Close must still release ACE immediately and in retail order. | Closing is correct, but an eventual in-client "log off character" action cannot reuse this process-exit path; it needs an App/session transition that retains the socket after server `0xF653` rather than calling `Dispose`. | `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | +| 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` | --- diff --git a/docs/research/2026-07-21-retail-live-session-lifecycle-pseudocode.md b/docs/research/2026-07-21-retail-live-session-lifecycle-pseudocode.md new file mode 100644 index 00000000..126555cc --- /dev/null +++ b/docs/research/2026-07-21-retail-live-session-lifecycle-pseudocode.md @@ -0,0 +1,157 @@ +# Retail live-session lifecycle pseudocode + +**Scope:** GameWindow Slice 3 character-list, character-entry, and transport- +disconnect conformance corrections. + +## Named-retail oracle + +- `CharacterIdentity::UnPack @ 0x004FE880` +- `CharacterSet::UnPack @ 0x004FE340` +- `CharacterSet::GetGreyedOutFor @ 0x004FDFA0` +- `gmCharacterManagementUI::EnterGame @ 0x004ED440` +- `gmCharGenMainUI::Update @ 0x004E8460` +- `CPlayerSystem::Handle_Login__CharacterSet @ 0x0055F6D0` +- `Proto_UI::SendEnterWorldRequest @ 0x00546A00` +- `CPlayerSystem::Handle_Character__EnterGame_ServerReady @ 0x0055E4F0` +- `Proto_UI::SendEnterWorld @ 0x00546BC0` +- `CPlayerSystem::LogOnCharacter @ 0x0055F890` +- `CPlayerSystem::UseTime @ 0x00563110` +- `Proto_UI::LogOffCharacter @ 0x00546A20` +- `CPlayerSystem::RequestLogOff @ 0x00562DD0` +- inbound F653 dispatch `@ 0x0055C963` +- `CPlayerSystem::ExecuteLogOff @ 0x0055D780` +- `ClientNet::ExitWorldDisconnect @ 0x00541E00` +- `ClientNet::LogOffServer @ 0x00543EF0` +- `SharedNet::SendOptionalHeader @ 0x00543160` + +The verbatim layouts are `CharacterIdentity` and `CharacterSet` in +`docs/research/named-retail/acclient.h` (types 3206 and 3209). + +## CharacterSet wire decode + +```text +CharacterIdentity.UnPack(cursor): + identity.guid = read_u32(cursor) + identity.name = read_PString(cursor) + identity.secondsGreyedOut = read_u32(cursor) + align cursor to 4 bytes + +CharacterSet.UnPack(cursor): + result.status = read_u32(cursor) + + activeCount = read_u32(cursor) + result.active = empty + repeat activeCount times: + result.active.append(CharacterIdentity.UnPack(cursor)) + + deletedCount = read_u32(cursor) + result.deleted = empty + repeat deletedCount times: + result.deleted.append(CharacterIdentity.UnPack(cursor)) + + result.numAllowedCharacters = read_u32(cursor) + result.account = read_PString(cursor) + result.useTurbineChat = read_u32(cursor) + result.hasThroneOfDestiny = read_u32(cursor) +``` + +The two values previously described as "leading/trailing padding" are the +retail status and deleted-character count. ACE currently writes zero for both, +which hid the schema error in acdream and in Holtburger's current reader. + +Cross-checks: + +- ACE `GameMessageCharacterList` writes the same active identity records, + allowed-slot count, canonical `Session.Account`, and booleans, but currently + emits status/deleted-count as zero and no deleted records. +- Holtburger `CharacterListData` agrees on active records and the tail, but + likewise labels both zero values as padding. The named retail `CharacterSet` + layout and `UnPack` control flow win where the references differ. + +## Retail selection and acdream unattended entry + +```text +gmCharacterManagementUI.EnterGame(): + playerSystem = GetPlayerSystem() + if playerSystem exists + and selectedGuid != 0 + and CharacterSet.GetGreyedOutFor( + CharacterSet.GetSlot(selectedGuid)) <= 0: + show entering-world dialog + playerSystem.LogOnCharacter(selectedGuid) +``` + +Retail ordinarily requires an explicit selected active character. The only +automatic retail path found is `gmCharGenMainUI::Update`: after character +creation it walks the active list, skips entries greyed for a positive number +of seconds, matches the newly-created name case-insensitively, and logs that +GUID on. + +acdream does not yet have the retained character-management screen. Its +unattended auto-entry is therefore an explicit adaptation with a narrow rule: + +```text +for each identity in active wire order: + if identity.guid != 0 and identity.secondsGreyedOut == 0: + select this active-list index + stop +if none matched: + do not enter a character +``` + +Deleted entries are never candidates. This preserves the established "first +available character" behavior without treating a pending-deletion character +as available. + +## Canonical account used by EnterWorld + +```text +CPlayerSystem.LogOnCharacter(selectedGuid), when ready: + selectedGuid = persistentData.selectedCharacter + account = playerSystem.account // populated from CharacterSet.account + Proto_UI.SendEnterWorld(account, selectedGuid) + ClientNet.EnterWorld() +``` + +The account string in the final F657 message comes from the server-returned +`CharacterSet.account`, not from the spelling typed into the startup login +form. acdream must therefore make `WorldSession.EnterWorld` consume +`Characters.AccountName` internally. + +## Logout and negotiated transport disconnect + +```text +ClientNet.LogOffServer(): + packet = make optional-header packet(flags = Disconnect) + for each negotiated ReceiverData: + SharedNet.SendOptionalHeader(packet, receiver.address, receiver) + logOffSent = true + logonReceiverId = 0 + ExitWorldDisconnect() +``` + +The in-world character path remains: + +```text +send F653 + active character GUID +wait for server's opcode-only F653 +send negotiated transport Disconnect +release transport +``` + +ACE's `GameMessageCharacterLogOff` confirms the server form is exactly four +bytes. Its `NetworkSession` terminates a session when it receives a packet with +the transport Disconnect flag. + +The transport Disconnect is connection-scoped, not character-state-scoped. +Once acdream has accepted ConnectRequest receiver id/iteration, disposal sends +it whether the session is still selecting a character, entering the world, in +world, or failed later. F653 remains restricted to an active in-world +character. A pre-negotiation failure has no receiver identity and only closes +the local socket. + +## Deliberately unchanged registered gaps + +- UN-6: the fixed 200 ms ConnectResponse delay remains registered. +- TS-28: LoginComplete readiness remains a later protocol slice. +- AD-44: closing exits rather than returning to a retained character screen. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 835d3dc8..1d31c22a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2867,6 +2867,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents); if (_liveSession is null) { + _commandBus = null; _combatChatTranslator?.Dispose(); _combatChatTranslator = null; _liveSessionController = null; @@ -2883,9 +2884,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _liveSession.Connect(user, pass); Chat.OnSystemMessage("connected — character list received", chatType: 1); - if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0) + if (_liveSession.Characters is null + || !AcDream.Core.Net.Messages.CharacterList.TrySelectFirstAvailable( + _liveSession.Characters, + out AcDream.Core.Net.Messages.CharacterList.Selection selection)) { - Console.WriteLine("live: no characters on account; disconnecting"); + Console.WriteLine("live: no available characters on account; disconnecting"); + _commandBus = null; + _combatChatTranslator?.Dispose(); + _combatChatTranslator = null; _liveSessionController.Dispose(); _liveSessionController = null; _liveSession = null; @@ -2893,7 +2900,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext return; } - var chosen = _liveSession.Characters.Characters[0]; + var chosen = selection.Character; _playerServerGuid = chosen.Id; _vitalsVm?.SetLocalPlayerGuid(chosen.Id); Chat.SetLocalPlayerGuid(chosen.Id); @@ -2901,7 +2908,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = chosen.Id; // TEMP #138-B probe Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); Combat.Clear(); - _liveSession.EnterWorld(user, characterIndex: 0); + _liveSession.EnterWorld(characterIndex: selection.ActiveIndex); _activeToonKey = chosen.Name; _retailUiRuntime?.RestoreLayout(); @@ -2919,6 +2926,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext catch (Exception ex) { Console.WriteLine($"live: session failed: {ex.Message}"); + _commandBus = null; _combatChatTranslator?.Dispose(); _combatChatTranslator = null; _liveSessionController?.Dispose(); diff --git a/src/AcDream.Core.Net/Messages/CharacterList.cs b/src/AcDream.Core.Net/Messages/CharacterList.cs index 0b7eac9a..fa436442 100644 --- a/src/AcDream.Core.Net/Messages/CharacterList.cs +++ b/src/AcDream.Core.Net/Messages/CharacterList.cs @@ -7,22 +7,25 @@ namespace AcDream.Core.Net.Messages; /// Inbound CharacterList GameMessage (opcode 0xF658). /// The server sends one of these right after ConnectResponse completes /// the handshake — it's the client's "pick which character to log in" -/// data. Contains one entry per character on the account plus account- -/// level settings (slot count, Turbine chat toggle, ToD flag). +/// data. Contains the retail status, separate active and deleted character +/// collections, plus account-level settings. /// /// -/// Wire layout (ported from ACE's GameMessageCharacterList.cs -/// writer — see NOTICE.md for attribution): +/// Wire layout ported from retail CharacterSet::UnPack at +/// 0x004FE340 and CharacterIdentity::UnPack at +/// 0x004FE880. ACE currently emits zero for both status and deleted +/// count, which is why older readers mislabeled them as padding: /// /// /// -/// u32 0 (leading padding, always 0) -/// u32 characterCount -/// for each character: +/// u32 status +/// u32 activeCount +/// for each active character: /// u32 characterId (GUID) /// String16L name (prefixed with '+' for admin chars) -/// u32 deleteTimeDelta (0 if not scheduled for deletion) -/// u32 0 (trailing padding) +/// u32 secondsGreyedOut +/// u32 deletedCount +/// for each deleted character: same CharacterIdentity record /// u32 slotCount (max characters per account) /// String16L accountName /// u32 useTurbineChat (bool) @@ -33,11 +36,15 @@ public static class CharacterList { public const uint Opcode = 0xF658u; - public readonly record struct Character(uint Id, string Name, uint DeleteTimeDelta); + public readonly record struct Character(uint Id, string Name, uint SecondsGreyedOut); + + public readonly record struct Selection(int ActiveIndex, Character Character); public sealed record Parsed( + uint Status, IReadOnlyList Characters, - uint SlotCount, + IReadOnlyList DeletedCharacters, + int SlotCount, string AccountName, bool UseTurbineChat, bool HasThroneOfDestiny); @@ -55,27 +62,78 @@ public static class CharacterList if (opcode != Opcode) throw new FormatException($"expected CharacterList opcode 0x{Opcode:X4}, got 0x{opcode:X8}"); - _ = ReadU32(body, ref pos); // leading 0 - uint count = ReadU32(body, ref pos); - if (count > 255) - throw new FormatException($"character count {count} exceeds sanity limit"); - - var characters = new Character[count]; - for (int i = 0; i < count; i++) - { - uint id = ReadU32(body, ref pos); - string name = ReadString16L(body, ref pos); - uint deleteDelta = ReadU32(body, ref pos); - characters[i] = new Character(id, name, deleteDelta); - } - - _ = ReadU32(body, ref pos); // trailing 0 - uint slotCount = ReadU32(body, ref pos); + uint status = ReadU32(body, ref pos); + Character[] characters = ReadCharacters(body, ref pos, "active"); + Character[] deletedCharacters = ReadCharacters(body, ref pos, "deleted"); + int slotCount = unchecked((int)ReadU32(body, ref pos)); string accountName = ReadString16L(body, ref pos); bool useTurbineChat = ReadU32(body, ref pos) != 0; bool hasThroneOfDestiny = ReadU32(body, ref pos) != 0; - return new Parsed(characters, slotCount, accountName, useTurbineChat, hasThroneOfDestiny); + return new Parsed( + status, + characters, + deletedCharacters, + slotCount, + accountName, + useTurbineChat, + hasThroneOfDestiny); + } + + /// + /// acdream's unattended character-management adaptation: choose the first + /// active, non-greyed identity in retail wire order. Deleted identities are + /// intentionally outside the candidate collection. + /// + public static bool TrySelectFirstAvailable(Parsed parsed, out Selection selection) + { + ArgumentNullException.ThrowIfNull(parsed); + + for (int i = 0; i < parsed.Characters.Count; i++) + { + Character character = parsed.Characters[i]; + if (!IsAvailableActiveIdentity(character)) + continue; + + selection = new Selection(i, character); + return true; + } + + selection = default; + return false; + } + + /// + /// Returns whether an identity from the active collection can be selected. + /// The collection context is deliberate: deleted identities share the same + /// wire record but are never selectable. + /// + public static bool IsAvailableActiveIdentity(Character character) => + character.Id != 0 && character.SecondsGreyedOut == 0; + + private static Character[] ReadCharacters( + ReadOnlySpan body, + ref int pos, + string collectionName) + { + uint count = ReadU32(body, ref pos); + // CharacterIdentity's minimum aligned wire size is twelve bytes: + // guid + empty String16L record + secondsGreyedOut. Derive the bound + // from the packet rather than inventing a retail-external count cap. + if (count > (uint)((body.Length - pos) / 12)) + throw new FormatException( + $"{collectionName} character count {count} exceeds remaining payload"); + + var characters = new Character[checked((int)count)]; + for (int i = 0; i < characters.Length; i++) + { + uint id = ReadU32(body, ref pos); + string name = ReadString16L(body, ref pos); + uint secondsGreyedOut = ReadU32(body, ref pos); + characters[i] = new Character(id, name, secondsGreyedOut); + } + + return characters; } private static uint ReadU32(ReadOnlySpan source, ref int pos) @@ -97,6 +155,8 @@ public static class CharacterList pos += length; int recordSize = 2 + length; int padding = (4 - (recordSize & 3)) & 3; + if (source.Length - pos < padding) + throw new FormatException("truncated String16L padding"); pos += padding; return result; } diff --git a/src/AcDream.Core.Net/Messages/CharacterLogOff.cs b/src/AcDream.Core.Net/Messages/CharacterLogOff.cs index d92a50e0..0ca97def 100644 --- a/src/AcDream.Core.Net/Messages/CharacterLogOff.cs +++ b/src/AcDream.Core.Net/Messages/CharacterLogOff.cs @@ -33,6 +33,6 @@ public static class CharacterLogOff /// confirmation. ACE emits the canonical four-byte opcode-only form. /// public static bool IsConfirmation(ReadOnlySpan body) => - body.Length >= sizeof(uint) && + body.Length == sizeof(uint) && BinaryPrimitives.ReadUInt32LittleEndian(body) == Opcode; } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index fa9a3366..1f27bc25 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -627,6 +627,7 @@ public sealed class WorldSession : IDisposable private IsaacRandom? _outboundIsaac; private ushort _sessionClientId; private ushort _sessionIteration; + private bool _transportNegotiated; private uint _clientPacketSequence; private uint _fragmentSequence = 1; @@ -732,9 +733,6 @@ public sealed class WorldSession : IDisposable // in the ConnectRequest optional section. Publish it to // subscribers so WorldTimeService.SyncFromServer can seed the // client clock. - LastServerTimeTicks = opt.ConnectRequestServerTime; - ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime); - byte[] serverSeedBytes = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed); byte[] clientSeedBytes = new byte[4]; @@ -746,8 +744,15 @@ public sealed class WorldSession : IDisposable // generation into connection-level control packets, including the // final disconnect. ACE currently emits iteration 1. _sessionIteration = cr.Header.Iteration; + _transportNegotiated = true; _clientPacketSequence = 2; + // Publish only after the receiver identity and crypto state are fully + // committed. A synchronous App callback may throw or request teardown; + // disposal must still send the negotiated Disconnect. + LastServerTimeTicks = opt.ConnectRequestServerTime; + ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime); + byte[] crBody = new byte[8]; BinaryPrimitives.WriteUInt64LittleEndian(crBody, opt.ConnectRequestCookie); var crHeader = new PacketHeader { Sequence = 1, Flags = PacketHeaderFlags.ConnectResponse, Id = 0 }; @@ -770,15 +775,15 @@ public sealed class WorldSession : IDisposable /// Returns once the server starts sending CreateObjects (at which point /// callers should poll to stream events). /// - public void EnterWorld(string account, int characterIndex = 0, TimeSpan? timeout = null) + public void EnterWorld(int characterIndex = 0, TimeSpan? timeout = null) { if (Characters is null || Characters.Characters.Count == 0) throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList"); - if (characterIndex < 0 || characterIndex >= Characters.Characters.Count) - throw new ArgumentOutOfRangeException(nameof(characterIndex)); - var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); - var chosen = Characters.Characters[characterIndex]; + EnterWorldSelection selection = SelectCharacterForEnterWorld( + Characters, + characterIndex); + CharacterList.Character chosen = selection.Character; _activeCharacterId = chosen.Id; Transition(State.EnteringWorld); @@ -795,7 +800,10 @@ public sealed class WorldSession : IDisposable } if (!serverReady) { Transition(State.Failed); throw new TimeoutException("ServerReady not received"); } - SendGameMessage(CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, account)); + // CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account + // populated by CharacterSet::UnPack, not the spelling supplied to the + // 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/ @@ -819,6 +827,35 @@ public sealed class WorldSession : IDisposable _netThread.Start(); } + internal readonly record struct EnterWorldSelection( + CharacterList.Character Character, + byte[] EnterWorldBody); + + /// + /// Applies retail's selected-active-character gate and binds the F657 + /// account field to the canonical CharacterSet account. Kept pure so the + /// public session invariant is conformance-tested without opening a socket. + /// + internal static EnterWorldSelection SelectCharacterForEnterWorld( + CharacterList.Parsed characters, + int characterIndex) + { + ArgumentNullException.ThrowIfNull(characters); + if (characterIndex < 0 || characterIndex >= characters.Characters.Count) + throw new ArgumentOutOfRangeException(nameof(characterIndex)); + + CharacterList.Character chosen = characters.Characters[characterIndex]; + if (!CharacterList.IsAvailableActiveIdentity(chosen)) + throw new InvalidOperationException( + "selected character must be an active, non-greyed identity"); + + return new EnterWorldSelection( + chosen, + CharacterEnterWorld.BuildEnterWorldBody( + chosen.Id, + characters.AccountName)); + } + // Per-frame inbound time budget (#2 flood timeslice). On a teleport arrival ACE floods // a town's CreateObjects; draining them ALL in one Tick (each hydrates mesh + textures // under _datLock on the render thread) monopolized the update loop for ~a minute and @@ -1951,38 +1988,38 @@ public sealed class WorldSession : IDisposable if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return; - if (CurrentState == State.InWorld) + SessionShutdownPlan shutdown = BuildShutdownPlan( + CurrentState, + _transportNegotiated, + _activeCharacterId); + + Interlocked.Exchange(ref _characterLogOffConfirmed, 0); + ShutdownExecutionResult result = ExecuteShutdownWire( + shutdown, + _activeCharacterId, + _sessionClientId, + _sessionIteration, + SendGameMessage, + WaitForCharacterLogOffConfirmation, + packet => _net.Send(packet), + TimeSpan.FromSeconds(35)); + + if (result.CharacterLogOffSent) { - try - { - // Proto_UI::LogOffCharacter @ 0x00546A20 sends the active id. - Interlocked.Exchange(ref _characterLogOffConfirmed, 0); - SendGameMessage(CharacterLogOff.BuildRequestBody(_activeCharacterId)); - Console.WriteLine( - $"[session] graceful logout requested character=0x{_activeCharacterId:X8}"); - - if (WaitForCharacterLogOffConfirmation(TimeSpan.FromSeconds(35))) - Console.WriteLine("[session] graceful logout confirmed"); - else - Console.Error.WriteLine( - "[session] graceful logout confirmation timed out; disconnecting transport"); - } - catch (Exception error) - { + Console.WriteLine( + $"[session] graceful logout requested character=0x{_activeCharacterId:X8}"); + if (result.ConfirmationReceived) + Console.WriteLine("[session] graceful logout confirmed"); + else if (result.CharacterLogOffError is null) Console.Error.WriteLine( - $"[session] graceful logout failed: {error.Message}"); - } - - try - { - SendTransportDisconnect(); - } - catch (Exception error) - { - Console.Error.WriteLine( - $"[session] transport disconnect failed: {error.Message}"); - } + "[session] graceful logout confirmation timed out; disconnecting transport"); } + if (result.CharacterLogOffError is not null) + Console.Error.WriteLine( + $"[session] graceful logout failed: {result.CharacterLogOffError.Message}"); + if (result.TransportDisconnectError is not null) + Console.Error.WriteLine( + $"[session] transport disconnect failed: {result.TransportDisconnectError.Message}"); // Phase A.3: shut down the background receive thread. Cancel the // token → the 250ms receive timeout fires → loop exits → join. @@ -1995,25 +2032,132 @@ public sealed class WorldSession : IDisposable Transition(State.Disconnected); } - private bool WaitForCharacterLogOffConfirmation(TimeSpan timeout) + internal readonly record struct SessionShutdownPlan( + bool RequestCharacterLogOff, + bool SendTransportDisconnect); + + internal readonly record struct ShutdownExecutionResult( + bool CharacterLogOffSent, + bool ConfirmationReceived, + bool TransportDisconnectSent, + Exception? CharacterLogOffError, + Exception? TransportDisconnectError); + + internal static SessionShutdownPlan BuildShutdownPlan( + State state, + bool transportNegotiated, + uint activeCharacterId) => + new( + RequestCharacterLogOff: + transportNegotiated + && state == State.InWorld + && activeCharacterId != 0, + SendTransportDisconnect: transportNegotiated); + + /// + /// Executes the retail wire order while keeping request and transport + /// failures independent. The delegates are the production send/wait path; + /// their narrow shape also makes exact packet ordering deterministic in + /// tests without duplicating shutdown logic. + /// + internal static ShutdownExecutionResult ExecuteShutdownWire( + SessionShutdownPlan plan, + uint activeCharacterId, + ushort sessionClientId, + ushort sessionIteration, + Action sendGameMessage, + Func waitForConfirmation, + Action sendTransportDatagram, + TimeSpan confirmationTimeout) { + ArgumentNullException.ThrowIfNull(sendGameMessage); + ArgumentNullException.ThrowIfNull(waitForConfirmation); + ArgumentNullException.ThrowIfNull(sendTransportDatagram); + + bool characterLogOffSent = false; + bool confirmationReceived = false; + bool transportDisconnectSent = false; + Exception? characterError = null; + Exception? transportError = null; + + if (plan.RequestCharacterLogOff) + { + try + { + // Proto_UI::LogOffCharacter @ 0x00546A20 sends the active id. + sendGameMessage(CharacterLogOff.BuildRequestBody(activeCharacterId)); + characterLogOffSent = true; + confirmationReceived = waitForConfirmation(confirmationTimeout); + } + catch (Exception error) + { + characterError = error; + } + } + + if (plan.SendTransportDisconnect) + { + try + { + // ClientNet::LogOffServer @ 0x00543EF0 sends Disconnect for + // every negotiated ReceiverData, independent of character + // entry state. + sendTransportDatagram(TransportDisconnect.Build( + sessionClientId, + sessionIteration)); + transportDisconnectSent = true; + } + catch (Exception error) + { + transportError = error; + } + } + + return new ShutdownExecutionResult( + characterLogOffSent, + confirmationReceived, + transportDisconnectSent, + characterError, + transportError); + } + + private bool WaitForCharacterLogOffConfirmation(TimeSpan timeout) => + WaitForCharacterLogOffConfirmation( + _inboundQueue.Reader, + timeout, + bytes => + { + ProcessDatagram(bytes, dispatchWorldEvents: false); + return Volatile.Read(ref _characterLogOffConfirmed) != 0; + }); + + internal static bool WaitForCharacterLogOffConfirmation( + ChannelReader reader, + TimeSpan timeout, + Func processAndCheckConfirmation) + { + ArgumentNullException.ThrowIfNull(reader); + ArgumentNullException.ThrowIfNull(processAndCheckConfirmation); using var timeoutSource = new CancellationTokenSource(timeout); try { - while (Volatile.Read(ref _characterLogOffConfirmed) == 0) + while (!timeoutSource.IsCancellationRequested) { - while (_inboundQueue.Reader.TryRead(out byte[]? bytes)) + while (reader.TryRead(out byte[]? bytes)) { - ProcessDatagram(bytes, dispatchWorldEvents: false); - if (Volatile.Read(ref _characterLogOffConfirmed) != 0) + if (timeoutSource.IsCancellationRequested) + return false; + if (processAndCheckConfirmation(bytes)) return true; } - _inboundQueue.Reader.WaitToReadAsync(timeoutSource.Token) + bool canRead = reader.WaitToReadAsync(timeoutSource.Token) .AsTask() .GetAwaiter() .GetResult(); + if (!canRead) + return false; } } catch (OperationCanceledException) @@ -2025,14 +2169,6 @@ public sealed class WorldSession : IDisposable return false; } - return true; - } - - private void SendTransportDisconnect() - { - byte[] disconnectPacket = TransportDisconnect.Build( - _sessionClientId, - _sessionIteration); - _net.Send(disconnectPacket); + return false; } } diff --git a/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs b/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs index a096262e..52ac51d6 100644 --- a/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs +++ b/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs @@ -347,7 +347,10 @@ public class LiveHandshakeTests Assert.NotNull(charList); Assert.NotEmpty(charList!.Characters); - var chosen = charList.Characters[0]; + Assert.True( + CharacterList.TrySelectFirstAvailable(charList, out var selection), + "CharacterList contains no active, non-greyed character"); + var chosen = selection.Character; Console.WriteLine($"[live] choosing character: 0x{chosen.Id:X8} {chosen.Name}"); // ---- Step 5: send CharacterEnterWorldRequest (UIQueue, encrypted checksum) ---- @@ -403,7 +406,7 @@ public class LiveHandshakeTests // ---- Step 7: send CharacterEnterWorld with the chosen GUID. ---- SendGameMessage( - CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, user), + CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, charList.AccountName), $"CharacterEnterWorld(guid=0x{chosen.Id:X8})"); // ---- Step 8: receive the CreateObject flood + parse bodies. ---- diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs index 0fa1d264..0b607f0a 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterEnterWorldTests.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; @@ -41,6 +42,67 @@ public class CharacterEnterWorldTests Assert.Equal(0, body[pos++]); Assert.Equal(4 + 4 + 16, body.Length); // opcode + guid + padded string } + + [Fact] + public void SessionSelection_UsesCanonicalCharacterListAccountInF657() + { + CharacterList.Parsed characters = MakeCharacters( + [new CharacterList.Character(0x50000001u, "Ready", 0)], + accountName: "CanonicalCase"); + + WorldSession.EnterWorldSelection selection = + WorldSession.SelectCharacterForEnterWorld(characters, 0); + + ReadOnlySpan body = selection.EnterWorldBody; + Assert.Equal(CharacterEnterWorld.EnterWorldOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body)); + Assert.Equal(0x50000001u, + BinaryPrimitives.ReadUInt32LittleEndian(body[4..])); + ushort length = BinaryPrimitives.ReadUInt16LittleEndian(body[8..]); + Assert.Equal("CanonicalCase", + System.Text.Encoding.ASCII.GetString(body.Slice(10, length))); + } + + [Theory] + [InlineData(0u, 0u)] + [InlineData(0x50000001u, 1u)] + [InlineData(0x50000001u, 30u)] + public void SessionSelection_RejectsUnavailableActiveIdentity( + uint guid, + uint secondsGreyedOut) + { + CharacterList.Parsed characters = MakeCharacters( + [new CharacterList.Character(guid, "Unavailable", secondsGreyedOut)], + accountName: "Account"); + + Assert.Throws(() => + WorldSession.SelectCharacterForEnterWorld(characters, 0)); + } + + [Theory] + [InlineData(-1)] + [InlineData(1)] + public void SessionSelection_RejectsIndexOutsideActiveCollection(int index) + { + CharacterList.Parsed characters = MakeCharacters( + [new CharacterList.Character(0x50000001u, "Ready", 0)], + accountName: "Account"); + + Assert.Throws(() => + WorldSession.SelectCharacterForEnterWorld(characters, index)); + } + + private static CharacterList.Parsed MakeCharacters( + IReadOnlyList active, + string accountName) => + new( + Status: 0, + Characters: active, + DeletedCharacters: [], + SlotCount: 11, + AccountName: accountName, + UseTurbineChat: true, + HasThroneOfDestiny: true); } public class GameMessageFragmentTests diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs index 84c58e68..c38a854e 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterListTests.cs @@ -9,10 +9,10 @@ public class CharacterListTests [Fact] public void Parse_SingleCharacter_ExtractsGuidAndName() { - // Hand-assemble a CharacterList body matching ACE's writer format: - // u32 opcode, u32 0, u32 count=1, - // u32 guid, String16L name, u32 deleteDelta, - // u32 0, u32 slotCount, String16L accountName, + // Hand-assemble retail CharacterSet wire layout as emitted by ACE: + // u32 opcode, u32 status=0, u32 activeCount=1, + // u32 guid, String16L name, u32 secondsGreyedOut, + // u32 deletedCount=0, i32 numAllowed, String16L accountName, // u32 useTurbineChat, u32 hasToD var w = new PacketWriter(128); w.WriteUInt32(CharacterList.Opcode); @@ -29,11 +29,13 @@ public class CharacterListTests var parsed = CharacterList.Parse(w.ToArray()); + Assert.Equal(0u, parsed.Status); Assert.Single(parsed.Characters); + Assert.Empty(parsed.DeletedCharacters); Assert.Equal(0x50000001u, parsed.Characters[0].Id); Assert.Equal("+Acdream", parsed.Characters[0].Name); - Assert.Equal(0u, parsed.Characters[0].DeleteTimeDelta); - Assert.Equal(11u, parsed.SlotCount); + Assert.Equal(0u, parsed.Characters[0].SecondsGreyedOut); + Assert.Equal(11, parsed.SlotCount); Assert.Equal("testaccount", parsed.AccountName); Assert.True(parsed.UseTurbineChat); Assert.True(parsed.HasThroneOfDestiny); @@ -69,6 +71,152 @@ public class CharacterListTests Assert.Equal("Carol", parsed.Characters[2].Name); } + [Fact] + public void Parse_NonzeroStatusAndDeletedCollection_PreservesRetailLayout() + { + var w = new PacketWriter(160); + w.WriteUInt32(CharacterList.Opcode); + w.WriteUInt32(7); // status + w.WriteUInt32(1); // active count + WriteIdentity(w, 0x50000001u, "Active", 0); + w.WriteUInt32(2); // deleted count, not padding + WriteIdentity(w, 0x50000002u, "Deleted One", 90); + WriteIdentity(w, 0x50000003u, "Deleted Two", 180); + WriteTail(w, 13, "CanonicalAccount", useTurbineChat: true, hasTod: false); + + CharacterList.Parsed parsed = CharacterList.Parse(w.ToArray()); + + Assert.Equal(7u, parsed.Status); + Assert.Single(parsed.Characters); + Assert.Equal("Active", parsed.Characters[0].Name); + Assert.Collection( + parsed.DeletedCharacters, + character => + { + Assert.Equal(0x50000002u, character.Id); + Assert.Equal("Deleted One", character.Name); + Assert.Equal(90u, character.SecondsGreyedOut); + }, + character => + { + Assert.Equal(0x50000003u, character.Id); + Assert.Equal("Deleted Two", character.Name); + Assert.Equal(180u, character.SecondsGreyedOut); + }); + Assert.Equal(13, parsed.SlotCount); + Assert.Equal("CanonicalAccount", parsed.AccountName); + Assert.True(parsed.UseTurbineChat); + Assert.False(parsed.HasThroneOfDestiny); + } + + [Fact] + public void TrySelectFirstAvailable_SkipsZeroGuidAndGreyedActiveCharacters() + { + CharacterList.Parsed parsed = BuildParsed( + [ + new CharacterList.Character(0, "Empty", 0), + new CharacterList.Character(0x50000001u, "Deleting", 30), + new CharacterList.Character(0x50000002u, "Ready", 0), + ], + [new CharacterList.Character(0x50000003u, "Deleted", 0)]); + + bool found = CharacterList.TrySelectFirstAvailable(parsed, out var selection); + + Assert.True(found); + Assert.Equal(2, selection.ActiveIndex); + Assert.Equal(0x50000002u, selection.Character.Id); + } + + [Fact] + public void TrySelectFirstAvailable_RejectsAllGreyedAndNeverReadsDeletedCollection() + { + CharacterList.Parsed parsed = BuildParsed( + [new CharacterList.Character(0x50000001u, "Deleting", 1)], + [new CharacterList.Character(0x50000002u, "Deleted Ready", 0)]); + + Assert.False(CharacterList.TrySelectFirstAvailable(parsed, out _)); + } + + [Fact] + public void Parse_EmptyCollectionsAndNegativeAllowedSentinel_ArePreserved() + { + var w = new PacketWriter(64); + w.WriteUInt32(CharacterList.Opcode); + w.WriteUInt32(0); + w.WriteUInt32(0); // active count + w.WriteUInt32(0); // deleted count + w.WriteUInt32(uint.MaxValue); // retail signed -1 sentinel + w.WriteString16L("Account"); + w.WriteUInt32(2); // every nonzero retail int is true + w.WriteUInt32(uint.MaxValue); + + CharacterList.Parsed parsed = CharacterList.Parse(w.ToArray()); + + Assert.Empty(parsed.Characters); + Assert.Empty(parsed.DeletedCharacters); + Assert.Equal(-1, parsed.SlotCount); + Assert.True(parsed.UseTurbineChat); + Assert.True(parsed.HasThroneOfDestiny); + Assert.False(CharacterList.TrySelectFirstAvailable(parsed, out _)); + } + + [Fact] + public void Parse_CountThatCannotFitRemainingPayload_IsRejected() + { + var active = new PacketWriter(16); + active.WriteUInt32(CharacterList.Opcode); + active.WriteUInt32(0); + active.WriteUInt32(256); + Assert.Throws(() => CharacterList.Parse(active.ToArray())); + + var deleted = new PacketWriter(20); + deleted.WriteUInt32(CharacterList.Opcode); + deleted.WriteUInt32(0); + deleted.WriteUInt32(0); + deleted.WriteUInt32(256); + Assert.Throws(() => CharacterList.Parse(deleted.ToArray())); + } + + [Fact] + public void Parse_255MinimumSizeIdentities_UsesPayloadBoundNotArbitraryCap() + { + var w = new PacketWriter(4096); + w.WriteUInt32(CharacterList.Opcode); + w.WriteUInt32(0); + w.WriteUInt32(255); + for (uint i = 1; i <= 255; i++) + WriteIdentity(w, i, string.Empty, 0); + w.WriteUInt32(0); + WriteTail(w, 255, "Account", useTurbineChat: true, hasTod: true); + + CharacterList.Parsed parsed = CharacterList.Parse(w.ToArray()); + + Assert.Equal(255, parsed.Characters.Count); + Assert.Equal(255, parsed.SlotCount); + } + + [Fact] + public void Parse_EveryTruncatedPrefixOfRetailPayload_Throws() + { + var w = new PacketWriter(160); + w.WriteUInt32(CharacterList.Opcode); + w.WriteUInt32(3); + w.WriteUInt32(1); + WriteIdentity(w, 0x50000001u, "A", 0); + w.WriteUInt32(1); + WriteIdentity(w, 0x50000002u, "Deleted", 22); + WriteTail(w, 11, "Account", useTurbineChat: true, hasTod: true); + byte[] valid = w.ToArray(); + + for (int length = 0; length < valid.Length; length++) + { + byte[] prefix = valid.AsSpan(0, length).ToArray(); + Assert.Throws(() => CharacterList.Parse(prefix)); + } + + _ = CharacterList.Parse(valid); + } + [Fact] public void Parse_WrongOpcode_Throws() { @@ -84,4 +232,40 @@ public class CharacterListTests BinaryPrimitives.WriteUInt32LittleEndian(bytes, CharacterList.Opcode); Assert.Throws(() => CharacterList.Parse(bytes)); } + + private static void WriteIdentity( + PacketWriter writer, + uint id, + string name, + uint secondsGreyedOut) + { + writer.WriteUInt32(id); + writer.WriteString16L(name); + writer.WriteUInt32(secondsGreyedOut); + } + + private static void WriteTail( + PacketWriter writer, + int slotCount, + string accountName, + bool useTurbineChat, + bool hasTod) + { + writer.WriteUInt32(unchecked((uint)slotCount)); + writer.WriteString16L(accountName); + writer.WriteUInt32(useTurbineChat ? 1u : 0u); + writer.WriteUInt32(hasTod ? 1u : 0u); + } + + private static CharacterList.Parsed BuildParsed( + IReadOnlyList active, + IReadOnlyList deleted) => + new( + Status: 0, + Characters: active, + DeletedCharacters: deleted, + SlotCount: 11, + AccountName: "Account", + UseTurbineChat: true, + HasThroneOfDestiny: true); } diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterLogOffTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterLogOffTests.cs index 41af606c..bfeb627b 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CharacterLogOffTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterLogOffTests.cs @@ -33,4 +33,12 @@ public sealed class CharacterLogOffTests Assert.False(CharacterLogOff.IsConfirmation([0x53, 0xF6, 0x00])); Assert.False(CharacterLogOff.IsConfirmation(BitConverter.GetBytes(0xF654u))); } + + [Fact] + public void IsConfirmation_RejectsRequestShapedBodyWithCharacterId() + { + byte[] request = CharacterLogOff.BuildRequestBody(0x5000000Au); + + Assert.False(CharacterLogOff.IsConfirmation(request)); + } } diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionNegotiationShutdownTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionNegotiationShutdownTests.cs new file mode 100644 index 00000000..90d70e57 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionNegotiationShutdownTests.cs @@ -0,0 +1,77 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using AcDream.Core.Net.Cryptography; +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Tests; + +public sealed class WorldSessionNegotiationShutdownTests +{ + [Fact] + public async Task ThrowingServerTimeSubscriber_StillDisposesWithNegotiatedDisconnect() + { + using var server = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + var serverEndpoint = (IPEndPoint)server.Client.LocalEndPoint!; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + Task serverFlow = Task.Run(async () => + { + UdpReceiveResult login = await server.ReceiveAsync(timeout.Token); + byte[] connectRequest = BuildConnectRequest( + clientId: 0x1234, + iteration: 7, + serverTime: 12345.5); + await server.SendAsync(connectRequest, login.RemoteEndPoint, timeout.Token); + + UdpReceiveResult disconnect = await server.ReceiveAsync(timeout.Token); + return disconnect.Buffer; + }, timeout.Token); + + using var session = new WorldSession(serverEndpoint); + session.ServerTimeUpdated += _ => + throw new InvalidOperationException("subscriber failed"); + + Assert.Throws(() => + session.Connect("typed-account", "password", TimeSpan.FromSeconds(3))); + + session.Dispose(); + byte[] datagram = await serverFlow; + PacketCodec.PacketDecodeResult decoded = + PacketCodec.TryDecode(datagram, inboundIsaac: null); + + Assert.True(decoded.IsOk, decoded.Error.ToString()); + Assert.True(decoded.Packet!.Header.HasFlag(PacketHeaderFlags.Disconnect)); + Assert.Equal((ushort)0x1234, decoded.Packet.Header.Id); + Assert.Equal((ushort)7, decoded.Packet.Header.Iteration); + Assert.Equal(WorldSession.State.Disconnected, session.CurrentState); + } + + private static byte[] BuildConnectRequest( + ushort clientId, + ushort iteration, + double serverTime) + { + byte[] body = new byte[32]; + BinaryPrimitives.WriteInt64LittleEndian( + body, + BitConverter.DoubleToInt64Bits(serverTime)); + BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(8), 0xFEEDFACECAFEBABEUL); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), clientId); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), 0xAABBCCDDu); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), 0x01020304u); + + var header = new PacketHeader + { + Flags = PacketHeaderFlags.ConnectRequest, + DataSize = (ushort)body.Length, + Iteration = iteration, + }; + header.Checksum = header.CalculateHeaderHash32() + Hash32.Calculate(body); + + byte[] datagram = new byte[PacketHeader.Size + body.Length]; + header.Pack(datagram); + body.CopyTo(datagram.AsSpan(PacketHeader.Size)); + return datagram; + } +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs new file mode 100644 index 00000000..4bff3d30 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs @@ -0,0 +1,226 @@ +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Net.Packets; +using System.Diagnostics; +using System.Threading.Channels; + +namespace AcDream.Core.Net.Tests; + +public sealed class WorldSessionShutdownTests +{ + [Theory] + [InlineData(WorldSession.State.Disconnected)] + [InlineData(WorldSession.State.Handshaking)] + public void PreNegotiationShutdown_OnlyReleasesLocalTransport( + WorldSession.State state) + { + WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan( + state, + transportNegotiated: false, + activeCharacterId: 0); + + Assert.False(plan.RequestCharacterLogOff); + Assert.False(plan.SendTransportDisconnect); + } + + [Theory] + [InlineData(WorldSession.State.Handshaking)] + [InlineData(WorldSession.State.InCharacterSelect)] + [InlineData(WorldSession.State.EnteringWorld)] + [InlineData(WorldSession.State.Failed)] + [InlineData(WorldSession.State.Disconnected)] + public void EveryNegotiatedNonWorldState_SendsTransportDisconnectOnly( + WorldSession.State state) + { + WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan( + state, + transportNegotiated: true, + activeCharacterId: 0x50000001u); + + Assert.False(plan.RequestCharacterLogOff); + Assert.True(plan.SendTransportDisconnect); + } + + [Fact] + public void NegotiatedInWorldState_RequestsCharacterLogoffThenDisconnect() + { + WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan( + WorldSession.State.InWorld, + transportNegotiated: true, + activeCharacterId: 0x50000001u); + + Assert.True(plan.RequestCharacterLogOff); + Assert.True(plan.SendTransportDisconnect); + } + + [Fact] + public void InWorldWithoutActiveCharacter_DoesNotSendMalformedF653Request() + { + WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan( + WorldSession.State.InWorld, + transportNegotiated: true, + activeCharacterId: 0); + + Assert.False(plan.RequestCharacterLogOff); + Assert.True(plan.SendTransportDisconnect); + } + + [Fact] + public void ImpossibleUnnegotiatedInWorldState_DoesNotAttemptWireMessages() + { + WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan( + WorldSession.State.InWorld, + transportNegotiated: false, + activeCharacterId: 0x50000001u); + + Assert.False(plan.RequestCharacterLogOff); + Assert.False(plan.SendTransportDisconnect); + } + + [Fact] + public void ExecuteShutdownWire_SendsExactRequestWaitThenNegotiatedDisconnect() + { + var order = new List(); + byte[]? request = null; + byte[]? disconnect = null; + WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan( + WorldSession.State.InWorld, + transportNegotiated: true, + activeCharacterId: 0x5000000Au); + + WorldSession.ShutdownExecutionResult result = WorldSession.ExecuteShutdownWire( + plan, + activeCharacterId: 0x5000000Au, + sessionClientId: 0x1234, + sessionIteration: 7, + sendGameMessage: body => + { + order.Add("F653"); + request = body; + }, + waitForConfirmation: timeout => + { + order.Add("wait"); + Assert.Equal(TimeSpan.FromSeconds(35), timeout); + return true; + }, + sendTransportDatagram: datagram => + { + order.Add("disconnect"); + disconnect = datagram; + }, + confirmationTimeout: TimeSpan.FromSeconds(35)); + + Assert.Equal(["F653", "wait", "disconnect"], order); + Assert.Equal(CharacterLogOff.BuildRequestBody(0x5000000Au), request); + PacketCodec.PacketDecodeResult decoded = + PacketCodec.TryDecode(disconnect!, inboundIsaac: null); + Assert.True(decoded.IsOk, decoded.Error.ToString()); + Assert.True(decoded.Packet!.Header.HasFlag(PacketHeaderFlags.Disconnect)); + Assert.Equal((ushort)0x1234, decoded.Packet.Header.Id); + Assert.Equal((ushort)7, decoded.Packet.Header.Iteration); + Assert.True(result.CharacterLogOffSent); + Assert.True(result.ConfirmationReceived); + Assert.True(result.TransportDisconnectSent); + Assert.Null(result.CharacterLogOffError); + Assert.Null(result.TransportDisconnectError); + } + + [Fact] + public void ExecuteShutdownWire_RequestFailureStillSendsDisconnect() + { + var order = new List(); + WorldSession.SessionShutdownPlan plan = WorldSession.BuildShutdownPlan( + WorldSession.State.InWorld, + transportNegotiated: true, + activeCharacterId: 0x50000001u); + + WorldSession.ShutdownExecutionResult result = WorldSession.ExecuteShutdownWire( + plan, + activeCharacterId: 0x50000001u, + sessionClientId: 1, + sessionIteration: 2, + sendGameMessage: _ => + { + order.Add("F653"); + throw new IOException("send failed"); + }, + waitForConfirmation: _ => + { + order.Add("wait"); + return true; + }, + sendTransportDatagram: _ => order.Add("disconnect"), + confirmationTimeout: TimeSpan.FromSeconds(1)); + + Assert.Equal(["F653", "disconnect"], order); + Assert.False(result.CharacterLogOffSent); + Assert.False(result.ConfirmationReceived); + Assert.True(result.TransportDisconnectSent); + Assert.IsType(result.CharacterLogOffError); + } + + [Fact] + public void WaitForConfirmation_CompletedEmptyChannelReturnsWithoutSpinning() + { + Channel channel = Channel.CreateUnbounded(); + channel.Writer.TryComplete(); + var stopwatch = Stopwatch.StartNew(); + + bool confirmed = WorldSession.WaitForCharacterLogOffConfirmation( + channel.Reader, + TimeSpan.FromSeconds(2), + _ => false); + + Assert.False(confirmed); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1)); + } + + [Fact] + public void WaitForConfirmation_IgnoresMalformedBodiesUntilExactOpcodeOnlyBody() + { + Channel channel = Channel.CreateUnbounded(); + channel.Writer.TryWrite([0x53, 0xF6, 0x00]); + channel.Writer.TryWrite(BitConverter.GetBytes(0xF654u)); + channel.Writer.TryWrite(CharacterLogOff.BuildRequestBody(0x50000001u)); + channel.Writer.TryWrite(BitConverter.GetBytes(CharacterLogOff.Opcode)); + channel.Writer.TryComplete(); + int processed = 0; + + bool confirmed = WorldSession.WaitForCharacterLogOffConfirmation( + channel.Reader, + TimeSpan.FromSeconds(1), + body => + { + processed++; + return CharacterLogOff.IsConfirmation(body); + }); + + Assert.True(confirmed); + Assert.Equal(4, processed); + } + + [Fact] + public void WaitForConfirmation_TimeoutWinsDuringContinuousUnrelatedDrain() + { + Channel channel = Channel.CreateUnbounded(); + for (int i = 0; i < 100; i++) + channel.Writer.TryWrite(BitConverter.GetBytes(0xF654u)); + int processed = 0; + var stopwatch = Stopwatch.StartNew(); + + bool confirmed = WorldSession.WaitForCharacterLogOffConfirmation( + channel.Reader, + TimeSpan.FromMilliseconds(25), + _ => + { + processed++; + Thread.Sleep(2); + return false; + }); + + Assert.False(confirmed); + Assert.True(processed < 100); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1)); + } +} From 88fe1db37b68170cfdff0abbd2d09a6ed9d1e6a8 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 10:33:25 +0200 Subject: [PATCH 26/98] docs(plan): record session conformance checkpoint --- .../2026-07-21-gamewindow-slice-3-live-session.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md index cbed3d14..3558a00a 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md +++ b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md @@ -11,6 +11,20 @@ disconnect-state gaps discovered by the slice audit. This slice changes App ownership and fixes proven session-lifetime defects; it does not add a retained character-selection UI or redesign the transport. +**Progress ledger:** + +- [x] A0 — retail lifecycle conformance corrections (`aea957f8`): retail + active/deleted CharacterSet parsing, validated unattended selection, + canonical-account F657, exact F653 confirmation, negotiated-state + Disconnect symmetry, closed-channel/flood-safe confirmation wait, and + deterministic shutdown tests. Release: 6,605 passed / 5 skipped. +- [ ] A — owned subscription primitives (in progress). +- [ ] B — session event and command routers. +- [ ] C — convergent session reset. +- [ ] D — complete lifecycle controller. +- [ ] E — GameWindow cutover. +- [ ] F — final review, connected gate, and documentation closeout. + ## 1. Outcome `LiveSessionController` becomes the sole App owner of one exact From 7d452aa6a27e54ffd686580f5375e0dd297f06f1 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 10:45:15 +0200 Subject: [PATCH 27/98] refactor(net): own session wiring subscriptions --- src/AcDream.Core.Net/CombatStateWiring.cs | 17 +- src/AcDream.Core.Net/GameEventWiring.cs | 125 +++++++++----- .../Messages/GameEventDispatcher.cs | 68 +++++++- src/AcDream.Core.Net/ObjectTableWiring.cs | 27 ++- src/AcDream.Core.Net/SubscriptionSet.cs | 61 +++++++ .../Messages/GameEventDispatcherTests.cs | 162 +++++++++++++++++- .../SubscriptionSetTests.cs | 47 +++++ .../WorldSessionWiringOwnershipTests.cs | 131 ++++++++++++++ 8 files changed, 577 insertions(+), 61 deletions(-) create mode 100644 src/AcDream.Core.Net/SubscriptionSet.cs create mode 100644 tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs diff --git a/src/AcDream.Core.Net/CombatStateWiring.cs b/src/AcDream.Core.Net/CombatStateWiring.cs index 44f2514b..9e637e60 100644 --- a/src/AcDream.Core.Net/CombatStateWiring.cs +++ b/src/AcDream.Core.Net/CombatStateWiring.cs @@ -12,13 +12,15 @@ public static class CombatStateWiring { public const uint CombatModePropertyId = 40u; - public static void Wire(WorldSession session, CombatState combat) + public static IDisposable Wire(WorldSession session, CombatState combat) { ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(combat); - session.PlayerIntPropertyUpdated += update => + Action handler = update => ApplyPlayerIntProperty(combat, update.Property, update.Value); + session.PlayerIntPropertyUpdated += handler; + return new EventSubscription(session, handler); } /// @@ -44,4 +46,15 @@ public static class CombatStateWiring combat.SetCombatMode(mode); return true; } + + private sealed class EventSubscription( + WorldSession session, + Action handler) : IDisposable + { + private Action? _unsubscribe = + () => session.PlayerIntPropertyUpdated -= handler; + + public void Dispose() => + Interlocked.Exchange(ref _unsubscribe, null)?.Invoke(); + } } diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index fba3279a..89f9561c 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -18,9 +18,10 @@ namespace AcDream.Core.Net; /// /// /// Call once at startup (or on reconnect) passing the session's -/// dispatcher + the state instances you want to feed. The wiring is -/// additive — if you want to add a custom handler for a specific -/// event, register it AFTER this helper so it overrides the default. +/// dispatcher + the state instances you want to feed. The returned +/// subscription owns exactly these registrations and must be disposed +/// before the session is replaced. A later legacy registration still +/// overrides the corresponding default without becoming owned here. /// /// /// @@ -32,7 +33,7 @@ namespace AcDream.Core.Net; /// public static class GameEventWiring { - public static void WireAll( + public static IDisposable WireAll( GameEventDispatcher dispatcher, ClientObjectTable items, CombatState combat, @@ -88,29 +89,31 @@ public static class GameEventWiring ArgumentNullException.ThrowIfNull(spellbook); ArgumentNullException.ThrowIfNull(chat); clientTime ??= static () => 0d; + var registrar = new OwnedGameEventRegistrar(dispatcher); + using var construction = new RegistrationBuildScope(registrar); // ── Chat ────────────────────────────────────────────────── - dispatcher.Register(GameEventType.ChannelBroadcast, e => + registrar.Register(GameEventType.ChannelBroadcast, e => { var p = GameEvents.ParseChannelBroadcast(e.Payload.Span); if (p is not null) chat.OnChannelBroadcast(p.Value.ChannelId, p.Value.SenderName, p.Value.Message); }); - dispatcher.Register(GameEventType.Tell, e => + registrar.Register(GameEventType.Tell, e => { var p = GameEvents.ParseTell(e.Payload.Span); if (p is not null) chat.OnTellReceived(p.Value.SenderName, p.Value.Message, p.Value.SenderGuid); }); - dispatcher.Register(GameEventType.CommunicationTransientString, e => + registrar.Register(GameEventType.CommunicationTransientString, e => { var p = GameEvents.ParseTransient(e.Payload.Span); if (p is not null) chat.OnSystemMessage(p.Value.Message, p.Value.ChatType); }); - dispatcher.Register(GameEventType.PopupString, e => + registrar.Register(GameEventType.PopupString, e => { var s = GameEvents.ParsePopupString(e.Payload.Span); if (s is not null) chat.OnPopup(s); }); - dispatcher.Register(GameEventType.QueryAgeResponse, e => + registrar.Register(GameEventType.QueryAgeResponse, e => { var p = GameEvents.ParseQueryAgeResponse(e.Payload.Span); if (p is null) return; @@ -121,7 +124,7 @@ public static class GameEventWiring }); if (onConfirmationRequest is not null) { - dispatcher.Register(GameEventType.CharacterConfirmationRequest, e => + registrar.Register(GameEventType.CharacterConfirmationRequest, e => { var request = GameEvents.ParseCharacterConfirmationRequest(e.Payload.Span); if (request is not null) @@ -131,7 +134,7 @@ public static class GameEventWiring if (onConfirmationDone is not null) { - dispatcher.Register(GameEventType.CharacterConfirmationDone, e => + registrar.Register(GameEventType.CharacterConfirmationDone, e => { var done = GameEvents.ParseCharacterConfirmationDone(e.Payload.Span); if (done is not null) @@ -141,7 +144,7 @@ public static class GameEventWiring if (friends is not null) { - dispatcher.Register(GameEventType.FriendsListUpdate, e => + registrar.Register(GameEventType.FriendsListUpdate, e => { FriendsUpdate? update = SocialStateMessages.ParseFriendsUpdate(e.Payload.Span); if (update is not null) friends.Apply(update); @@ -150,7 +153,7 @@ public static class GameEventWiring if (squelch is not null) { - dispatcher.Register(GameEventType.SetSquelchDB, e => + registrar.Register(GameEventType.SetSquelchDB, e => { SquelchDatabase? database = SocialStateMessages.ParseSquelchDatabase(e.Payload.Span); if (database is not null) squelch.Replace(database); @@ -167,7 +170,7 @@ public static class GameEventWiring // server-message handling pattern. if (turbineChat is not null) { - dispatcher.Register(GameEventType.SetTurbineChatChannels, e => + registrar.Register(GameEventType.SetTurbineChatChannels, e => { var p = SetTurbineChatChannels.TryParse(e.Payload.Span); if (p is null) return; @@ -202,26 +205,26 @@ public static class GameEventWiring // (GameEvents.ParseWeenieError(WithString)) but were never registered. // The server fires these for game-logic failures: "not enough mana", // "can't pick that up", "your spell fizzled". Routed to chat. - dispatcher.Register(GameEventType.WeenieError, e => + registrar.Register(GameEventType.WeenieError, e => { var code = GameEvents.ParseWeenieError(e.Payload.Span); if (code is not null) chat.OnWeenieError(code.Value, param: null); }); - dispatcher.Register(GameEventType.WeenieErrorWithString, e => + registrar.Register(GameEventType.WeenieErrorWithString, e => { var p = GameEvents.ParseWeenieErrorWithString(e.Payload.Span); if (p is not null) chat.OnWeenieError(p.Value.ErrorCode, p.Value.Interpolation); }); // ── Combat ──────────────────────────────────────────────── - dispatcher.Register(GameEventType.UpdateHealth, e => + registrar.Register(GameEventType.UpdateHealth, e => { var p = GameEvents.ParseUpdateHealth(e.Payload.Span); if (p is not null) combat.OnUpdateHealth(p.Value.TargetGuid, p.Value.HealthPercent); }); if (itemMana is not null) { - dispatcher.Register(GameEventType.QueryItemManaResponse, e => + registrar.Register(GameEventType.QueryItemManaResponse, e => { var p = GameEvents.ParseQueryItemManaResponse(e.Payload.Span); if (p is not null) @@ -229,67 +232,67 @@ public static class GameEventWiring p.Value.ItemGuid, p.Value.ManaPercent, p.Value.Valid); }); } - dispatcher.Register(GameEventType.VictimNotification, e => + registrar.Register(GameEventType.VictimNotification, e => { var p = GameEvents.ParseVictimNotification(e.Payload.Span); if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error); }); - dispatcher.Register(GameEventType.DefenderNotification, e => + registrar.Register(GameEventType.DefenderNotification, e => { var p = GameEvents.ParseDefenderNotification(e.Payload.Span); if (p is not null) combat.OnDefenderNotification( p.Value.AttackerName, 0u, p.Value.DamageType, p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical); }); - dispatcher.Register(GameEventType.AttackerNotification, e => + registrar.Register(GameEventType.AttackerNotification, e => { var p = GameEvents.ParseAttackerNotification(e.Payload.Span); if (p is not null) combat.OnAttackerNotification( p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, (float)p.Value.HealthPercent); }); - dispatcher.Register(GameEventType.EvasionAttackerNotification, e => + registrar.Register(GameEventType.EvasionAttackerNotification, e => { var name = GameEvents.ParseEvasionAttackerNotification(e.Payload.Span); if (name is not null) combat.OnEvasionAttackerNotification(name); }); - dispatcher.Register(GameEventType.EvasionDefenderNotification, e => + registrar.Register(GameEventType.EvasionDefenderNotification, e => { var name = GameEvents.ParseEvasionDefenderNotification(e.Payload.Span); if (name is not null) combat.OnEvasionDefenderNotification(name); }); - dispatcher.Register(GameEventType.AttackDone, e => + registrar.Register(GameEventType.AttackDone, e => { var p = GameEvents.ParseAttackDone(e.Payload.Span); if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError); }); - dispatcher.Register(GameEventType.CombatCommenceAttack, e => + registrar.Register(GameEventType.CombatCommenceAttack, e => { if (GameEvents.ParseCombatCommenceAttack(e.Payload.Span)) combat.OnCombatCommenceAttack(); }); - dispatcher.Register(GameEventType.KillerNotification, e => + registrar.Register(GameEventType.KillerNotification, e => { var p = GameEvents.ParseKillerNotification(e.Payload.Span); if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info); }); // ── Spells ──────────────────────────────────────────────── - dispatcher.Register(GameEventType.MagicUpdateSpell, e => + registrar.Register(GameEventType.MagicUpdateSpell, e => { var spellId = GameEvents.ParseMagicUpdateSpell(e.Payload.Span); if (spellId is not null) spellbook.OnSpellLearned(spellId.Value); }); - dispatcher.Register(GameEventType.MagicRemoveSpell, e => + registrar.Register(GameEventType.MagicRemoveSpell, e => { var spellId = GameEvents.ParseMagicRemoveSpell(e.Payload.Span); if (spellId is not null) spellbook.OnSpellForgotten(spellId.Value); }); - dispatcher.Register(GameEventType.MagicUpdateEnchantment, e => + registrar.Register(GameEventType.MagicUpdateEnchantment, e => { var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span); if (p is not null) spellbook.OnEnchantmentAdded(ToActiveEnchantment(p.Value, clientTime())); }); - dispatcher.Register(GameEventType.MagicUpdateMultipleEnchantments, e => + registrar.Register(GameEventType.MagicUpdateMultipleEnchantments, e => { var entries = GameEvents.ParseMagicUpdateMultipleEnchantments(e.Payload.Span); if (entries is not null) @@ -299,35 +302,35 @@ public static class GameEventWiring ToActiveEnchantment(entry, receivedAt))); } }); - dispatcher.Register(GameEventType.MagicRemoveEnchantment, e => + registrar.Register(GameEventType.MagicRemoveEnchantment, e => { var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span); if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId); }); - dispatcher.Register(GameEventType.MagicRemoveMultipleEnchantments, e => + registrar.Register(GameEventType.MagicRemoveMultipleEnchantments, e => { var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span); if (entries is not null) spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer))); }); - dispatcher.Register(GameEventType.MagicDispelEnchantment, e => + registrar.Register(GameEventType.MagicDispelEnchantment, e => { var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span); if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId); }); - dispatcher.Register(GameEventType.MagicDispelMultipleEnchantments, e => + registrar.Register(GameEventType.MagicDispelMultipleEnchantments, e => { var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span); if (entries is not null) spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer))); }); - dispatcher.Register(GameEventType.MagicPurgeEnchantments, + registrar.Register(GameEventType.MagicPurgeEnchantments, _ => spellbook.OnPurgeAll()); - dispatcher.Register(GameEventType.MagicPurgeBadEnchantments, + registrar.Register(GameEventType.MagicPurgeBadEnchantments, _ => spellbook.OnPurgeBadEnchantments()); // ── Inventory ───────────────────────────────────────────── - dispatcher.Register(GameEventType.WieldObject, e => + registrar.Register(GameEventType.WieldObject, e => { var p = GameEvents.ParseWieldObject(e.Payload.Span); if (p is null) return; @@ -338,7 +341,7 @@ public static class GameEventWiring wielderGuid, (AcDream.Core.Items.EquipMask)p.Value.EquipLoc); }); - dispatcher.Register(GameEventType.InventoryPutObjInContainer, e => + registrar.Register(GameEventType.InventoryPutObjInContainer, e => { var p = GameEvents.ParsePutObjInContainer(e.Payload.Span); if (p is null) return; @@ -355,7 +358,7 @@ public static class GameEventWiring // opened (Use 0x0036). Treat it as a full projection-only REPLACE: update membership without // inventing ContainerSlot values, then publish one ContainerContentsReplaced notification so // every UI consumer repaints from the same snapshot. Retail: ClientUISystem::OnViewContents. - dispatcher.Register(GameEventType.ViewContents, e => + registrar.Register(GameEventType.ViewContents, e => { var p = GameEvents.ParseViewContents(e.Payload.Span); if (p is null) return; @@ -371,7 +374,7 @@ public static class GameEventWiring // B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped // to the world. Unparent it from its container (it's now a ground object) so // the inventory grid drops the cell; the object itself survives. - dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e => + registrar.Register(GameEventType.InventoryPutObjectIn3D, e => { var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span); if (guid is not null) @@ -386,7 +389,7 @@ public static class GameEventWiring // B-Drag: InventoryServerSaveFailed (0x00A0) — server rejected an optimistic move. // Snap the item back to its pre-move slot. Log only when there was no pending move // (a server-initiated failure on a non-optimistic path). - dispatcher.Register(GameEventType.InventoryServerSaveFailed, e => + registrar.Register(GameEventType.InventoryServerSaveFailed, e => { var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span); if (p is null) return; @@ -404,7 +407,7 @@ public static class GameEventWiring // retail surfaces the string-table text as a chat line. Interim text map: // WeenieErrorText (#202 / register AP-74). Without this line a refused // kit-heal looks like "nothing happened" — the 2026-07-03 session bug. - dispatcher.Register(GameEventType.UseDone, e => + registrar.Register(GameEventType.UseDone, e => { uint? err = GameEvents.ParseUseDone(e.Payload.Span); if (err is null) return; @@ -417,7 +420,7 @@ public static class GameEventWiring // CloseGroundContainer (0x0052): clear ClientUISystem::groundObject and // retire the root plus nested temporary ViewContents projections. Child // objects remain until authoritative move/delete wire says otherwise. - dispatcher.Register(GameEventType.CloseGroundContainer, e => + registrar.Register(GameEventType.CloseGroundContainer, e => { var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span); if (guid is null) return; @@ -425,7 +428,7 @@ public static class GameEventWiring items.StopViewingContentsTree(guid.Value); }); - dispatcher.Register(GameEventType.IdentifyObjectResponse, e => + registrar.Register(GameEventType.IdentifyObjectResponse, e => { var p = AppraiseInfoParser.TryParse(e.Payload.Span); if (p is null || !p.Value.Success) return; @@ -461,7 +464,7 @@ public static class GameEventWiring // SpellBook flag in IdentifyObjectResponse is for caster // items / scrolls only). bool dumpPd = Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1"; - dispatcher.Register(GameEventType.PlayerDescription, e => + registrar.Register(GameEventType.PlayerDescription, e => { var p = PlayerDescriptionParser.TryParse(e.Payload.Span); if (dumpPd) @@ -652,6 +655,38 @@ public static class GameEventWiring // toolbar can read them without holding a parser reference. onShortcuts?.Invoke(p.Value.Shortcuts); }); + return construction.Complete(); + } + + private sealed class OwnedGameEventRegistrar( + GameEventDispatcher dispatcher) : IDisposable + { + private readonly SubscriptionSet _subscriptions = new(); + + public void Register( + GameEventType type, + GameEventDispatcher.EventHandler handler) => + _subscriptions.Add(dispatcher.RegisterOwned(type, handler)); + + public void Dispose() => _subscriptions.Dispose(); + } + + private sealed class RegistrationBuildScope( + OwnedGameEventRegistrar registration) : IDisposable + { + private bool _complete; + + public IDisposable Complete() + { + _complete = true; + return registration; + } + + public void Dispose() + { + if (!_complete) + registration.Dispose(); + } } private static ActiveEnchantmentRecord ToActiveEnchantment( diff --git a/src/AcDream.Core.Net/Messages/GameEventDispatcher.cs b/src/AcDream.Core.Net/Messages/GameEventDispatcher.cs index c4eb38bb..1a1abfdc 100644 --- a/src/AcDream.Core.Net/Messages/GameEventDispatcher.cs +++ b/src/AcDream.Core.Net/Messages/GameEventDispatcher.cs @@ -24,7 +24,27 @@ public sealed class GameEventDispatcher { public delegate void EventHandler(GameEventEnvelope envelope); - private readonly Dictionary _handlers = new(); + private sealed class RegistrationNode( + GameEventType type, + EventHandler handler, + RegistrationNode? previous) + { + public GameEventType Type { get; } = type; + public EventHandler Handler { get; } = handler; + public RegistrationNode? Previous { get; } = previous; + public bool Retired { get; set; } + } + + private sealed class OwnedRegistration( + GameEventDispatcher owner, + RegistrationNode node) : IDisposable + { + private Action? _retire = () => owner.Retire(node); + + public void Dispose() => Interlocked.Exchange(ref _retire, null)?.Invoke(); + } + + private readonly Dictionary _handlers = new(); private readonly Dictionary _unhandledCounts = new(); /// @@ -34,13 +54,34 @@ public sealed class GameEventDispatcher public void Register(GameEventType type, EventHandler handler) { ArgumentNullException.ThrowIfNull(handler); - _handlers[type] = handler; + // Legacy replacement intentionally severs the predecessor chain. An + // exact owned token installed earlier must never remove or expose a + // later unowned replacement when that token is disposed. + _handlers[type] = new RegistrationNode(type, handler, previous: null); + } + + /// + /// Installs one exact owned handler. Disposal restores the nearest live + /// predecessor only when this registration is still current. Nested A/B + /// ownership is safe in either disposal order. + /// + public IDisposable RegisterOwned(GameEventType type, EventHandler handler) + { + ArgumentNullException.ThrowIfNull(handler); + _handlers.TryGetValue(type, out RegistrationNode? previous); + var node = new RegistrationNode(type, handler, previous); + _handlers[type] = node; + return new OwnedRegistration(this, node); } /// /// Remove the registered handler for a sub-opcode. /// - public void Unregister(GameEventType type) => _handlers.Remove(type); + public void Unregister(GameEventType type) + { + if (_handlers.Remove(type, out RegistrationNode? current)) + current.Retired = true; + } /// /// Route an envelope to its handler, or log as unhandled. Exceptions @@ -49,11 +90,11 @@ public sealed class GameEventDispatcher /// public void Dispatch(GameEventEnvelope envelope) { - if (_handlers.TryGetValue(envelope.EventType, out var handler)) + if (_handlers.TryGetValue(envelope.EventType, out RegistrationNode? registration)) { try { - handler(envelope); + registration.Handler(envelope); } catch (Exception ex) { @@ -87,4 +128,21 @@ public sealed class GameEventDispatcher /// How many distinct sub-opcodes have a handler registered. public int RegisteredHandlerCount => _handlers.Count; + + private void Retire(RegistrationNode node) + { + node.Retired = true; + if (!_handlers.TryGetValue(node.Type, out RegistrationNode? current) + || !ReferenceEquals(current, node)) + return; + + RegistrationNode? predecessor = node.Previous; + while (predecessor?.Retired == true) + predecessor = predecessor.Previous; + + if (predecessor is null) + _handlers.Remove(node.Type); + else + _handlers[node.Type] = predecessor; + } } diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index 1f6d3530..b9d320a7 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -19,7 +19,7 @@ public static class ObjectTableWiring /// Subscribe to quality, inventory, and property /// updates whose freshness is independent of the physics timestamp pack. /// - public static void Wire( + public static IDisposable Wire( WorldSession session, ClientObjectTable table, Func? playerGuid = null, @@ -27,6 +27,7 @@ public static class ObjectTableWiring { ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(table); + var subscriptions = new SubscriptionSet(); // Create/Delete/Pickup are deliberately not subscribed here. Their // shared live-object timestamps must be accepted before either the @@ -35,8 +36,10 @@ public static class ObjectTableWiring // B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just // UiEffects — the server is the authority on object properties. UpdateIntProperty // stores it in the bundle and still mirrors UiEffects → the typed Effects field. - session.ObjectIntPropertyUpdated += u => + Action objectIntUpdated = u => table.UpdateIntProperty(u.Guid, u.Property, u.Value); + session.ObjectIntPropertyUpdated += objectIntUpdated; + subscriptions.Add(() => session.ObjectIntPropertyUpdated -= objectIntUpdated); // B-Wire: PrivateUpdatePropertyInt (0x02CD) carries no guid — it targets the // local player. Route it to the player object so live EncumbranceVal updates the @@ -44,26 +47,38 @@ public static class ObjectTableWiring // call (which precedes any live 0x02CD), so UpdateIntProperty finds it. If it somehow // hasn't yet, this no-ops (UpdateIntProperty returns false on an unknown guid) rather // than creating a phantom — the next PD / CreateObject seeds it. - session.PlayerIntPropertyUpdated += u => + Action playerIntUpdated = u => { if (playerGuid is not null) table.UpdateIntProperty(playerGuid(), u.Property, u.Value); }; + session.PlayerIntPropertyUpdated += playerIntUpdated; + subscriptions.Add(() => session.PlayerIntPropertyUpdated -= playerIntUpdated); // Retail's qualities system owns one local-player value and notifies every // registered panel. acdream currently exposes that value through two projections: // ClientObjectTable (retained UI) and LocalPlayerState (Core consumers/fallback). // Apply the authoritative 0x02CF update to both in this one wiring owner so they // cannot drift after the login PlayerDescription snapshot. - session.PlayerInt64PropertyUpdated += u => ApplyPlayerInt64PropertyUpdate( + Action playerInt64Updated = u => + ApplyPlayerInt64PropertyUpdate( table, localPlayer, playerGuid?.Invoke() ?? 0u, u); + session.PlayerInt64PropertyUpdated += playerInt64Updated; + subscriptions.Add(() => session.PlayerInt64PropertyUpdated -= playerInt64Updated); // B-Wire: SetStackSize (0x0197) — update the object's stack count + value. - session.StackSizeUpdated += u => table.UpdateStackSize(u.Guid, u.StackSize, u.Value); + Action stackSizeUpdated = u => + table.UpdateStackSize(u.Guid, u.StackSize, u.Value); + session.StackSizeUpdated += stackSizeUpdated; + subscriptions.Add(() => session.StackSizeUpdated -= stackSizeUpdated); // B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory // view; drop it from the table (retail ClientUISystem removes it from maintenance). - session.InventoryObjectRemoved += guid => table.Remove(guid); + Action inventoryObjectRemoved = guid => table.Remove(guid); + session.InventoryObjectRemoved += inventoryObjectRemoved; + subscriptions.Add(() => session.InventoryObjectRemoved -= inventoryObjectRemoved); + + return subscriptions; } /// diff --git a/src/AcDream.Core.Net/SubscriptionSet.cs b/src/AcDream.Core.Net/SubscriptionSet.cs new file mode 100644 index 00000000..5f271c02 --- /dev/null +++ b/src/AcDream.Core.Net/SubscriptionSet.cs @@ -0,0 +1,61 @@ +namespace AcDream.Core.Net; + +/// +/// Owns a transactionally-built set of subscriptions and releases them in +/// reverse registration order. Disposal is idempotent and attempts every +/// release even when one fails. +/// +internal sealed class SubscriptionSet : IDisposable +{ + private List? _subscriptions = []; + + public void Add(IDisposable subscription) + { + ArgumentNullException.ThrowIfNull(subscription); + List? subscriptions = _subscriptions; + if (subscriptions is null) + { + subscription.Dispose(); + throw new ObjectDisposedException(nameof(SubscriptionSet)); + } + + subscriptions.Add(subscription); + } + + public void Add(Action unsubscribe) + { + ArgumentNullException.ThrowIfNull(unsubscribe); + Add(new ActionSubscription(unsubscribe)); + } + + public void Dispose() + { + List? subscriptions = + Interlocked.Exchange(ref _subscriptions, null); + if (subscriptions is null) + return; + + List? errors = null; + for (int i = subscriptions.Count - 1; i >= 0; i--) + { + try + { + subscriptions[i].Dispose(); + } + catch (Exception error) + { + (errors ??= []).Add(error); + } + } + + if (errors is not null) + throw new AggregateException("one or more subscriptions failed to detach", errors); + } + + private sealed class ActionSubscription(Action unsubscribe) : IDisposable + { + private Action? _unsubscribe = unsubscribe; + + public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke(); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs b/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs index f44bb55d..05cc5289 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs @@ -15,9 +15,9 @@ public sealed class GameEventDispatcherTests { byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length]; var span = body.AsSpan(); - BinaryPrimitives.WriteUInt32LittleEndian(span, GameEventEnvelope.Opcode); - BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(4), playerGuid); - BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8), sequence); + BinaryPrimitives.WriteUInt32LittleEndian(span, GameEventEnvelope.Opcode); + BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(4), playerGuid); + BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8), sequence); BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(12), (uint)type); payload.CopyTo(span.Slice(GameEventEnvelope.HeaderSize)); return body; @@ -106,6 +106,162 @@ public sealed class GameEventDispatcherTests Assert.Equal(1, dispatcher.GetUnhandledCount(GameEventType.Tell)); } + [Fact] + public void OwnedRegistration_DisposeRestoresUnownedPredecessor() + { + var dispatcher = new GameEventDispatcher(); + var calls = new List(); + dispatcher.Register(GameEventType.Tell, _ => calls.Add("baseline")); + IDisposable owned = dispatcher.RegisterOwned( + GameEventType.Tell, + _ => calls.Add("owned")); + + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + owned.Dispose(); + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + + Assert.Equal(["owned", "baseline"], calls); + Assert.Equal(1, dispatcher.RegisteredHandlerCount); + } + + [Fact] + public void NestedOwnedRegistrations_DisposeOlderFirst_SkipsRetiredPredecessor() + { + var dispatcher = new GameEventDispatcher(); + var calls = new List(); + dispatcher.Register(GameEventType.Tell, _ => calls.Add("baseline")); + IDisposable ownerA = dispatcher.RegisterOwned( + GameEventType.Tell, + _ => calls.Add("A")); + IDisposable ownerB = dispatcher.RegisterOwned( + GameEventType.Tell, + _ => calls.Add("B")); + + ownerA.Dispose(); + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + ownerB.Dispose(); + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + + Assert.Equal(["B", "baseline"], calls); + } + + [Fact] + public void NestedOwnedRegistrations_DisposeNewestFirst_RestoresLiveOlderOwner() + { + var dispatcher = new GameEventDispatcher(); + var calls = new List(); + dispatcher.Register(GameEventType.Tell, _ => calls.Add("baseline")); + IDisposable ownerA = dispatcher.RegisterOwned( + GameEventType.Tell, + _ => calls.Add("A")); + IDisposable ownerB = dispatcher.RegisterOwned( + GameEventType.Tell, + _ => calls.Add("B")); + + ownerB.Dispose(); + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + ownerA.Dispose(); + ownerA.Dispose(); + ownerB.Dispose(); + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + + Assert.Equal(["A", "baseline"], calls); + } + + [Fact] + public void LaterUnownedReplacement_SurvivesOwnedTokenDisposal() + { + var dispatcher = new GameEventDispatcher(); + var calls = new List(); + IDisposable owned = dispatcher.RegisterOwned( + GameEventType.Tell, + _ => calls.Add("owned")); + dispatcher.Register(GameEventType.Tell, _ => calls.Add("replacement")); + + owned.Dispose(); + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + + Assert.Equal(["replacement"], calls); + } + + [Fact] + public void OwnedRegistrationWithoutPredecessor_BecomesUnhandledAfterDispose() + { + var dispatcher = new GameEventDispatcher(); + IDisposable owned = dispatcher.RegisterOwned(GameEventType.Tell, _ => { }); + + owned.Dispose(); + owned.Dispose(); + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + + Assert.Equal(0, dispatcher.RegisteredHandlerCount); + Assert.Equal(1, dispatcher.GetUnhandledCount(GameEventType.Tell)); + } + + [Fact] + public void ExplicitUnregister_RetiresCurrentChainWithoutResurrection() + { + var dispatcher = new GameEventDispatcher(); + dispatcher.Register(GameEventType.Tell, _ => { }); + IDisposable ownerA = dispatcher.RegisterOwned(GameEventType.Tell, _ => { }); + IDisposable ownerB = dispatcher.RegisterOwned(GameEventType.Tell, _ => { }); + + dispatcher.Unregister(GameEventType.Tell); + ownerA.Dispose(); + ownerB.Dispose(); + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + + Assert.Equal(0, dispatcher.RegisteredHandlerCount); + Assert.Equal(1, dispatcher.GetUnhandledCount(GameEventType.Tell)); + } + + [Fact] + public void HandlerCanDisposeItselfAndInstallRawReplacementDuringDispatch() + { + var dispatcher = new GameEventDispatcher(); + var calls = new List(); + IDisposable? owned = null; + owned = dispatcher.RegisterOwned(GameEventType.Tell, _ => + { + calls.Add("owned"); + owned!.Dispose(); + dispatcher.Register(GameEventType.Tell, _ => calls.Add("replacement")); + }); + + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + + Assert.Equal(["owned", "replacement"], calls); + Assert.Equal(1, dispatcher.RegisteredHandlerCount); + } + + [Fact] + public void RawReplacementAfterNestedOwners_SurvivesEveryOwnedDisposeOrder() + { + var dispatcher = new GameEventDispatcher(); + var calls = new List(); + IDisposable ownerA = dispatcher.RegisterOwned(GameEventType.Tell, _ => calls.Add("A")); + IDisposable ownerB = dispatcher.RegisterOwned(GameEventType.Tell, _ => calls.Add("B")); + dispatcher.Register(GameEventType.Tell, _ => calls.Add("raw")); + + ownerA.Dispose(); + ownerB.Dispose(); + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + + Assert.Equal(["raw"], calls); + } + + [Fact] + public void OwnedRegistration_ConcurrentDisposeRetiresExactlyOnce() + { + var dispatcher = new GameEventDispatcher(); + IDisposable owned = dispatcher.RegisterOwned(GameEventType.Tell, _ => { }); + + Parallel.For(0, 64, _ => owned.Dispose()); + + Assert.Equal(0, dispatcher.RegisteredHandlerCount); + } + // ── Per-event parser tests ─────────────────────────────────────────────── private static byte[] MakeString16L(string s) diff --git a/tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs b/tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs new file mode 100644 index 00000000..658983b1 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs @@ -0,0 +1,47 @@ +namespace AcDream.Core.Net.Tests; + +public sealed class SubscriptionSetTests +{ + [Fact] + public void Dispose_ReleasesInReverseOrderAndIsIdempotent() + { + var order = new List(); + var subscriptions = new SubscriptionSet(); + subscriptions.Add(() => order.Add(1)); + subscriptions.Add(() => order.Add(2)); + subscriptions.Add(() => order.Add(3)); + + subscriptions.Dispose(); + subscriptions.Dispose(); + + Assert.Equal([3, 2, 1], order); + } + + [Fact] + public void Dispose_AttemptsEveryReleaseAndAggregatesFailures() + { + var order = new List(); + var subscriptions = new SubscriptionSet(); + subscriptions.Add(() => order.Add(1)); + subscriptions.Add(() => + { + order.Add(2); + throw new InvalidOperationException("second failed"); + }); + subscriptions.Add(() => + { + order.Add(3); + throw new IOException("third failed"); + }); + + AggregateException error = Assert.Throws( + subscriptions.Dispose); + + Assert.Equal([3, 2, 1], order); + Assert.Collection( + error.InnerExceptions, + exception => Assert.IsType(exception), + exception => Assert.IsType(exception)); + subscriptions.Dispose(); + } +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs new file mode 100644 index 00000000..32cba289 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Reflection; +using AcDream.Core.Chat; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using AcDream.Core.Player; +using AcDream.Core.Spells; + +namespace AcDream.Core.Net.Tests; + +public sealed class WorldSessionWiringOwnershipTests +{ + [Fact] + public void ObjectTableWiring_DisposeDetachesEveryExactSessionDelegate() + { + using var session = NewSession(); + var table = new ClientObjectTable(); + var player = new LocalPlayerState(); + + IDisposable registration = ObjectTableWiring.Wire( + session, + table, + playerGuid: () => 0x50000001u, + localPlayer: player); + + Assert.Equal(1, HandlerCount(session, nameof(session.ObjectIntPropertyUpdated))); + Assert.Equal(1, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated))); + Assert.Equal(1, HandlerCount(session, nameof(session.PlayerInt64PropertyUpdated))); + Assert.Equal(1, HandlerCount(session, nameof(session.StackSizeUpdated))); + Assert.Equal(1, HandlerCount(session, nameof(session.InventoryObjectRemoved))); + + registration.Dispose(); + registration.Dispose(); + + Assert.Equal(0, HandlerCount(session, nameof(session.ObjectIntPropertyUpdated))); + Assert.Equal(0, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated))); + Assert.Equal(0, HandlerCount(session, nameof(session.PlayerInt64PropertyUpdated))); + Assert.Equal(0, HandlerCount(session, nameof(session.StackSizeUpdated))); + Assert.Equal(0, HandlerCount(session, nameof(session.InventoryObjectRemoved))); + } + + [Fact] + public void CombatStateWiring_DisposeDetachesOnlyItsExactDelegate() + { + using var session = NewSession(); + var combat = new CombatState(); + Action predecessor = _ => { }; + session.PlayerIntPropertyUpdated += predecessor; + IDisposable registration = CombatStateWiring.Wire(session, combat); + + Assert.Equal(2, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated))); + + registration.Dispose(); + registration.Dispose(); + + Assert.Equal(1, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated))); + session.PlayerIntPropertyUpdated -= predecessor; + } + + [Fact] + public void CombatStateWiring_ConcurrentDisposeDetachesExactlyOnce() + { + using var session = NewSession(); + IDisposable registration = CombatStateWiring.Wire(session, new CombatState()); + + Parallel.For(0, 64, _ => registration.Dispose()); + + Assert.Equal(0, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated))); + } + + [Fact] + public void GameEventWiring_DisposeRestoresPreexistingHandlerAndRemovesOwnedSet() + { + var dispatcher = new GameEventDispatcher(); + int predecessorCalls = 0; + dispatcher.Register(GameEventType.Tell, _ => predecessorCalls++); + + IDisposable registration = GameEventWiring.WireAll( + dispatcher, + new ClientObjectTable(), + new CombatState(), + new Spellbook(), + new ChatLog()); + int wiredCount = dispatcher.RegisteredHandlerCount; + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + Assert.Equal(0, predecessorCalls); + + registration.Dispose(); + registration.Dispose(); + dispatcher.Dispatch(new GameEventEnvelope(0, 0, GameEventType.Tell, default)); + + Assert.True(wiredCount > 1); + Assert.Equal(1, dispatcher.RegisteredHandlerCount); + Assert.Equal(1, predecessorCalls); + } + + [Fact] + public void GameEventWiring_OnWorldSession_RestoresInternalRegistrations() + { + using var session = NewSession(); + int baselineCount = session.GameEvents.RegisteredHandlerCount; + + IDisposable registration = GameEventWiring.WireAll( + session.GameEvents, + new ClientObjectTable(), + new CombatState(), + new Spellbook(), + new ChatLog(), + turbineChat: new TurbineChatState()); + + Assert.True(session.GameEvents.RegisteredHandlerCount > baselineCount); + registration.Dispose(); + + Assert.Equal(baselineCount, session.GameEvents.RegisteredHandlerCount); + } + + private static WorldSession NewSession() => + new(new IPEndPoint(IPAddress.Loopback, 9)); + + private static int HandlerCount(WorldSession session, string eventName) + { + FieldInfo field = typeof(WorldSession).GetField( + eventName, + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"event field {eventName} not found"); + return (field.GetValue(session) as MulticastDelegate)? + .GetInvocationList() + .Length ?? 0; + } +} From 707e606e35504850e9bfee1b3016e3c09296f492 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 10:46:18 +0200 Subject: [PATCH 28/98] docs(plan): record owned session wiring checkpoint --- docs/plans/2026-07-21-gamewindow-slice-3-live-session.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md index 3558a00a..3a602279 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md +++ b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md @@ -18,8 +18,12 @@ character-selection UI or redesign the transport. canonical-account F657, exact F653 confirmation, negotiated-state Disconnect symmetry, closed-channel/flood-safe confirmation wait, and deterministic shutdown tests. Release: 6,605 passed / 5 skipped. -- [ ] A — owned subscription primitives (in progress). -- [ ] B — session event and command routers. +- [x] A — owned subscription primitives (`7d452aa6`): exact nested + GameEvent registration tokens, reverse/idempotent subscription ownership, + transactional wiring construction, and adversarial unregister, + reentrancy, replacement, and concurrent-dispose coverage. Release: 6,621 + passed / 5 skipped. +- [ ] B — session event and command routers (in progress). - [ ] C — convergent session reset. - [ ] D — complete lifecycle controller. - [ ] E — GameWindow cutover. From 961bdd07b7865bed1fb8a29639b2bbb8190d05ce Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 11:17:09 +0200 Subject: [PATCH 29/98] refactor(net): own live session routing --- .../Net/LiveSessionCommandRouter.cs | 326 +++++++ src/AcDream.App/Net/LiveSessionEventRouter.cs | 341 ++++++++ src/AcDream.App/Rendering/GameWindow.cs | 799 ++++++------------ src/AcDream.Core.Net/CombatStateWiring.cs | 8 +- src/AcDream.Core.Net/GameEventWiring.cs | 20 +- src/AcDream.Core.Net/ObjectTableWiring.cs | 21 +- src/AcDream.Core/Chat/CombatChatTranslator.cs | 30 +- src/AcDream.UI.Abstractions/LiveCommandBus.cs | 7 + .../Net/LiveSessionCommandRouterTests.cs | 301 +++++++ .../Net/LiveSessionEventRouterTests.cs | 291 +++++++ .../WorldSessionWiringOwnershipTests.cs | 30 + .../LiveCommandBusTests.cs | 14 + 12 files changed, 1645 insertions(+), 543 deletions(-) create mode 100644 src/AcDream.App/Net/LiveSessionCommandRouter.cs create mode 100644 src/AcDream.App/Net/LiveSessionEventRouter.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs new file mode 100644 index 00000000..3390b7b2 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -0,0 +1,326 @@ +using AcDream.App.UI; +using AcDream.Core.Chat; +using AcDream.Core.Net.Messages; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Net; + +internal sealed record LiveSessionCommandBindings( + ClientCommandController.Bindings ClientCommands, + ChatLog Chat, + TurbineChatState TurbineChat, + Func PlayerGuid, + Action SendTalk, + Action SendTell, + Action SendChannel, + Action SendTurbineChat, + Action? Log = null); + +/// +/// Owns the command surface for one exact live-session generation. The router +/// itself is the published bus, so a retained reference becomes inert before +/// the displaced transport is disposed. +/// +internal sealed class LiveSessionCommandRouter : ICommandBus, IDisposable +{ + private readonly object _gate = new(); + private LiveCommandBus? _commands; + private ClientCommandController.Bindings? _clientCommands; + private int _state; // 0 = constructed, 1 = active, 2 = disposed + + public LiveSessionCommandRouter(LiveSessionCommandBindings bindings) + { + ArgumentNullException.ThrowIfNull(bindings); + ArgumentNullException.ThrowIfNull(bindings.ClientCommands); + ArgumentNullException.ThrowIfNull(bindings.Chat); + ArgumentNullException.ThrowIfNull(bindings.TurbineChat); + ArgumentNullException.ThrowIfNull(bindings.PlayerGuid); + ArgumentNullException.ThrowIfNull(bindings.SendTalk); + ArgumentNullException.ThrowIfNull(bindings.SendTell); + ArgumentNullException.ThrowIfNull(bindings.SendChannel); + ArgumentNullException.ThrowIfNull(bindings.SendTurbineChat); + + _clientCommands = bindings.ClientCommands; + var commands = new LiveCommandBus(); + var clientCommands = new ClientCommandController( + BuildGuardedClientCommands(bindings.ClientCommands)); + commands.Register(clientCommands.Execute); + commands.Register(command => + { + if (!string.IsNullOrEmpty(command.Text)) + SendIfActive(() => bindings.SendTalk(command.Text)); + }); + commands.Register(command => RouteChat(bindings, command)); + _commands = commands; + } + + public bool IsActive + { + get + { + lock (_gate) + return _state == 1; + } + } + + public void Activate() + { + lock (_gate) + { + if (_state == 2) + throw new ObjectDisposedException(nameof(LiveSessionCommandRouter)); + _state = 1; + } + } + + public void Publish(T command) where T : notnull + { + lock (_gate) + { + if (_state == 1) + _commands?.Publish(command); + } + } + + public void Dispose() + { + LiveCommandBus? commands; + lock (_gate) + { + _state = 2; + commands = _commands; + _commands = null; + _clientCommands = null; + } + + commands?.Clear(); + } + + private void RouteChat( + LiveSessionCommandBindings bindings, + SendChatCmd command) + { + if (string.IsNullOrEmpty(command.Text)) + return; + + switch (command.Channel) + { + case ChatChannelKind.Say: + // ACE echoes HearSpeech to the sender. Retail therefore uses + // the authoritative inbound line rather than a local echo. + SendIfActive(() => bindings.SendTalk(command.Text)); + return; + + case ChatChannelKind.Tell: + if (string.IsNullOrEmpty(command.TargetName)) + return; + if (!SendIfActive(() => + bindings.SendTell(command.TargetName, command.Text))) + return; + bindings.Chat.OnSelfSent( + ChatKind.Tell, + command.Text, + targetOrChannel: command.TargetName); + return; + } + + TurbineResolution? turbine = TurbineChatRouting.Resolve( + command.Channel, + bindings.TurbineChat); + if (turbine is not null) + { + uint cookie = bindings.TurbineChat.NextContextId(); + uint senderGuid = bindings.PlayerGuid(); + bindings.Log?.Invoke( + $"chat: outbound TurbineChat {turbine.Value.DisplayName} " + + $"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " + + $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={command.Text.Length}"); + SendIfActive(() => bindings.SendTurbineChat( + turbine.Value.RoomId, + turbine.Value.ChatType, + (uint)TurbineChat.DispatchType.SendToRoomById, + senderGuid, + command.Text, + cookie)); + return; + } + + ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(command.Channel); + if (legacy is null) + { + bindings.Log?.Invoke( + $"chat: SendChatCmd kind={command.Channel} dropped " + + $"(turbine.Enabled={bindings.TurbineChat.Enabled} no legacy id)"); + return; + } + + bindings.Log?.Invoke( + $"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " + + $"id=0x{legacy.Value.ChannelId:X8} len={command.Text.Length}"); + if (!SendIfActive(() => + bindings.SendChannel(legacy.Value.ChannelId, command.Text))) + return; + bindings.Chat.OnSelfSent( + ChatKind.Channel, + command.Text, + targetOrChannel: legacy.Value.DisplayName); + } + + private ClientCommandController.Bindings BuildGuardedClientCommands( + ClientCommandController.Bindings source) => new( + TeleportToLifestone: () => InvokeClient(static b => b.TeleportToLifestone()), + TeleportToMarketplace: () => InvokeClient(static b => b.TeleportToMarketplace()), + TeleportToPkArena: () => InvokeClient(static b => b.TeleportToPkArena()), + TeleportToPkLiteArena: () => InvokeClient(static b => b.TeleportToPkLiteArena()), + TeleportToHouse: () => InvokeClient(static b => b.TeleportToHouse()), + TeleportToMansion: () => InvokeClient(static b => b.TeleportToMansion()), + QueryAge: () => InvokeClient(static b => b.QueryAge()), + QueryBirth: () => InvokeClient(static b => b.QueryBirth()), + ToggleFrameRate: () => InvokeClient(static b => b.ToggleFrameRate()), + ToggleUiLock: () => InvokeClient(static b => b.ToggleUiLock()), + ShowSystemMessage: text => InvokeClient(b => b.ShowSystemMessage(text)), + ShowWeenieError: error => InvokeClient(b => b.ShowWeenieError(error)), + PlayerPublicWeenieBitfield: () => + ReadClient(static b => b.PlayerPublicWeenieBitfield(), default(uint?)), + ClientVersion: () => ReadClient(static b => b.ClientVersion(), string.Empty), + CurrentPosition: () => + ReadClient(static b => b.CurrentPosition(), default(AcDream.Core.Physics.Position?)), + LastOutsideCorpsePosition: () => + ReadClient(static b => b.LastOutsideCorpsePosition(), default(AcDream.Core.Physics.Position?)), + ShowConfirmation: (text, callback) => + InvokeClient(b => b.ShowConfirmation(text, callback)), + Suicide: () => InvokeClient(static b => b.Suicide()), + ClearChat: all => InvokeClient(b => b.ClearChat(all)), + SaveUi: name => InvokeClient(b => b.SaveUi(name)), + LoadUi: name => InvokeClient(b => b.LoadUi(name)), + SaveAutoUi: () => InvokeClient(static b => b.SaveAutoUi()), + LoadAutoUi: () => InvokeClient(static b => b.LoadAutoUi()), + IsAway: () => ReadClient(static b => b.IsAway(), false), + SetAway: away => InvokeClient(b => b.SetAway(away)), + SetAwayMessage: message => InvokeClient(b => b.SetAwayMessage(message)), + AcceptLootPermits: () => ReadClient(static b => b.AcceptLootPermits(), false), + SetAcceptLootPermits: accept => InvokeClient(b => b.SetAcceptLootPermits(accept)), + DisplayConsent: () => InvokeClient(static b => b.DisplayConsent()), + ClearConsent: () => InvokeClient(static b => b.ClearConsent()), + RemoveConsent: name => InvokeClient(b => b.RemoveConsent(name)), + SendEmote: text => InvokeClient(b => b.SendEmote(text)), + Friends: source.Friends, + AddFriend: name => InvokeClient(b => b.AddFriend(name)), + RemoveFriend: id => InvokeClient(b => b.RemoveFriend(id)), + ClearFriends: () => InvokeClient(static b => b.ClearFriends()), + RequestLegacyFriends: () => InvokeClient(static b => b.RequestLegacyFriends()), + Squelch: source.Squelch, + ModifyCharacterSquelch: (add, id, name, chatType) => + InvokeClient(b => b.ModifyCharacterSquelch(add, id, name, chatType)), + ModifyAccountSquelch: (add, name) => + InvokeClient(b => b.ModifyAccountSquelch(add, name)), + ModifyGlobalSquelch: (add, chatType) => + InvokeClient(b => b.ModifyGlobalSquelch(add, chatType)), + LastTeller: () => ReadClient(static b => b.LastTeller(), default(string?)), + ClearDesiredComponents: () => InvokeClient(static b => b.ClearDesiredComponents()), + HasOpenVendor: () => ReadClient(static b => b.HasOpenVendor(), false), + FillComponentBuyList: (componentId, targetCount) => + InvokeClient(b => b.FillComponentBuyList(componentId, targetCount))); + + private bool InvokeClient(Action invoke) + { + lock (_gate) + { + ClientCommandController.Bindings? bindings = _clientCommands; + if (_state != 1 || bindings is null) + return false; + invoke(bindings); + return true; + } + } + + private TResult ReadClient( + Func read, + TResult fallback) + { + lock (_gate) + { + ClientCommandController.Bindings? bindings = _clientCommands; + return _state == 1 && bindings is not null + ? read(bindings) + : fallback; + } + } + + private bool SendIfActive(Action send) + { + lock (_gate) + { + if (_state != 1) + return false; + send(); + return true; + } + } +} + +internal readonly record struct TurbineResolution( + uint RoomId, + uint ChatType, + string DisplayName); + +internal static class TurbineChatRouting +{ + /// + /// Resolve the server-assigned Turbine room for one UI channel. This is + /// the existing holtburger resolve_turbine_channel mapping + /// (references/holtburger/.../client/commands.rs, lines 64-98), + /// moved intact from GameWindow with its runtime-room gate preserved. + /// + public static TurbineResolution? Resolve( + ChatChannelKind kind, + TurbineChatState state) + { + ArgumentNullException.ThrowIfNull(state); + if (!state.Enabled) + return null; + + (uint Room, uint ChatType, string Name) = kind switch + { + ChatChannelKind.Allegiance => + (state.AllegianceRoom, (uint)TurbineChat.ChatType.Allegiance, "Allegiance"), + ChatChannelKind.General => + (state.GeneralRoom, (uint)TurbineChat.ChatType.General, "General"), + ChatChannelKind.Trade => + (state.TradeRoom, (uint)TurbineChat.ChatType.Trade, "Trade"), + ChatChannelKind.Lfg => + (state.LfgRoom, (uint)TurbineChat.ChatType.Lfg, "LFG"), + ChatChannelKind.Roleplay => + (state.RoleplayRoom, (uint)TurbineChat.ChatType.Roleplay, "Roleplay"), + ChatChannelKind.Society => + (state.SocietyRoom, (uint)TurbineChat.ChatType.Society, "Society"), + ChatChannelKind.Olthoi => + (state.OlthoiRoom, (uint)TurbineChat.ChatType.Olthoi, "Olthoi"), + _ => (0u, 0u, string.Empty), + }; + + return Room == 0u + ? null + : new TurbineResolution(Room, ChatType, Name); + } + + /// + /// Derive the retail-facing channel label from the inbound Turbine chat + /// type, retaining the numeric-room fallback for unknown extensions. + /// + public static string DisplayName(uint roomId, uint chatType) => + (TurbineChat.ChatType)chatType switch + { + TurbineChat.ChatType.Allegiance => "Allegiance", + TurbineChat.ChatType.General => "General", + TurbineChat.ChatType.Trade => "Trade", + TurbineChat.ChatType.Lfg => "LFG", + TurbineChat.ChatType.Roleplay => "Roleplay", + TurbineChat.ChatType.Society => "Society", + TurbineChat.ChatType.SocietyCelHan => "Celestial Hand", + TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web", + TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood", + TurbineChat.ChatType.Olthoi => "Olthoi", + _ => $"Room 0x{roomId:X8}", + }; +} diff --git a/src/AcDream.App/Net/LiveSessionEventRouter.cs b/src/AcDream.App/Net/LiveSessionEventRouter.cs new file mode 100644 index 00000000..585dbe08 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionEventRouter.cs @@ -0,0 +1,341 @@ +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; + +namespace AcDream.App.Net; + +internal sealed record LiveEntitySessionSink( + Action Spawned, + Action Deleted, + Action PickedUp, + Action MotionUpdated, + Action PositionUpdated, + Action VectorUpdated, + Action StateUpdated, + Action ParentUpdated, + Action TeleportStarted, + Action AppearanceUpdated, + Action PlayPhysicsScript, + Action PlayPhysicsScriptType); + +internal sealed record LiveEnvironmentSessionSink( + Action EnvironChanged, + Action ServerTimeUpdated); + +internal sealed record LiveInventorySessionBindings( + ClientObjectTable Objects, + LocalPlayerState LocalPlayer, + Func PlayerGuid, + Action>? OnShortcuts, + Action? OnUseDone, + ItemManaState? ItemMana, + Action>? OnDesiredComponents, + ExternalContainerState? ExternalContainers); + +internal sealed record LiveCharacterSessionBindings( + CombatState Combat, + Spellbook Spellbook, + Func, uint>? ResolveSkillFormulaBonus, + Action? OnSkillsUpdated, + Action? OnConfirmationRequest, + Action? OnConfirmationDone, + Action? OnCharacterOptions, + Func? ClientTime); + +internal sealed record LiveSocialSessionBindings( + ChatLog Chat, + TurbineChatState TurbineChat, + FriendsState? Friends, + SquelchState? Squelch); + +/// +/// Owns every inbound subscription for one exact live session. Domain state +/// remains in the supplied sinks; this class owns only routing and teardown. +/// +internal sealed class LiveSessionEventRouter : IDisposable +{ + private readonly LiveSessionSubscriptionSet _subscriptions = new(); + private readonly Action? _constructionCheckpoint; + private int _constructionStep; + private int _accepting = 1; + + public LiveSessionEventRouter( + WorldSession session, + LiveEntitySessionSink entities, + LiveEnvironmentSessionSink environment, + LiveInventorySessionBindings inventory, + LiveCharacterSessionBindings character, + LiveSocialSessionBindings social, + Action? constructionCheckpoint = null) + { + ArgumentNullException.ThrowIfNull(session); + Validate(entities, environment, inventory, character, social); + _constructionCheckpoint = constructionCheckpoint; + + try + { + // Preserve the shipped pre-Connect registration order. Property + // state is installed before lifecycle packets can be dispatched. + _subscriptions.Add(ObjectTableWiring.Wire( + session, + inventory.Objects, + inventory.PlayerGuid, + inventory.LocalPlayer, + IsAccepting)); + ConstructionCheckpoint(); + _subscriptions.Add(CombatStateWiring.Wire( + session, + character.Combat, + IsAccepting)); + ConstructionCheckpoint(); + + Subscribe(h => session.EntitySpawned += h, h => session.EntitySpawned -= h, entities.Spawned); + Subscribe(h => session.EntityDeleted += h, h => session.EntityDeleted -= h, entities.Deleted); + Subscribe(h => session.EntityPickedUp += h, h => session.EntityPickedUp -= h, entities.PickedUp); + Subscribe(h => session.MotionUpdated += h, h => session.MotionUpdated -= h, entities.MotionUpdated); + Subscribe(h => session.PositionUpdated += h, h => session.PositionUpdated -= h, entities.PositionUpdated); + Subscribe(h => session.VectorUpdated += h, h => session.VectorUpdated -= h, entities.VectorUpdated); + Subscribe(h => session.StateUpdated += h, h => session.StateUpdated -= h, entities.StateUpdated); + Subscribe(h => session.ParentUpdated += h, h => session.ParentUpdated -= h, entities.ParentUpdated); + Subscribe(h => session.TeleportStarted += h, h => session.TeleportStarted -= h, entities.TeleportStarted); + Subscribe(h => session.AppearanceUpdated += h, h => session.AppearanceUpdated -= h, entities.AppearanceUpdated); + Subscribe( + h => session.PlayPhysicsScriptReceived += h, + h => session.PlayPhysicsScriptReceived -= h, + entities.PlayPhysicsScript); + Subscribe( + h => session.PlayPhysicsScriptTypeReceived += h, + h => session.PlayPhysicsScriptTypeReceived -= h, + entities.PlayPhysicsScriptType); + + Subscribe(h => session.EnvironChanged += h, h => session.EnvironChanged -= h, environment.EnvironChanged); + Subscribe(h => session.ServerTimeUpdated += h, h => session.ServerTimeUpdated -= h, environment.ServerTimeUpdated); + + _subscriptions.Add(GameEventWiring.WireAll( + session.GameEvents, + inventory.Objects, + character.Combat, + character.Spellbook, + social.Chat, + inventory.LocalPlayer, + social.TurbineChat, + onSkillsUpdated: character.OnSkillsUpdated, + resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus, + onShortcuts: inventory.OnShortcuts, + playerGuid: inventory.PlayerGuid, + onUseDone: inventory.OnUseDone, + itemMana: inventory.ItemMana, + onConfirmationRequest: character.OnConfirmationRequest, + onConfirmationDone: character.OnConfirmationDone, + friends: social.Friends, + squelch: social.Squelch, + onDesiredComponents: inventory.OnDesiredComponents, + onCharacterOptions: character.OnCharacterOptions, + clientTime: character.ClientTime, + externalContainers: inventory.ExternalContainers, + accepting: IsAccepting)); + ConstructionCheckpoint(); + _subscriptions.Add(new CombatChatTranslator( + character.Combat, + social.Chat, + IsAccepting)); + ConstructionCheckpoint(); + + Subscribe(h => session.SpeechHeard += h, h => session.SpeechHeard -= h, speech => + social.Chat.OnLocalSpeech( + speech.SenderName, + speech.Text, + speech.SenderGuid, + speech.IsRanged)); + Subscribe( + h => session.ServerMessageReceived += h, + h => session.ServerMessageReceived -= h, + message => social.Chat.OnSystemMessage(message.Message, message.ChatType)); + Subscribe(h => session.EmoteHeard += h, h => session.EmoteHeard -= h, emote => + social.Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid)); + Subscribe(h => session.SoulEmoteHeard += h, h => session.SoulEmoteHeard -= h, emote => + social.Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid)); + Subscribe( + h => session.PlayerKilledReceived += h, + h => session.PlayerKilledReceived -= h, + killed => social.Chat.OnPlayerKilled( + killed.DeathMessage, + killed.VictimGuid, + killed.KillerGuid)); + Subscribe( + h => session.TurbineChatReceived += h, + h => session.TurbineChatReceived -= h, + parsed => RouteTurbineChat(social.Chat, parsed)); + Subscribe(h => session.VitalUpdated += h, h => session.VitalUpdated -= h, vital => + inventory.LocalPlayer.OnVitalUpdate( + vital.VitalId, + vital.Ranks, + vital.Start, + vital.Xp, + vital.Current)); + Subscribe( + h => session.VitalCurrentUpdated += h, + h => session.VitalCurrentUpdated -= h, + vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current)); + } + catch (Exception constructionError) + { + Interlocked.Exchange(ref _accepting, 0); + try + { + _subscriptions.Dispose(); + } + catch (Exception cleanupError) + { + throw new AggregateException( + "live-session event routing failed and cleanup also failed", + constructionError, + cleanupError); + } + + throw; + } + } + + public bool Accepting => IsAccepting(); + + public void Dispose() + { + Interlocked.Exchange(ref _accepting, 0); + _subscriptions.Dispose(); + } + + private void Subscribe( + Action> attach, + Action> detach, + Action sink) + { + Action handler = value => + { + if (Volatile.Read(ref _accepting) != 0) + sink(value); + }; + + attach(handler); + try + { + _subscriptions.Add(() => detach(handler)); + } + catch + { + detach(handler); + throw; + } + + ConstructionCheckpoint(); + } + + private void ConstructionCheckpoint() => + _constructionCheckpoint?.Invoke(++_constructionStep); + + private bool IsAccepting() => Volatile.Read(ref _accepting) != 0; + + private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed) + { + if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message) + return; + + chat.OnChannelBroadcast( + message.RoomId, + message.SenderName, + message.Message, + TurbineChatRouting.DisplayName(message.RoomId, message.ChatType)); + } + + private static void Validate( + LiveEntitySessionSink entities, + LiveEnvironmentSessionSink environment, + LiveInventorySessionBindings inventory, + LiveCharacterSessionBindings character, + LiveSocialSessionBindings social) + { + ArgumentNullException.ThrowIfNull(entities); + ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(social); + ArgumentNullException.ThrowIfNull(entities.Spawned); + ArgumentNullException.ThrowIfNull(entities.Deleted); + ArgumentNullException.ThrowIfNull(entities.PickedUp); + ArgumentNullException.ThrowIfNull(entities.MotionUpdated); + ArgumentNullException.ThrowIfNull(entities.PositionUpdated); + ArgumentNullException.ThrowIfNull(entities.VectorUpdated); + ArgumentNullException.ThrowIfNull(entities.StateUpdated); + ArgumentNullException.ThrowIfNull(entities.ParentUpdated); + ArgumentNullException.ThrowIfNull(entities.TeleportStarted); + ArgumentNullException.ThrowIfNull(entities.AppearanceUpdated); + ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScript); + ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScriptType); + ArgumentNullException.ThrowIfNull(environment.EnvironChanged); + ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated); + ArgumentNullException.ThrowIfNull(inventory.Objects); + ArgumentNullException.ThrowIfNull(inventory.LocalPlayer); + ArgumentNullException.ThrowIfNull(inventory.PlayerGuid); + ArgumentNullException.ThrowIfNull(character.Combat); + ArgumentNullException.ThrowIfNull(character.Spellbook); + ArgumentNullException.ThrowIfNull(social.Chat); + ArgumentNullException.ThrowIfNull(social.TurbineChat); + } +} + +internal sealed class LiveSessionSubscriptionSet : IDisposable +{ + private List? _subscriptions = []; + + public void Add(IDisposable subscription) + { + ArgumentNullException.ThrowIfNull(subscription); + List? subscriptions = _subscriptions; + if (subscriptions is null) + { + subscription.Dispose(); + throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet)); + } + + subscriptions.Add(subscription); + } + + public void Add(Action unsubscribe) => Add(new ActionSubscription(unsubscribe)); + + public void Dispose() + { + List? subscriptions = Interlocked.Exchange(ref _subscriptions, null); + if (subscriptions is null) + return; + + List? errors = null; + for (int index = subscriptions.Count - 1; index >= 0; index--) + { + try + { + subscriptions[index].Dispose(); + } + catch (Exception error) + { + (errors ??= []).Add(error); + } + } + + if (errors is not null) + throw new AggregateException( + "one or more live-session subscriptions failed to detach", + errors); + } + + private sealed class ActionSubscription(Action unsubscribe) : IDisposable + { + private Action? _unsubscribe = unsubscribe; + + public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke(); + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1d31c22a..d7479c15 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -813,16 +813,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private bool DevToolsEnabled => _options.DevTools; private bool DumpMoveTruthEnabled => _options.DumpMoveTruth; - // Phase I.3 — real ICommandBus for live sessions. Constructed when - // the live session spins up (so SendChatCmd handlers can close over - // _liveSession + Chat). Null when offline; PanelContext then falls - // back to NullCommandBus.Instance. - private AcDream.UI.Abstractions.LiveCommandBus? _commandBus; - - // Phase I.7 — bridges CombatState's typed events into ChatLog as - // retail-faithful "You hit ..." / "... evaded your attack." lines. - // Disposable; lives for the duration of the live session. - private AcDream.Core.Chat.CombatChatTranslator? _combatChatTranslator; + // Slice 3: exact session-generation owners. The command router is itself + // an ICommandBus and becomes inert before the displaced socket closes. + private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents; + private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands; // Phase G.1-G.2 world lighting/time state. public readonly AcDream.Core.World.WorldTimeService WorldTime = @@ -2221,7 +2215,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm), Chat: new AcDream.App.UI.ChatRuntimeBindings( retailChatVm, - () => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus) + () => _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus) AcDream.UI.Abstractions.NullCommandBus.Instance), Radar: new AcDream.App.UI.RadarRuntimeBindings( radarSnapshotProvider.BuildSnapshot, @@ -2867,10 +2861,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents); if (_liveSession is null) { - _commandBus = null; - _combatChatTranslator?.Dispose(); - _combatChatTranslator = null; - _liveSessionController = null; + try + { + DisposeLiveSessionRouting(); + } + finally + { + _liveSessionController.Dispose(); + _liveSessionController = null; + } return; } @@ -2890,12 +2889,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext out AcDream.Core.Net.Messages.CharacterList.Selection selection)) { Console.WriteLine("live: no available characters on account; disconnecting"); - _commandBus = null; - _combatChatTranslator?.Dispose(); - _combatChatTranslator = null; - _liveSessionController.Dispose(); - _liveSessionController = null; - _liveSession = null; + try + { + DisposeLiveSessionRouting(); + } + finally + { + _liveSessionController.Dispose(); + _liveSessionController = null; + _liveSession = null; + } ClearInboundEntityState(); return; } @@ -2909,6 +2912,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); Combat.Clear(); _liveSession.EnterWorld(characterIndex: selection.ActiveIndex); + _liveSessionCommands?.Activate(); _activeToonKey = chosen.Name; _retailUiRuntime?.RestoreLayout(); @@ -2926,12 +2930,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext catch (Exception ex) { Console.WriteLine($"live: session failed: {ex.Message}"); - _commandBus = null; - _combatChatTranslator?.Dispose(); - _combatChatTranslator = null; - _liveSessionController?.Dispose(); - _liveSessionController = null; - _liveSession = null; + try + { + DisposeLiveSessionRouting(); + } + finally + { + _liveSessionController?.Dispose(); + _liveSessionController = null; + _liveSession = null; + } ClearInboundEntityState(); } } @@ -2995,445 +3003,250 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext } /// - /// Step 2 helper: subscribes the live to all - /// the parsers / handlers / translators that GameWindow needs. - /// Called once by - /// immediately after the - /// is constructed and BEFORE any network I/O. + /// Composes the exact inbound and outbound owners for one live session. + /// Registration completes before Connect; outbound commands remain inert + /// until EnterWorld succeeds. /// private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session) { _liveSession = session; - // D.5.4: wire non-lifecycle object quality/property updates. The live - // handlers below apply Create/Delete to both projections only after - // their canonical timestamp decision. - AcDream.Core.Net.ObjectTableWiring.Wire( - session, Objects, () => _playerServerGuid, LocalPlayer); - AcDream.Core.Net.CombatStateWiring.Wire(session, Combat); - _liveSession.EntitySpawned += spawn => - _inboundEntityEvents.Run( - this, - spawn, - static (window, value) => window.OnLiveEntitySpawned(value)); - _liveSession.EntityDeleted += deletion => - _inboundEntityEvents.Run( - this, - deletion, - static (window, value) => window.OnLiveEntityDeleted(value)); - _liveSession.EntityPickedUp += pickup => - _inboundEntityEvents.Run( - this, - pickup, - static (window, value) => window.OnLiveEntityPickedUp(value)); - _liveSession.MotionUpdated += motion => - _inboundEntityEvents.Run( - this, - motion, - static (window, value) => window.OnLiveMotionUpdated(value)); - _liveSession.PositionUpdated += position => - _inboundEntityEvents.Run( - this, - position, - static (window, value) => window.OnLivePositionUpdated(value)); - _liveSession.VectorUpdated += vector => - _inboundEntityEvents.Run( - this, - vector, - static (window, value) => window.OnLiveVectorUpdated(value)); - _liveSession.StateUpdated += state => - _inboundEntityEvents.Run( - this, - state, - static (window, value) => window.OnLiveStateUpdated(value)); - _liveSession.ParentUpdated += parent => - _inboundEntityEvents.Run( - this, - parent, - static (window, value) => window.OnLiveParentUpdated(value)); - _liveSession.TeleportStarted += teleport => - _inboundEntityEvents.Run( - this, - teleport, - static (window, value) => window.OnTeleportStarted(value)); - _liveSession.AppearanceUpdated += appearance => - _inboundEntityEvents.Run( - this, - appearance, - static (window, value) => window.OnLiveAppearanceUpdated(value)); + if (_liveSessionEvents is not null || _liveSessionCommands is not null) + throw new InvalidOperationException("live-session routing is already bound"); - // Phase 6c — PlayScript (0xF754) arrives from the server as - // a (guid, scriptId) pair. Resolve the guid's current world - // position and feed the PhysicsScript runner; it schedules - // the script's hooks (particle spawns, sound cues, light - // toggles) at their StartTime offsets. This is the channel - // retail uses for spell casts, combat flinches, emote - // gestures, AND — per Agent #5 research — lightning - // flashes during stormy weather. - _liveSession.PlayPhysicsScriptReceived += script => - _inboundEntityEvents.Run( - this, - script, - static (window, value) => window.OnPlayScriptReceived(value)); - _liveSession.PlayPhysicsScriptTypeReceived += message => - _inboundEntityEvents.Run( - this, - message, - static (window, value) => window._entityEffects?.HandleTyped(value)); + var skillTable = _dats?.Get(0x0E000004u); + if (_characterSheetProvider is not null && _dats is not null) + { + _characterSheetProvider.SkillTable = skillTable; + _characterSheetProvider.ExperienceTable = + AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable( + _dats, + Console.WriteLine); + } - // Phase 5d — AdminEnvirons (0xEA60): fog presets + sound - // cues. Fog types (0x00..0x06) set WeatherSystem.Override; - // sound types (0x65..0x7B) play a one-shot audio cue. - // Lightning flashes arrive as a PAIRED PlayScript (the - // visual) + AdminEnvirons ThunderXSound (the audio) — both - // are handled here and in OnPlayScriptReceived respectively. - _liveSession.EnvironChanged += OnEnvironChanged; - - // Phase G.1: keep the client's day/night clock in sync with - // server time. Fires once from ConnectRequest (initial seed) - // and repeatedly on TimeSync-flagged packets. - // Phase 3a: also re-roll the active DayGroup if the Dereth-day - // index changed — retail rolls one weather preset per server - // day (r12 §11), deterministic from the day index so retail - // and acdream converge without a wire message. - _liveSession.ServerTimeUpdated += ticks => - { - WorldTime.SyncFromServer(ticks); - RefreshSkyForCurrentDay(); - }; - - // Phase F.1-H.1: wire every parsed GameEvent into the right - // Core state class (chat, combat, spellbook, items). After - // this one call, server-sent ChannelBroadcast / damage - // notifications / spell learns / wield events all update - // the corresponding client-side state without further glue. - // K-fix13 (2026-04-26): cache portal.dat's SkillTable so the - // skill-formula resolver can apply the AttributeFormula - // contribution. Without this, our wire-derived "skill" - // value was missing the dominant attribute-derived term - // and jumps undershot retail by ~30 % at typical - // attribute levels. - var skillTable = _dats?.Get(0x0E000004u); - if (_characterSheetProvider is not null && _dats is not null) - { - _characterSheetProvider.SkillTable = skillTable; - _characterSheetProvider.ExperienceTable = - AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable( - _dats, Console.WriteLine); - } - - AcDream.Core.Net.GameEventWiring.WireAll( - _liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer, - TurbineChat, - resolveSkillFormulaBonus: (skillId, attrCurrents) => - { - // ACE GetFormula (AttributeFormula.cs:55-): when - // formula.X (Attribute1Multiplier) is 0, the formula - // is "no attribute contribution" and the function - // returns 0. Otherwise: - // bonus = (attr1 * Mult1 + attr2 * Mult2) / Divisor + Additive - if (skillTable?.Skills is null) return 0u; - if (!skillTable.Skills.TryGetValue( - (DatReaderWriter.Enums.SkillId)skillId, out var skillBase)) - return 0u; - var f = skillBase.Formula; - if (f.Attribute1Multiplier == 0 || f.Divisor == 0) return 0u; - attrCurrents.TryGetValue((uint)f.Attribute1, out uint a1); - attrCurrents.TryGetValue((uint)f.Attribute2, out uint a2); - long num = (long)a1 * f.Attribute1Multiplier - + (long)a2 * f.Attribute2Multiplier; - long bonus = num / f.Divisor + f.AdditiveBonus; - return bonus < 0 ? 0u : (uint)bonus; - }, - onSkillsUpdated: (runSkill, jumpSkill) => - { - // K-fix7 (2026-04-26): cache the latest server-sent - // Run / Jump skill values so the next - // EnterPlayerModeNow can hand them to the new - // PlayerMovementController. Push immediately too, - // so a PD that arrives WHILE player mode is active - // (re-equip / log-in mid-session) updates the live - // controller. -1 from the wiring means "skill not - // present in this PD" — keep the previous cached - // value rather than overwriting with -1. - if (runSkill >= 0) _lastSeenRunSkill = runSkill; - if (jumpSkill >= 0) _lastSeenJumpSkill = jumpSkill; - if (_playerController is not null - && _lastSeenRunSkill >= 0 && _lastSeenJumpSkill >= 0) + AcDream.App.Net.LiveSessionEventRouter? events = null; + AcDream.App.Net.LiveSessionCommandRouter? commands = null; + try + { + events = new AcDream.App.Net.LiveSessionEventRouter( + session, + CreateLiveEntitySessionSink(), + new AcDream.App.Net.LiveEnvironmentSessionSink( + OnEnvironChanged, + ticks => { - _playerController.SetCharacterSkills( - _lastSeenRunSkill, _lastSeenJumpSkill); - Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}"); - } - }, - onShortcuts: list => Shortcuts = list, - playerGuid: () => _playerServerGuid, - onUseDone: error => - { - _externalContainers.ApplyUseDone(error); - _itemInteractionController?.CompleteUse(error); - }, - itemMana: ItemMana, - onConfirmationRequest: request => - _retailUiRuntime?.HandleConfirmationRequest(request), - onConfirmationDone: done => - _retailUiRuntime?.HandleConfirmationDone(done), - friends: Friends, - squelch: Squelch, - onDesiredComponents: components => DesiredComponents = components, - onCharacterOptions: (options1, _) => - _characterOptions1 = - (AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1) - options1, - clientTime: ClientTimerNow, - externalContainers: _externalContainers); - - // Phase I.7: subscribe to CombatState events and emit - // retail-faithful "You hit X for Y damage" chat lines into - // the unified ChatLog. The translator owns the wording - // (templates ported from holtburger chat.rs:221-308); the - // panel renders combat entries via TextColored. - _combatChatTranslator = new AcDream.Core.Chat.CombatChatTranslator(Combat, Chat); - - // Phase H.1: feed inbound HearSpeech into the chat log. - _liveSession.SpeechHeard += speech => - Chat.OnLocalSpeech( - sender: speech.SenderName, - text: speech.Text, - senderGuid: speech.SenderGuid, - isRanged: speech.IsRanged); - - // Phase I.6: feed inbound TurbineChat events into the chat log. - // The Response variant is fire-and-forget (server-side ack); - // EventSendToRoom is a real chat message broadcast to a room. - // Phase J: ACE's GameMessageSystemChat (used for the login - // banner "Welcome to Asheron's Call ... type @acehelp" and - // for SystemChat broadcasts) rides opcode 0xF7E0 ServerMessage, - // parsed in I.5 but never wired. Surface it as a System - // chat line so the welcome banner appears + future server - // pushes (announcements, command responses) show. - _liveSession.ServerMessageReceived += sm => - Chat.OnSystemMessage(sm.Message, sm.ChatType); - - // Phase I.5 + J: emotes already had ChatLog adapters; wire - // their session events here so they actually reach chat. - _liveSession.EmoteHeard += emote => - Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid); - _liveSession.SoulEmoteHeard += emote => - Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid); - _liveSession.PlayerKilledReceived += pk => - Chat.OnPlayerKilled(pk.DeathMessage, pk.VictimGuid, pk.KillerGuid); - - _liveSession.TurbineChatReceived += parsed => - { - if (parsed.Body is AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom ev) - { - // Pass the friendly channel name out-of-band via - // ChatLog.OnChannelBroadcast's channelName param so - // ChatVM.FormatEntry can render the retail-style - // "[Trade] +Acdream says, \"hello\"" without us - // mangling the payload text. - string label = TurbineRoomDisplayName(ev.RoomId, ev.ChatType); - Chat.OnChannelBroadcast( - channelId: ev.RoomId, - sender: ev.SenderName, - text: ev.Message, - channelName: label); - } - // Response (server ack of an outbound RequestSendToRoomById) - // and Unknown payloads are intentionally not surfaced — - // the inbound EventSendToRoom for our own message acts as - // the canonical echo. - }; - - // Phase I.3: real ICommandBus. Panels publish SendChatCmd here - // and we route it to the right wire opcode (Talk / Tell / ChatChannel) - // plus a local echo into ChatLog so the player sees their own - // message immediately. Closes over _liveSession + Chat so this - // wiring only exists for the lifetime of the live session. - // Step 2: capture the non-null `session` parameter rather than - // the nullable _liveSession field. Semantically identical (the - // field WAS set to `session` at the top of WireLiveSessionEvents) - // but the compiler can prove non-null for the lambda body. - var liveSession = session; - var chat = Chat; - _commandBus = new AcDream.UI.Abstractions.LiveCommandBus(); - var turbineChat = TurbineChat; - uint playerGuid = _playerServerGuid; - var clientCommandController = new AcDream.App.UI.ClientCommandController( - new AcDream.App.UI.ClientCommandController.Bindings( - TeleportToLifestone: liveSession.SendTeleportToLifestone, - TeleportToMarketplace: liveSession.SendTeleportToMarketplace, - TeleportToPkArena: liveSession.SendTeleportToPkArena, - TeleportToPkLiteArena: liveSession.SendTeleportToPkLiteArena, - TeleportToHouse: liveSession.SendTeleportToHouse, - TeleportToMansion: liveSession.SendTeleportToMansion, - QueryAge: liveSession.SendQueryAge, - QueryBirth: liveSession.SendQueryBirth, - ToggleFrameRate: ToggleRetailFrameRate, - ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI), - ShowSystemMessage: text => chat.OnSystemMessage(text, 0u), - ShowWeenieError: code => chat.OnWeenieError(code, null), - PlayerPublicWeenieBitfield: () => - Objects.Get(_playerServerGuid)?.PublicWeenieBitfield, - ClientVersion: () => - typeof(GameWindow).Assembly.GetName().Version?.ToString(3) - ?? "unknown", - CurrentPosition: () => _playerController?.CellPosition, - LastOutsideCorpsePosition: () => - LocalPlayer.GetPosition(0x0Eu), - ShowConfirmation: (message, completed) => - _retailUiRuntime?.ShowConfirmation(message, completed), - Suicide: liveSession.SendSuicide, - ClearChat: _ => chat.Clear(), - SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name), - LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name), - SaveAutoUi: () => _retailUiRuntime?.SaveLayout(), - LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(), - IsAway: () => - Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true, - SetAway: away => SetRetailAway(liveSession, away), - SetAwayMessage: liveSession.SendSetAfkMessage, - AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits, - SetAcceptLootPermits: SetRetailAcceptLootPermits, - DisplayConsent: liveSession.SendDisplayConsent, - ClearConsent: liveSession.SendClearConsent, - RemoveConsent: liveSession.SendRemoveConsent, - SendEmote: liveSession.SendEmote, - Friends: Friends, - AddFriend: liveSession.SendAddFriend, - RemoveFriend: liveSession.SendRemoveFriend, - ClearFriends: liveSession.SendClearFriends, - RequestLegacyFriends: liveSession.SendLegacyFriendsListRequest, - Squelch: Squelch, - ModifyCharacterSquelch: liveSession.SendModifyCharacterSquelch, - ModifyAccountSquelch: liveSession.SendModifyAccountSquelch, - ModifyGlobalSquelch: liveSession.SendModifyGlobalSquelch, - LastTeller: () => _retailChatVm?.LastIncomingTellSender, - ClearDesiredComponents: () => - { - liveSession.SendClearDesiredComponents(); - DesiredComponents = System.Array.Empty<(uint Id, uint Amount)>(); - }, - HasOpenVendor: () => false, - FillComponentBuyList: (_, _) => { })); - _commandBus.Register( - clientCommandController.Execute); - _commandBus.Register(cmd => - { - if (!string.IsNullOrEmpty(cmd.Text)) - liveSession.SendTalk(cmd.Text); - }); - _commandBus.Register(cmd => - { - if (string.IsNullOrEmpty(cmd.Text)) return; - switch (cmd.Channel) - { - case AcDream.UI.Abstractions.ChatChannelKind.Say: - // Phase J: drop optimistic /say echo. ACE's - // HandleActionTalk broadcasts a HearSpeech back - // to the sender too, and ChatLog.OnLocalSpeech - // detects own-guid match to render it as - // "You say, ...". Optimistic-echoing here - // doubled the line. ALSO: don't echo "@xxx" - // server-side admin commands — ACE consumes - // them silently and replies via SystemChat. - liveSession.SendTalk(cmd.Text); - break; - case AcDream.UI.Abstractions.ChatChannelKind.Tell: - if (string.IsNullOrEmpty(cmd.TargetName)) return; - liveSession.SendTell(cmd.TargetName, cmd.Text); - chat.OnSelfSent( - AcDream.Core.Chat.ChatKind.Tell, cmd.Text, - targetOrChannel: cmd.TargetName); - break; - default: - // Phase I.6: try TurbineChat first for the global - // community channels (General/Trade/LFG/Roleplay/ - // Society/Olthoi) — they ride 0xF7DE TurbineChat. - // Allegiance is double-routed: try TurbineChat first - // (when the player has a Turbine allegiance room) and - // fall back to the legacy 0x0147 ChatChannel. - // - // We do NOT optimistic-echo channels: ACE's - // TurbineChatHandler broadcasts EventSendToRoom back - // to the sender too, so we always get the canonical - // echo from the server. Optimistic-echoing here - // double-prints the message (one as "[Trade] hello" - // from us, one as "[Trade] +Acdream says, \"hello\"" - // from the server). Trust the server. - var turbine = ResolveTurbineForKind(cmd.Channel, turbineChat); - if (turbine is not null) - { - uint cookie = turbineChat.NextContextId(); - // Use the live player guid if it's been captured; - // otherwise 0 (server treats unknown sender_id - // gracefully — the cookie is what we care about). - uint senderGuid = _playerServerGuid != 0u - ? _playerServerGuid - : playerGuid; - Console.WriteLine( - $"chat: outbound TurbineChat {turbine.Value.DisplayName} " + - $"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " + - $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={cmd.Text.Length}"); - liveSession.SendTurbineChatTo( - roomId: turbine.Value.RoomId, - chatType: turbine.Value.ChatType, - dispatchType: (uint)AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomById, - senderGuid: senderGuid, - text: cmd.Text, - cookie: cookie); - // No optimistic echo: server EventSendToRoom - // broadcast comes back with sender="+Acdream" - // and is rendered by ChatVM as - // "[Trade] +Acdream says, \"hello\"". - break; - } - - var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel); - if (resolved is null) - { - // Diagnostic: the user picked a channel kind that - // (a) isn't a Turbine channel TurbineChatState - // knows about and (b) has no legacy ChatChannel - // mapping. Most common cause: TurbineChat hasn't - // been enabled yet (server didn't send 0x0295) - // and the kind is General/Trade/LFG/etc. - Console.WriteLine( - $"chat: SendChatCmd kind={cmd.Channel} dropped " + - $"(turbine.Enabled={turbineChat.Enabled} no legacy id)"); - return; - } - Console.WriteLine( - $"chat: outbound legacy ChatChannel {resolved.Value.DisplayName} " + - $"id=0x{resolved.Value.ChannelId:X8} len={cmd.Text.Length}"); - liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text); - // Legacy channels (Fellowship / Allegiance / Patron / - // Monarch / Vassals / CoVassals) — keep the optimistic - // echo because legacy ChatChannel does NOT always - // broadcast back to the sender. ChannelName is the - // friendly display name so ChatVM renders it as - // "[Fellowship] +Acdream says, \"hello\"". - chat.OnSelfSent( - AcDream.Core.Chat.ChatKind.Channel, cmd.Text, - targetOrChannel: resolved.Value.DisplayName); - break; - } - }); - - // Issue #5: feed PrivateUpdateVital + PrivateUpdateVitalCurrent - // into LocalPlayer so VitalsPanel can draw Stam / Mana bars. - _liveSession.VitalUpdated += v => - LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current); - _liveSession.VitalCurrentUpdated += v => - LocalPlayer.OnVitalCurrent(v.VitalId, v.Current); + WorldTime.SyncFromServer(ticks); + RefreshSkyForCurrentDay(); + }), + CreateLiveInventorySessionBindings(), + CreateLiveCharacterSessionBindings(skillTable), + CreateLiveSocialSessionBindings()); + commands = new AcDream.App.Net.LiveSessionCommandRouter( + CreateLiveSessionCommandBindings(session)); + _liveSessionEvents = events; + _liveSessionCommands = commands; + } + catch + { + commands?.Dispose(); + events?.Dispose(); + throw; + } + } + + private AcDream.App.Net.LiveEntitySessionSink CreateLiveEntitySessionSink() => new( + Spawned: spawn => _inboundEntityEvents.Run( + this, spawn, static (window, value) => window.OnLiveEntitySpawned(value)), + Deleted: deletion => _inboundEntityEvents.Run( + this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)), + PickedUp: pickup => _inboundEntityEvents.Run( + this, pickup, static (window, value) => window.OnLiveEntityPickedUp(value)), + MotionUpdated: motion => _inboundEntityEvents.Run( + this, motion, static (window, value) => window.OnLiveMotionUpdated(value)), + PositionUpdated: position => _inboundEntityEvents.Run( + this, position, static (window, value) => window.OnLivePositionUpdated(value)), + VectorUpdated: vector => _inboundEntityEvents.Run( + this, vector, static (window, value) => window.OnLiveVectorUpdated(value)), + StateUpdated: state => _inboundEntityEvents.Run( + this, state, static (window, value) => window.OnLiveStateUpdated(value)), + ParentUpdated: parent => _inboundEntityEvents.Run( + this, parent, static (window, value) => window.OnLiveParentUpdated(value)), + TeleportStarted: teleport => _inboundEntityEvents.Run( + this, teleport, static (window, value) => window.OnTeleportStarted(value)), + AppearanceUpdated: appearance => _inboundEntityEvents.Run( + this, appearance, static (window, value) => window.OnLiveAppearanceUpdated(value)), + PlayPhysicsScript: script => _inboundEntityEvents.Run( + this, script, static (window, value) => window.OnPlayScriptReceived(value)), + PlayPhysicsScriptType: message => _inboundEntityEvents.Run( + this, message, static (window, value) => window._entityEffects?.HandleTyped(value))); + + private AcDream.App.Net.LiveInventorySessionBindings + CreateLiveInventorySessionBindings() => new( + Objects, + LocalPlayer, + PlayerGuid: () => _playerServerGuid, + OnShortcuts: list => Shortcuts = list, + OnUseDone: error => + { + _externalContainers.ApplyUseDone(error); + _itemInteractionController?.CompleteUse(error); + }, + ItemMana, + OnDesiredComponents: components => DesiredComponents = components, + ExternalContainers: _externalContainers); + + private AcDream.App.Net.LiveCharacterSessionBindings + CreateLiveCharacterSessionBindings( + DatReaderWriter.DBObjs.SkillTable? skillTable) => new( + Combat, + SpellBook, + ResolveSkillFormulaBonus: (skillId, attributeCurrents) => + { + // ACE AttributeFormula.GetFormula + // (references/ACE/Source/ACE.Entity/Models/AttributeFormula.cs:55-): + // Attribute1Multiplier == 0 means no attribute contribution. + // Otherwise the retail data formula is + // (attr1 * mult1 + attr2 * mult2) / divisor + additive. + // Guard a zero divisor because malformed/custom DAT input must not + // take down the live-session dispatcher. + if (skillTable?.Skills is null) + return 0u; + if (!skillTable.Skills.TryGetValue( + (DatReaderWriter.Enums.SkillId)skillId, + out var skillBase)) + return 0u; + + var formula = skillBase.Formula; + if (formula.Attribute1Multiplier == 0 || formula.Divisor == 0) + return 0u; + + attributeCurrents.TryGetValue((uint)formula.Attribute1, out uint attribute1); + attributeCurrents.TryGetValue((uint)formula.Attribute2, out uint attribute2); + long numerator = + (long)attribute1 * formula.Attribute1Multiplier + + (long)attribute2 * formula.Attribute2Multiplier; + long bonus = numerator / formula.Divisor + formula.AdditiveBonus; + return bonus < 0 ? 0u : (uint)bonus; + }, + OnSkillsUpdated: (runSkill, jumpSkill) => + { + if (runSkill >= 0) + _lastSeenRunSkill = runSkill; + if (jumpSkill >= 0) + _lastSeenJumpSkill = jumpSkill; + if (_playerController is not null + && _lastSeenRunSkill >= 0 + && _lastSeenJumpSkill >= 0) + { + _playerController.SetCharacterSkills( + _lastSeenRunSkill, + _lastSeenJumpSkill); + Console.WriteLine( + $"player: applied server skills run={_lastSeenRunSkill} " + + $"jump={_lastSeenJumpSkill}"); + } + }, + OnConfirmationRequest: request => + _retailUiRuntime?.HandleConfirmationRequest(request), + OnConfirmationDone: done => + _retailUiRuntime?.HandleConfirmationDone(done), + OnCharacterOptions: (options1, _) => + _characterOptions1 = + (AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1) + options1, + ClientTime: ClientTimerNow); + + private AcDream.App.Net.LiveSocialSessionBindings + CreateLiveSocialSessionBindings() => new( + Chat, + TurbineChat, + Friends, + Squelch); + + private AcDream.App.Net.LiveSessionCommandBindings CreateLiveSessionCommandBindings( + AcDream.Core.Net.WorldSession session) => new( + ClientCommands: new AcDream.App.UI.ClientCommandController.Bindings( + TeleportToLifestone: session.SendTeleportToLifestone, + TeleportToMarketplace: session.SendTeleportToMarketplace, + TeleportToPkArena: session.SendTeleportToPkArena, + TeleportToPkLiteArena: session.SendTeleportToPkLiteArena, + TeleportToHouse: session.SendTeleportToHouse, + TeleportToMansion: session.SendTeleportToMansion, + QueryAge: session.SendQueryAge, + QueryBirth: session.SendQueryBirth, + ToggleFrameRate: ToggleRetailFrameRate, + ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI), + ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u), + ShowWeenieError: code => Chat.OnWeenieError(code, null), + PlayerPublicWeenieBitfield: () => + Objects.Get(_playerServerGuid)?.PublicWeenieBitfield, + ClientVersion: () => + typeof(GameWindow).Assembly.GetName().Version?.ToString(3) + ?? "unknown", + CurrentPosition: () => _playerController?.CellPosition, + LastOutsideCorpsePosition: () => LocalPlayer.GetPosition(0x0Eu), + ShowConfirmation: (message, completed) => + _retailUiRuntime?.ShowConfirmation(message, completed), + Suicide: session.SendSuicide, + ClearChat: _ => Chat.Clear(), + SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name), + LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name), + SaveAutoUi: () => _retailUiRuntime?.SaveLayout(), + LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(), + IsAway: () => + Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true, + SetAway: away => SetRetailAway(session, away), + SetAwayMessage: session.SendSetAfkMessage, + AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits, + SetAcceptLootPermits: SetRetailAcceptLootPermits, + DisplayConsent: session.SendDisplayConsent, + ClearConsent: session.SendClearConsent, + RemoveConsent: session.SendRemoveConsent, + SendEmote: session.SendEmote, + Friends, + AddFriend: session.SendAddFriend, + RemoveFriend: session.SendRemoveFriend, + ClearFriends: session.SendClearFriends, + RequestLegacyFriends: session.SendLegacyFriendsListRequest, + Squelch, + ModifyCharacterSquelch: session.SendModifyCharacterSquelch, + ModifyAccountSquelch: session.SendModifyAccountSquelch, + ModifyGlobalSquelch: session.SendModifyGlobalSquelch, + LastTeller: () => _retailChatVm?.LastIncomingTellSender, + ClearDesiredComponents: () => + { + session.SendClearDesiredComponents(); + DesiredComponents = Array.Empty<(uint Id, uint Amount)>(); + }, + HasOpenVendor: () => false, + FillComponentBuyList: (_, _) => { }), + Chat, + TurbineChat, + PlayerGuid: () => _playerServerGuid, + SendTalk: session.SendTalk, + SendTell: session.SendTell, + SendChannel: session.SendChannel, + SendTurbineChat: (roomId, chatType, dispatchType, senderGuid, text, cookie) => + session.SendTurbineChatTo( + roomId, chatType, dispatchType, senderGuid, text, cookie), + Log: Console.WriteLine); + + private void DisposeLiveSessionRouting() + { + AcDream.App.Net.LiveSessionCommandRouter? commands = _liveSessionCommands; + AcDream.App.Net.LiveSessionEventRouter? events = _liveSessionEvents; + _liveSessionCommands = null; + _liveSessionEvents = null; + try + { + commands?.Dispose(); + } + finally + { + events?.Dispose(); + } } - /// - /// Convert a Phase 4.7 CreateObject spawn into a WorldEntity with hydrated - /// mesh refs and register it in IGameState. Called from WorldSession events - /// on the main thread (Tick runs in the Silk.NET Update callback). - /// private enum LiveProjectionPurpose { LogicalRegistration, @@ -11145,7 +10958,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // is up so panel-emitted SendChatCmd actually flows server-ward. // Fall back to NullCommandBus for offline / pre-connect renders. AcDream.UI.Abstractions.ICommandBus bus = - _commandBus ?? (AcDream.UI.Abstractions.ICommandBus) + _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus) AcDream.UI.Abstractions.NullCommandBus.Instance; var ctx = new AcDream.UI.Abstractions.PanelContext( (float)deltaSeconds, @@ -14330,7 +14143,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _magicCatalog = null; }), new("streamer", () => _streamer?.Dispose()), - new("combat chat", () => _combatChatTranslator?.Dispose()), + new("live session routing", DisposeLiveSessionRouting), new("equipped children", () => _equippedChildRenderer?.Dispose()), new("live session", () => { @@ -14470,76 +14283,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _window = null; } - // ── Phase I.6 — TurbineChat outbound helpers ────────────────── - - /// - /// Result of resolving a UI - /// to a runtime Turbine room. Returned by - /// when the player has access - /// to that Turbine channel; null otherwise. - /// - private readonly record struct TurbineResolution(uint RoomId, uint ChatType, string DisplayName); - - /// - /// Map a to a - /// runtime Turbine room id + chat-type. Returns null when - /// isn't - /// or the channel has no assigned room (e.g. player not in a society). - /// Mirrors holtburger's resolve_turbine_channel - /// (references/holtburger/.../client/commands.rs lines 64-98). - /// - private static TurbineResolution? ResolveTurbineForKind( - AcDream.UI.Abstractions.ChatChannelKind kind, - AcDream.Core.Chat.TurbineChatState state) - { - if (!state.Enabled) return null; - - var (room, chatType, name) = kind switch - { - AcDream.UI.Abstractions.ChatChannelKind.Allegiance => - (state.AllegianceRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance, "Allegiance"), - AcDream.UI.Abstractions.ChatChannelKind.General => - (state.GeneralRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.General, "General"), - AcDream.UI.Abstractions.ChatChannelKind.Trade => - (state.TradeRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade, "Trade"), - AcDream.UI.Abstractions.ChatChannelKind.Lfg => - (state.LfgRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg, "LFG"), - AcDream.UI.Abstractions.ChatChannelKind.Roleplay => - (state.RoleplayRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay, "Roleplay"), - AcDream.UI.Abstractions.ChatChannelKind.Society => - (state.SocietyRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Society, "Society"), - AcDream.UI.Abstractions.ChatChannelKind.Olthoi => - (state.OlthoiRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi, "Olthoi"), - _ => (0u, 0u, string.Empty), - }; - - if (room == 0u) return null; - return new TurbineResolution(room, chatType, name); - } - - /// - /// Pick a human-readable label for a Turbine room broadcast. Uses - /// the chat-type when known (semantic name), falls back to the - /// numeric room id for unknown rooms. - /// - private static string TurbineRoomDisplayName(uint roomId, uint chatType) - { - return (AcDream.Core.Net.Messages.TurbineChat.ChatType)chatType switch - { - AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance => "Allegiance", - AcDream.Core.Net.Messages.TurbineChat.ChatType.General => "General", - AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade => "Trade", - AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg => "LFG", - AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay => "Roleplay", - AcDream.Core.Net.Messages.TurbineChat.ChatType.Society => "Society", - AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyCelHan => "Celestial Hand", - AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web", - AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood", - AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi => "Olthoi", - _ => $"Room 0x{roomId:X8}", - }; - } - /// /// Fallback for the /// sequencer diff --git a/src/AcDream.Core.Net/CombatStateWiring.cs b/src/AcDream.Core.Net/CombatStateWiring.cs index 9e637e60..9cd19156 100644 --- a/src/AcDream.Core.Net/CombatStateWiring.cs +++ b/src/AcDream.Core.Net/CombatStateWiring.cs @@ -12,13 +12,19 @@ public static class CombatStateWiring { public const uint CombatModePropertyId = 40u; - public static IDisposable Wire(WorldSession session, CombatState combat) + public static IDisposable Wire( + WorldSession session, + CombatState combat, + Func? accepting = null) { ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(combat); Action handler = update => + { + if (accepting?.Invoke() == false) return; ApplyPlayerIntProperty(combat, update.Property, update.Value); + }; session.PlayerIntPropertyUpdated += handler; return new EventSubscription(session, handler); } diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 89f9561c..648b9f62 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -81,7 +81,8 @@ public static class GameEventWiring Action>? onDesiredComponents = null, Action? onCharacterOptions = null, Func? clientTime = null, - ExternalContainerState? externalContainers = null) + ExternalContainerState? externalContainers = null, + Func? accepting = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -89,7 +90,7 @@ public static class GameEventWiring ArgumentNullException.ThrowIfNull(spellbook); ArgumentNullException.ThrowIfNull(chat); clientTime ??= static () => 0d; - var registrar = new OwnedGameEventRegistrar(dispatcher); + var registrar = new OwnedGameEventRegistrar(dispatcher, accepting); using var construction = new RegistrationBuildScope(registrar); // ── Chat ────────────────────────────────────────────────── @@ -659,14 +660,23 @@ public static class GameEventWiring } private sealed class OwnedGameEventRegistrar( - GameEventDispatcher dispatcher) : IDisposable + GameEventDispatcher dispatcher, + Func? accepting) : IDisposable { private readonly SubscriptionSet _subscriptions = new(); public void Register( GameEventType type, - GameEventDispatcher.EventHandler handler) => - _subscriptions.Add(dispatcher.RegisterOwned(type, handler)); + GameEventDispatcher.EventHandler handler) + { + GameEventDispatcher.EventHandler registered = accepting is null + ? handler + : envelope => + { + if (accepting()) handler(envelope); + }; + _subscriptions.Add(dispatcher.RegisterOwned(type, registered)); + } public void Dispose() => _subscriptions.Dispose(); } diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index b9d320a7..b204bb5c 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -23,7 +23,8 @@ public static class ObjectTableWiring WorldSession session, ClientObjectTable table, Func? playerGuid = null, - LocalPlayerState? localPlayer = null) + LocalPlayerState? localPlayer = null, + Func? accepting = null) { ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(table); @@ -37,7 +38,10 @@ public static class ObjectTableWiring // UiEffects — the server is the authority on object properties. UpdateIntProperty // stores it in the bundle and still mirrors UiEffects → the typed Effects field. Action objectIntUpdated = u => + { + if (accepting?.Invoke() == false) return; table.UpdateIntProperty(u.Guid, u.Property, u.Value); + }; session.ObjectIntPropertyUpdated += objectIntUpdated; subscriptions.Add(() => session.ObjectIntPropertyUpdated -= objectIntUpdated); @@ -49,6 +53,7 @@ public static class ObjectTableWiring // than creating a phantom — the next PD / CreateObject seeds it. Action playerIntUpdated = u => { + if (accepting?.Invoke() == false) return; if (playerGuid is not null) table.UpdateIntProperty(playerGuid(), u.Property, u.Value); }; @@ -61,20 +66,30 @@ public static class ObjectTableWiring // Apply the authoritative 0x02CF update to both in this one wiring owner so they // cannot drift after the login PlayerDescription snapshot. Action playerInt64Updated = u => + { + if (accepting?.Invoke() == false) return; ApplyPlayerInt64PropertyUpdate( - table, localPlayer, playerGuid?.Invoke() ?? 0u, u); + table, localPlayer, playerGuid?.Invoke() ?? 0u, u); + }; session.PlayerInt64PropertyUpdated += playerInt64Updated; subscriptions.Add(() => session.PlayerInt64PropertyUpdated -= playerInt64Updated); // B-Wire: SetStackSize (0x0197) — update the object's stack count + value. Action stackSizeUpdated = u => + { + if (accepting?.Invoke() == false) return; table.UpdateStackSize(u.Guid, u.StackSize, u.Value); + }; session.StackSizeUpdated += stackSizeUpdated; subscriptions.Add(() => session.StackSizeUpdated -= stackSizeUpdated); // B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory // view; drop it from the table (retail ClientUISystem removes it from maintenance). - Action inventoryObjectRemoved = guid => table.Remove(guid); + Action inventoryObjectRemoved = guid => + { + if (accepting?.Invoke() == false) return; + table.Remove(guid); + }; session.InventoryObjectRemoved += inventoryObjectRemoved; subscriptions.Add(() => session.InventoryObjectRemoved -= inventoryObjectRemoved); diff --git a/src/AcDream.Core/Chat/CombatChatTranslator.cs b/src/AcDream.Core/Chat/CombatChatTranslator.cs index 4bec9247..d1b8ab3a 100644 --- a/src/AcDream.Core/Chat/CombatChatTranslator.cs +++ b/src/AcDream.Core/Chat/CombatChatTranslator.cs @@ -51,16 +51,34 @@ public sealed class CombatChatTranslator : IDisposable private bool _disposed; - public CombatChatTranslator(CombatState combat, ChatLog chat) + public CombatChatTranslator( + CombatState combat, + ChatLog chat, + Func? accepting = null) { _combat = combat ?? throw new ArgumentNullException(nameof(combat)); _chat = chat ?? throw new ArgumentNullException(nameof(chat)); - _onDealt = HandleDamageDealt; - _onTaken = HandleDamageTaken; - _onMissed = HandleMissedOutgoing; - _onEvaded = HandleEvadedIncoming; - _onKill = HandleKillLanded; + _onDealt = value => + { + if (accepting?.Invoke() != false) HandleDamageDealt(value); + }; + _onTaken = value => + { + if (accepting?.Invoke() != false) HandleDamageTaken(value); + }; + _onMissed = value => + { + if (accepting?.Invoke() != false) HandleMissedOutgoing(value); + }; + _onEvaded = value => + { + if (accepting?.Invoke() != false) HandleEvadedIncoming(value); + }; + _onKill = (name, guid) => + { + if (accepting?.Invoke() != false) HandleKillLanded(name, guid); + }; _combat.DamageDealtAccepted += _onDealt; _combat.DamageTaken += _onTaken; diff --git a/src/AcDream.UI.Abstractions/LiveCommandBus.cs b/src/AcDream.UI.Abstractions/LiveCommandBus.cs index 3f67d4ad..cb260c8e 100644 --- a/src/AcDream.UI.Abstractions/LiveCommandBus.cs +++ b/src/AcDream.UI.Abstractions/LiveCommandBus.cs @@ -61,4 +61,11 @@ public sealed class LiveCommandBus : ICommandBus $"[LiveCommandBus] no handler registered for {typeof(T).FullName}; dropping."); } } + + /// + /// Release every registered handler. Session-scoped owners call this + /// during teardown so a retained bus cannot keep an obsolete transport or + /// host object graph alive. + /// + public void Clear() => _handlers.Clear(); } diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs new file mode 100644 index 00000000..5674cac4 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -0,0 +1,301 @@ +using System.Runtime.CompilerServices; +using AcDream.App.Net; +using AcDream.App.UI; +using AcDream.Core.Chat; +using AcDream.Core.Social; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Tests.Net; + +public sealed class LiveSessionCommandRouterTests +{ + [Fact] + public void InactiveAndDisposedRouter_CannotReachTransport() + { + var sent = new List(); + var router = NewRouter(sendTalk: sent.Add); + + router.Publish(new SendServerCommandCmd("@before")); + router.Activate(); + router.Activate(); + router.Publish(new SendServerCommandCmd("@active")); + router.Dispose(); + router.Dispose(); + router.Publish(new SendServerCommandCmd("@after")); + + Assert.Equal(["@active"], sent); + Assert.False(router.IsActive); + } + + [Fact] + public void TellAndLegacyChannel_PreserveOutboundAndEchoPolicy() + { + var tells = new List<(string Target, string Text)>(); + var channels = new List<(uint Id, string Text)>(); + var chat = new ChatLog(); + var router = NewRouter( + chat: chat, + sendTell: (target, text) => tells.Add((target, text)), + sendChannel: (id, text) => channels.Add((id, text))); + router.Activate(); + + router.Publish(new SendChatCmd(ChatChannelKind.Tell, "Friend", "hello")); + router.Publish(new SendChatCmd(ChatChannelKind.Fellowship, null, "group")); + + Assert.Equal([("Friend", "hello")], tells); + Assert.Equal([(0x00000800u, "group")], channels); + Assert.Collection( + chat.Snapshot(), + entry => + { + Assert.Equal(ChatKind.Tell, entry.Kind); + Assert.Equal("Friend", entry.Sender); + }, + entry => + { + Assert.Equal(ChatKind.Channel, entry.Kind); + Assert.Equal("Fellowship", entry.ChannelName); + }); + } + + [Fact] + public void TurbineChannel_UsesRuntimeRoomCookieAndNoOptimisticEcho() + { + var turbine = new TurbineChatState(); + turbine.OnChannelsReceived( + allegianceRoom: 0u, + generalRoom: 0x70000001u, + tradeRoom: 0u, + lfgRoom: 0u, + roleplayRoom: 0u, + olthoiRoom: 0u, + societyRoom: 0u, + societyCelestialHandRoom: 0u, + societyEldrytchWebRoom: 0u, + societyRadiantBloodRoom: 0u); + var sent = new List<(uint Room, uint Type, uint Dispatch, uint Sender, string Text, uint Cookie)>(); + var chat = new ChatLog(); + var router = NewRouter( + chat: chat, + turbine: turbine, + playerGuid: () => 0x50000001u, + sendTurbine: (room, type, dispatch, sender, text, cookie) => + sent.Add((room, type, dispatch, sender, text, cookie))); + router.Activate(); + + router.Publish(new SendChatCmd(ChatChannelKind.General, null, "world")); + + Assert.Collection(sent, message => + { + Assert.Equal(0x70000001u, message.Room); + Assert.Equal(0x02u, message.Type); + Assert.Equal(0x02u, message.Dispatch); + Assert.Equal(0x50000001u, message.Sender); + Assert.Equal("world", message.Text); + Assert.Equal(1u, message.Cookie); + }); + Assert.Equal(0, chat.Count); + } + + [Fact] + public void ActivateAfterDispose_IsRejected() + { + var router = NewRouter(); + router.Dispose(); + + Assert.Throws(router.Activate); + } + + [Fact] + public async Task ConcurrentDispose_WaitsForInFlightTransportThenMakesRouterInert() + { + using var sendEntered = new ManualResetEventSlim(); + using var releaseSend = new ManualResetEventSlim(); + var sent = new List(); + var router = NewRouter(sendTalk: text => + { + sendEntered.Set(); + releaseSend.Wait(); + sent.Add(text); + }); + router.Activate(); + + Task publish = Task.Run(() => + router.Publish(new SendServerCommandCmd("@active"))); + Assert.True(sendEntered.Wait(TimeSpan.FromSeconds(5))); + using var disposeStarted = new ManualResetEventSlim(); + Task dispose = Task.Run(() => + { + disposeStarted.Set(); + router.Dispose(); + }); + Assert.True(disposeStarted.Wait(TimeSpan.FromSeconds(5))); + Task firstCompletion = await Task.WhenAny( + dispose, + Task.Delay(TimeSpan.FromMilliseconds(100))); + Assert.NotSame(dispose, firstCompletion); + + releaseSend.Set(); + await Task.WhenAll(publish, dispose); + router.Publish(new SendServerCommandCmd("@after")); + + Assert.Equal(["@active"], sent); + Assert.False(router.IsActive); + } + + [Fact] + public void ReentrantDisposeFromLog_PreventsLaterTransportAndEcho() + { + var turbine = new TurbineChatState(); + turbine.OnChannelsReceived( + 0u, 0x70000001u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u); + var sent = new List(); + var chat = new ChatLog(); + LiveSessionCommandRouter? router = null; + router = NewRouter( + chat: chat, + turbine: turbine, + sendTurbine: (_, _, _, _, text, _) => sent.Add(text), + log: _ => router!.Dispose()); + router.Activate(); + + router.Publish(new SendChatCmd(ChatChannelKind.General, null, "world")); + + Assert.Empty(sent); + Assert.Equal(0, chat.Count); + Assert.False(router.IsActive); + } + + [Fact] + public void RetainedDisposedRouter_ReleasesCapturedTransportGraph() + { + (LiveSessionCommandRouter router, WeakReference transport) = + CreateRouterWithCapturedTransport(); + + router.Dispose(); + ForceFullCollection(); + + Assert.False(transport.TryGetTarget(out _)); + GC.KeepAlive(router); + } + + [Fact] + public void DelayedConfirmationCallback_ReleasesTransportAndBecomesInert() + { + (LiveSessionCommandRouter router, WeakReference transport, Action callback) = + CreateRouterWithDelayedConfirmation(); + + router.Dispose(); + ForceFullCollection(); + + Assert.False(transport.TryGetTarget(out _)); + callback(true); + Assert.False(router.IsActive); + GC.KeepAlive(callback); + GC.KeepAlive(router); + } + + private static LiveSessionCommandRouter NewRouter( + ChatLog? chat = null, + TurbineChatState? turbine = null, + Func? playerGuid = null, + Action? sendTalk = null, + Action? sendTell = null, + Action? sendChannel = null, + Action? sendTurbine = null, + Action? log = null, + ClientCommandController.Bindings? clientBindings = null) => new( + new LiveSessionCommandBindings( + clientBindings ?? NewClientBindings(), + chat ?? new ChatLog(), + turbine ?? new TurbineChatState(), + playerGuid ?? (() => 0u), + sendTalk ?? (_ => { }), + sendTell ?? ((_, _) => { }), + sendChannel ?? ((_, _) => { }), + sendTurbine ?? ((_, _, _, _, _, _) => { }), + log)); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static (LiveSessionCommandRouter, WeakReference) + CreateRouterWithCapturedTransport() + { + object transport = new object(); + var weak = new WeakReference(transport); + var router = NewRouter(sendTalk: _ => GC.KeepAlive(transport)); + return (router, weak); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static (LiveSessionCommandRouter, WeakReference, Action) + CreateRouterWithDelayedConfirmation() + { + object transport = new object(); + var weak = new WeakReference(transport); + Action? callback = null; + ClientCommandController.Bindings bindings = NewClientBindings() with + { + ShowConfirmation = (_, completion) => callback = completion, + Suicide = () => GC.KeepAlive(transport), + }; + var router = NewRouter(clientBindings: bindings); + router.Activate(); + router.Publish(new ExecuteClientCommandCmd(ClientCommandId.Die, string.Empty)); + return (router, weak, Assert.IsType>(callback)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ForceFullCollection() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + private static ClientCommandController.Bindings NewClientBindings() => new( + TeleportToLifestone: () => { }, + TeleportToMarketplace: () => { }, + TeleportToPkArena: () => { }, + TeleportToPkLiteArena: () => { }, + TeleportToHouse: () => { }, + TeleportToMansion: () => { }, + QueryAge: () => { }, + QueryBirth: () => { }, + ToggleFrameRate: () => { }, + ToggleUiLock: () => { }, + ShowSystemMessage: _ => { }, + ShowWeenieError: _ => { }, + PlayerPublicWeenieBitfield: () => null, + ClientVersion: () => "test", + CurrentPosition: () => null, + LastOutsideCorpsePosition: () => null, + ShowConfirmation: (_, _) => { }, + Suicide: () => { }, + ClearChat: _ => { }, + SaveUi: _ => { }, + LoadUi: _ => { }, + SaveAutoUi: () => { }, + LoadAutoUi: () => { }, + IsAway: () => false, + SetAway: _ => { }, + SetAwayMessage: _ => { }, + AcceptLootPermits: () => false, + SetAcceptLootPermits: _ => { }, + DisplayConsent: () => { }, + ClearConsent: () => { }, + RemoveConsent: _ => { }, + SendEmote: _ => { }, + Friends: new FriendsState(), + AddFriend: _ => { }, + RemoveFriend: _ => { }, + ClearFriends: () => { }, + RequestLegacyFriends: () => { }, + Squelch: new SquelchState(), + ModifyCharacterSquelch: (_, _, _, _) => { }, + ModifyAccountSquelch: (_, _) => { }, + ModifyGlobalSquelch: (_, _) => { }, + LastTeller: () => null, + ClearDesiredComponents: () => { }, + HasOpenVendor: () => false, + FillComponentBuyList: (_, _) => { }); +} diff --git a/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs new file mode 100644 index 00000000..8402cbfb --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs @@ -0,0 +1,291 @@ +using System.Net; +using System.Reflection; +using AcDream.App.Net; +using AcDream.Core.Chat; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Player; +using AcDream.Core.Social; +using AcDream.Core.Spells; + +namespace AcDream.App.Tests.Net; + +public sealed class LiveSessionEventRouterTests +{ + [Fact] + public void Dispose_DetachesExactHandlersAndSilencesCopiedDelegates() + { + using var session = NewSession(); + int baselineGameEvents = session.GameEvents.RegisteredHandlerCount; + var counters = new Counters(); + var combat = new CombatState(); + var chat = new ChatLog(); + var router = NewRouter(session, counters, combat: combat, chat: chat); + + AssertSessionHandlerCounts(session, multiplier: 1); + Assert.True(session.GameEvents.RegisteredHandlerCount > baselineGameEvents); + Action copiedTime = EventDelegate>( + session, + nameof(session.ServerTimeUpdated)); + Action copiedTeleport = EventDelegate>( + session, + nameof(session.TeleportStarted)); + Action copiedPlayerInt = + EventDelegate>( + session, + nameof(session.PlayerIntPropertyUpdated)); + Action copiedDamage = + EventDelegate>( + combat, + nameof(combat.DamageDealtAccepted)); + copiedTime(123d); + copiedTeleport(0x50000001u); + copiedPlayerInt(new WorldSession.PlayerIntPropertyUpdate( + CombatStateWiring.CombatModePropertyId, + (int)CombatMode.Melee)); + copiedDamage(new CombatState.DamageDealt("Drudge", 1u, 5u, 0.1f)); + Assert.Equal(1, counters.ServerTime); + Assert.Equal(1, counters.Teleport); + Assert.Equal(CombatMode.Melee, combat.CurrentMode); + Assert.Equal(1, chat.Count); + + router.Dispose(); + router.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 0); + Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); + + copiedTime(456d); + copiedTeleport(0x50000002u); + copiedPlayerInt(new WorldSession.PlayerIntPropertyUpdate( + CombatStateWiring.CombatModePropertyId, + (int)CombatMode.Magic)); + copiedDamage(new CombatState.DamageDealt("Drudge", 1u, 7u, 0.2f)); + Assert.Equal(1, counters.ServerTime); + Assert.Equal(1, counters.Teleport); + Assert.Equal(CombatMode.Melee, combat.CurrentMode); + Assert.Equal(1, chat.Count); + } + + [Fact] + public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter() + { + using var session = NewSession(); + int baselineGameEvents = session.GameEvents.RegisteredHandlerCount; + var first = new Counters(); + var second = new Counters(); + var routerA = NewRouter(session, first); + var routerB = NewRouter(session, second); + + AssertSessionHandlerCounts(session, multiplier: 2); + EventDelegate>(session, nameof(session.ServerTimeUpdated))(1d); + Assert.Equal(1, first.ServerTime); + Assert.Equal(1, second.ServerTime); + + routerA.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 1); + EventDelegate>(session, nameof(session.ServerTimeUpdated))(2d); + Assert.Equal(1, first.ServerTime); + Assert.Equal(2, second.ServerTime); + + routerB.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 0); + Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); + } + + [Fact] + public void NestedRouters_DisposeNewerFirstLeavesOnlyOlderRouter() + { + using var session = NewSession(); + int baselineGameEvents = session.GameEvents.RegisteredHandlerCount; + var first = new Counters(); + var second = new Counters(); + var routerA = NewRouter(session, first); + var routerB = NewRouter(session, second); + + routerB.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 1); + EventDelegate>(session, nameof(session.ServerTimeUpdated))(1d); + Assert.Equal(1, first.ServerTime); + Assert.Equal(0, second.ServerTime); + + routerA.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 0); + Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); + } + + [Fact] + public void ThrowingSink_DoesNotCorruptLaterTeardown() + { + using var session = NewSession(); + int baselineGameEvents = session.GameEvents.RegisteredHandlerCount; + var router = NewRouter( + session, + new Counters { ThrowOnServerTime = true }); + + Assert.Throws(() => + EventDelegate>( + session, + nameof(session.ServerTimeUpdated))(1d)); + + router.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 0); + Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); + } + + [Fact] + public void ConstructionFailure_UnwindsEveryPriorRegistration() + { + using var session = NewSession(); + int baselineGameEvents = session.GameEvents.RegisteredHandlerCount; + + Assert.Throws(() => NewRouter( + session, + new Counters(), + step => + { + if (step == 20) + throw new InvalidOperationException("injected construction failure"); + })); + + AssertSessionHandlerCounts(session, multiplier: 0); + Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); + } + + private static LiveSessionEventRouter NewRouter( + WorldSession session, + Counters counters, + Action? constructionCheckpoint = null, + CombatState? combat = null, + ChatLog? chat = null) => new( + session, + new LiveEntitySessionSink( + Spawned: _ => { }, + Deleted: _ => { }, + PickedUp: _ => { }, + MotionUpdated: _ => { }, + PositionUpdated: _ => { }, + VectorUpdated: _ => { }, + StateUpdated: _ => { }, + ParentUpdated: _ => { }, + TeleportStarted: _ => counters.Teleport++, + AppearanceUpdated: _ => { }, + PlayPhysicsScript: _ => { }, + PlayPhysicsScriptType: _ => { }), + new LiveEnvironmentSessionSink( + EnvironChanged: _ => { }, + ServerTimeUpdated: _ => + { + if (counters.ThrowOnServerTime) + throw new InvalidOperationException("injected sink failure"); + counters.ServerTime++; + }), + NewInventoryBindings(), + NewCharacterBindings(combat), + NewSocialBindings(chat), + constructionCheckpoint); + + private static LiveInventorySessionBindings NewInventoryBindings() => new( + new ClientObjectTable(), + new LocalPlayerState(), + PlayerGuid: () => 0x50000001u, + OnShortcuts: null, + OnUseDone: null, + ItemMana: new ItemManaState(), + OnDesiredComponents: null, + ExternalContainers: new ExternalContainerState()); + + private static LiveCharacterSessionBindings NewCharacterBindings( + CombatState? combat = null) => new( + combat ?? new CombatState(), + new Spellbook(), + ResolveSkillFormulaBonus: null, + OnSkillsUpdated: null, + OnConfirmationRequest: null, + OnConfirmationDone: null, + OnCharacterOptions: null, + ClientTime: () => 0d); + + private static LiveSocialSessionBindings NewSocialBindings( + ChatLog? chat = null) => new( + chat ?? new ChatLog(), + new TurbineChatState(), + new FriendsState(), + new SquelchState()); + + private static WorldSession NewSession() => + new(new IPEndPoint(IPAddress.Loopback, 9)); + + private static void AssertSessionHandlerCounts( + WorldSession session, + int multiplier) + { + string[] directEvents = + [ + nameof(session.EntitySpawned), + nameof(session.EntityDeleted), + nameof(session.EntityPickedUp), + nameof(session.MotionUpdated), + nameof(session.PositionUpdated), + nameof(session.VectorUpdated), + nameof(session.StateUpdated), + nameof(session.ParentUpdated), + nameof(session.TeleportStarted), + nameof(session.AppearanceUpdated), + nameof(session.PlayPhysicsScriptReceived), + nameof(session.PlayPhysicsScriptTypeReceived), + nameof(session.EnvironChanged), + nameof(session.ServerTimeUpdated), + nameof(session.SpeechHeard), + nameof(session.ServerMessageReceived), + nameof(session.EmoteHeard), + nameof(session.SoulEmoteHeard), + nameof(session.PlayerKilledReceived), + nameof(session.TurbineChatReceived), + nameof(session.VitalUpdated), + nameof(session.VitalCurrentUpdated), + ]; + foreach (string eventName in directEvents) + Assert.Equal(multiplier, HandlerCount(session, eventName)); + + Assert.Equal(multiplier, HandlerCount(session, nameof(session.ObjectIntPropertyUpdated))); + Assert.Equal(multiplier * 2, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated))); + Assert.Equal(multiplier, HandlerCount(session, nameof(session.PlayerInt64PropertyUpdated))); + Assert.Equal(multiplier, HandlerCount(session, nameof(session.StackSizeUpdated))); + Assert.Equal(multiplier, HandlerCount(session, nameof(session.InventoryObjectRemoved))); + } + + private static int HandlerCount(WorldSession session, string eventName) => + (EventField(session, eventName).GetValue(session) as MulticastDelegate)? + .GetInvocationList() + .Length ?? 0; + + private static TDelegate EventDelegate( + WorldSession session, + string eventName) + where TDelegate : Delegate => + Assert.IsType(EventField(session, eventName).GetValue(session)); + + private static TDelegate EventDelegate( + TOwner owner, + string eventName) + where TOwner : class + where TDelegate : Delegate => + Assert.IsType( + typeof(TOwner).GetField( + eventName, + BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(owner)); + + private static FieldInfo EventField(WorldSession session, string eventName) => + typeof(WorldSession).GetField( + eventName, + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"event field {eventName} not found"); + + private sealed class Counters + { + public int ServerTime; + public int Teleport; + public bool ThrowOnServerTime; + } +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs index 32cba289..a2059722 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Reflection; +using System.Buffers.Binary; using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Items; @@ -115,6 +116,35 @@ public sealed class WorldSessionWiringOwnershipTests Assert.Equal(baselineCount, session.GameEvents.RegisteredHandlerCount); } + [Fact] + public void GameEventWiring_AcceptanceGateSilencesCopiedOrInFlightDispatch() + { + var dispatcher = new GameEventDispatcher(); + var combat = new CombatState(); + bool accepting = true; + using IDisposable registration = GameEventWiring.WireAll( + dispatcher, + new ClientObjectTable(), + combat, + new Spellbook(), + new ChatLog(), + accepting: () => accepting); + byte[] first = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(first, 0x50000001u); + BinaryPrimitives.WriteSingleLittleEndian(first.AsSpan(4), 0.75f); + byte[] stale = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(stale, 0x50000001u); + BinaryPrimitives.WriteSingleLittleEndian(stale.AsSpan(4), 0.25f); + + dispatcher.Dispatch(new GameEventEnvelope( + 0u, 0u, GameEventType.UpdateHealth, first)); + accepting = false; + dispatcher.Dispatch(new GameEventEnvelope( + 0u, 0u, GameEventType.UpdateHealth, stale)); + + Assert.Equal(0.75f, combat.GetHealthPercent(0x50000001u)); + } + private static WorldSession NewSession() => new(new IPEndPoint(IPAddress.Loopback, 9)); diff --git a/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs b/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs index d8710e97..e5115b5a 100644 --- a/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs @@ -35,6 +35,20 @@ public sealed class LiveCommandBusTests Assert.Throws(() => bus.Register(_ => { })); } + + [Fact] + public void Clear_ReleasesHandlersAndMakesExistingBusInert() + { + var bus = new LiveCommandBus(); + int calls = 0; + bus.Register(_ => calls++); + bus.Publish(new FakeCmd(1)); + + bus.Clear(); + bus.Publish(new FakeCmd(2)); + + Assert.Equal(1, calls); + } } public sealed class ChannelResolverTests From 78a9223b652b572b9471ed14ee5ccc20a527752b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 11:18:03 +0200 Subject: [PATCH 30/98] docs(plan): record live session routing checkpoint --- docs/plans/2026-07-21-gamewindow-slice-3-live-session.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md index 3a602279..5e2689b0 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md +++ b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md @@ -23,8 +23,13 @@ character-selection UI or redesign the transport. transactional wiring construction, and adversarial unregister, reentrancy, replacement, and concurrent-dispose coverage. Release: 6,621 passed / 5 skipped. -- [ ] B — session event and command routers (in progress). -- [ ] C — convergent session reset. +- [x] B — session event and command routers (`961bdd07`): focused inbound + event and outbound command owners, exact reverse teardown, copied/in-flight + callback acceptance gates, inactive-before-EnterWorld publication, + concurrent and reentrant disposal serialization, and delayed-dialog + indirection that makes displaced transports both inert and collectible. + Three-agent review clean. Release: 6,636 passed / 5 skipped. +- [ ] C — convergent session reset (in progress). - [ ] D — complete lifecycle controller. - [ ] E — GameWindow cutover. - [ ] F — final review, connected gate, and documentation closeout. From 4f31a5085f3e3b07f418933acc9a752cc8d87f77 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 12:00:48 +0200 Subject: [PATCH 31/98] refactor(net): converge live session state reset --- .../Combat/CombatAttackController.cs | 13 + .../SelectionInteractionController.cs | 18 +- .../Net/LiveSessionResetManifest.cs | 127 ++++++ src/AcDream.App/Net/LiveSessionResetPlan.cs | 79 ++++ src/AcDream.App/Rendering/CameraController.cs | 6 +- src/AcDream.App/Rendering/GameWindow.cs | 168 ++++++-- .../UI/GameplayConfirmationController.cs | 11 + src/AcDream.App/UI/InteractionState.cs | 25 ++ .../UI/ItemInteractionController.cs | 54 ++- .../UI/Layout/RetailDialogFactory.cs | 55 ++- src/AcDream.App/UI/RetailUiRuntime.cs | 19 + src/AcDream.Core/Chat/ChatLog.cs | 17 +- src/AcDream.Core/Chat/TurbineChatState.cs | 27 ++ src/AcDream.Core/Combat/CombatState.cs | 17 +- .../Items/ExternalContainerState.cs | 22 +- src/AcDream.Core/Player/LocalPlayerState.cs | 33 ++ src/AcDream.Core/Selection/SelectionState.cs | 28 +- src/AcDream.Core/Social/SquelchState.cs | 11 + .../Panels/Chat/ChatVM.cs | 11 + .../Panels/Vitals/VitalsVM.cs | 4 +- .../Combat/CombatAttackControllerTests.cs | 28 ++ .../Net/LiveSessionResetPlanTests.cs | 387 ++++++++++++++++++ .../Rendering/CameraControllerTests.cs | 29 ++ .../UI/GameplayConfirmationControllerTests.cs | 21 + .../UI/InteractionStateTests.cs | 27 ++ .../UI/ItemInteractionControllerTests.cs | 27 ++ .../UI/Layout/RetailDialogFactoryTests.cs | 68 +++ .../Chat/ChatLogLocalGuidTests.cs | 23 ++ .../Chat/TurbineChatStateTests.cs | 26 ++ .../Combat/CombatStateTests.cs | 27 ++ .../Items/ExternalContainerStateTests.cs | 26 ++ .../Player/LocalPlayerStateTests.cs | 41 ++ .../Selection/SelectionStateTests.cs | 27 ++ .../Social/SocialStateTests.cs | 24 ++ .../Chat/ChatVMRetellAndProvidersTests.cs | 17 + 35 files changed, 1460 insertions(+), 83 deletions(-) create mode 100644 src/AcDream.App/Net/LiveSessionResetManifest.cs create mode 100644 src/AcDream.App/Net/LiveSessionResetPlan.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs diff --git a/src/AcDream.App/Combat/CombatAttackController.cs b/src/AcDream.App/Combat/CombatAttackController.cs index 88680892..047aa8b2 100644 --- a/src/AcDream.App/Combat/CombatAttackController.cs +++ b/src/AcDream.App/Combat/CombatAttackController.cs @@ -403,6 +403,19 @@ public sealed class CombatAttackController : IDisposable ResetPowerBar(); } + /// + /// Restore the process-lived attack controller to retail's new-session + /// defaults without sending a cancel packet through the displaced session. + /// Mirrors ClientCombatSystem::Begin @ 0x0056A460. + /// + public void ResetSession() + { + Reset(); + RequestedHeight = AttackHeight.Medium; + DesiredPower = InitialDesiredPower; + StateChanged?.Invoke(); + } + public void Dispose() { if (_disposed) return; diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 430d1258..47def6ee 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -386,11 +386,21 @@ internal sealed class SelectionInteractionController public void ResetSession() { - CancelPendingApproach(); - _items.ResetSession(); - _selection.Reset(); - _outbound.Clear(); + List failures = []; + try { CancelPendingApproach(); } + catch (Exception error) { failures.Add(error); } + try { _items.ResetSession(); } + catch (Exception error) { failures.Add(error); } + try { _selection.Reset(); } + catch (Exception error) { failures.Add(error); } + try { _outbound.Clear(); } + catch (Exception error) { failures.Add(error); } _pendingPostArrival = null; + + if (failures.Count != 0) + throw new AggregateException( + "One or more selection-interaction reset stages failed.", + failures); } private bool ValidatePickupTarget(uint serverGuid, bool showToast) diff --git a/src/AcDream.App/Net/LiveSessionResetManifest.cs b/src/AcDream.App/Net/LiveSessionResetManifest.cs new file mode 100644 index 00000000..9c6c3191 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionResetManifest.cs @@ -0,0 +1,127 @@ +namespace AcDream.App.Net; + +using AcDream.App.World; + +/// +/// Focused owner operations composed by the App host for one character-session +/// reset. Mixed world/static registries are deliberately absent: their +/// session-owned entries leave through canonical live-entity teardown. +/// +internal sealed class LiveSessionResetBindings +{ + public required Action MouseCapture { get; init; } + public required Action PlayerPresentation { get; init; } + public required Action TeleportTransit { get; init; } + public required Action SessionDialogs { get; init; } + public required Action ChatCommandTargets { get; init; } + public required Action SettingsCharacterContext { get; init; } + public required Action EquippedChildren { get; init; } + public required Action ExternalContainer { get; init; } + public required Action InteractionAndSelection { get; init; } + public required Action SelectionPresentation { get; init; } + public required Action ObjectTable { get; init; } + public required Action Spellbook { get; init; } + public required Action MagicRuntime { get; init; } + public required Action CombatAttack { get; init; } + public required Action CombatState { get; init; } + public required Action ItemMana { get; init; } + public required Action LocalPlayer { get; init; } + public required Action Friends { get; init; } + public required Action Squelch { get; init; } + public required Action TurbineChat { get; init; } + public required Action ParticleVisibility { get; init; } + public required Action InboundEventFifo { get; init; } + public required Action LiveLiveness { get; init; } + public required Action LiveRuntime { get; init; } + public required Action SessionIdentity { get; init; } + public required Action RemoteTeleport { get; init; } + public required Action NetworkEffects { get; init; } + public required Action AnimationHookFrames { get; init; } + public required Action LivePresentation { get; init; } + public required Action RemoteMovementDiagnostics { get; init; } + public required Action PhysicsHostIndex { get; init; } +} + +/// +/// The concrete, ordered character-session reset manifest. Retail +/// CPlayerSystem::OnEndCharacterSession @ 0x00562870 calls End then +/// Begin; ClientUISystem::OnEndCharacterSession @ 0x00564AD0 +/// independently resets gameplay UI. App resource dependencies impose the +/// exact projection-before-canonical-runtime order pinned here. +/// +internal static class LiveSessionResetManifest +{ + public static LiveSessionResetPlan Create(LiveSessionResetBindings bindings) + { + ArgumentNullException.ThrowIfNull(bindings); + return new LiveSessionResetPlan( + [ + new("mouse capture", bindings.MouseCapture), + new("player presentation", bindings.PlayerPresentation), + new("teleport transit", bindings.TeleportTransit), + new("session dialogs", bindings.SessionDialogs), + new("chat command targets", bindings.ChatCommandTargets), + new("settings character context", bindings.SettingsCharacterContext), + // Attachment projections own GL-backed registrations and must leave + // before canonical live GUID records are released. + new("equipped children", bindings.EquippedChildren), + new("external container", bindings.ExternalContainer), + new("interaction and selection", bindings.InteractionAndSelection), + new("selection presentation", bindings.SelectionPresentation), + new("object table", bindings.ObjectTable), + new("spellbook", bindings.Spellbook), + new("magic runtime", bindings.MagicRuntime), + new("combat attack", bindings.CombatAttack), + new("combat state", bindings.CombatState), + new("item mana", bindings.ItemMana), + new("local player", bindings.LocalPlayer), + new("friends", bindings.Friends), + new("squelch", bindings.Squelch), + new("turbine chat", bindings.TurbineChat), + new("particle visibility", bindings.ParticleVisibility), + new("inbound event fifo", bindings.InboundEventFifo), + new("live liveness", bindings.LiveLiveness), + new("live runtime", bindings.LiveRuntime), + // Identity must remain A until live teardown has converged so + // player-specific teardown can still classify the old record. + new("session identity", bindings.SessionIdentity), + new("remote teleport", bindings.RemoteTeleport), + // F754/F755 can precede CreateObject, so pending network effects + // must clear even when no LiveEntityRecord was constructed. + new("network effects", bindings.NetworkEffects), + new("animation hook frames", bindings.AnimationHookFrames), + new("live presentation", bindings.LivePresentation), + new("remote movement diagnostics", bindings.RemoteMovementDiagnostics), + new("physics host index", bindings.PhysicsHostIndex), + ]); + } +} + +/// Shared convergence gate for canonical live runtime teardown. +internal static class LiveSessionEntityRuntimeReset +{ + public static void ClearAndRequireConvergence(LiveEntityRuntime? runtime) + { + if (runtime is null) + return; + runtime.Clear(); + RequireConvergence(runtime); + } + + public static void RequireConvergence(LiveEntityRuntime? runtime) + { + if (runtime is null) + return; + if (runtime.Count == 0 + && runtime.PendingTeardownCount == 0 + && runtime.MaterializedCount == 0) + { + return; + } + + throw new InvalidOperationException( + "The live-entity runtime has not converged after session clear " + + $"(records={runtime.Count}, pending={runtime.PendingTeardownCount}, " + + $"materialized={runtime.MaterializedCount})."); + } +} diff --git a/src/AcDream.App/Net/LiveSessionResetPlan.cs b/src/AcDream.App/Net/LiveSessionResetPlan.cs new file mode 100644 index 00000000..dbfba133 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionResetPlan.cs @@ -0,0 +1,79 @@ +namespace AcDream.App.Net; + +/// One named owner operation in the live-session reset transaction. +internal sealed record LiveSessionResetStage(string Name, Action Reset); + +/// +/// Executes every session-state reset stage even when an earlier owner fails. +/// A failed attempt throws after the complete manifest has run; lifecycle code +/// therefore cannot construct the next session until a later attempt converges. +/// +internal sealed class LiveSessionResetPlan +{ + private readonly LiveSessionResetStage[] _stages; + private int _executing; + + public LiveSessionResetPlan(IEnumerable stages) + { + ArgumentNullException.ThrowIfNull(stages); + _stages = stages.ToArray(); + var names = new HashSet(StringComparer.Ordinal); + foreach (LiveSessionResetStage stage in _stages) + { + ArgumentNullException.ThrowIfNull(stage); + if (string.IsNullOrWhiteSpace(stage.Name)) + throw new ArgumentException("Reset stage names must be non-empty.", nameof(stages)); + ArgumentNullException.ThrowIfNull(stage.Reset); + if (!names.Add(stage.Name)) + throw new ArgumentException( + $"Duplicate live-session reset stage '{stage.Name}'.", + nameof(stages)); + } + } + + public IReadOnlyList StageNames => + Array.ConvertAll(_stages, static stage => stage.Name); + + public void Execute() + { + if (Interlocked.Exchange(ref _executing, 1) != 0) + throw new InvalidOperationException( + "Live-session reset cannot run concurrently or reentrantly."); + + List? failures = null; + try + { + foreach (LiveSessionResetStage stage in _stages) + { + try + { + stage.Reset(); + } + catch (Exception error) + { + (failures ??= []).Add(new LiveSessionResetStageException( + stage.Name, + error)); + } + } + } + finally + { + Volatile.Write(ref _executing, 0); + } + + if (failures is not null) + throw new AggregateException( + "Live-session state did not converge; a new session must not start.", + failures); + } +} + +internal sealed class LiveSessionResetStageException( + string stageName, + Exception innerException) : Exception( + $"Live-session reset stage '{stageName}' failed.", + innerException) +{ + public string StageName { get; } = stageName; +} diff --git a/src/AcDream.App/Rendering/CameraController.cs b/src/AcDream.App/Rendering/CameraController.cs index c855a9a2..5961f358 100644 --- a/src/AcDream.App/Rendering/CameraController.cs +++ b/src/AcDream.App/Rendering/CameraController.cs @@ -66,10 +66,14 @@ public sealed class CameraController public void ExitChaseMode() { + bool wasChaseMode = IsChaseMode; Chase = null; RetailChase = null; + if (_mode == Mode.Orbit) + return; _mode = Mode.Fly; - ModeChanged?.Invoke(IsFlyMode); + if (wasChaseMode) + ModeChanged?.Invoke(IsFlyMode); } public void SetAspect(float aspect) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d7479c15..f6d9bcf7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -817,6 +817,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // an ICommandBus and becomes inert before the displaced socket closes. private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents; private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands; + private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan; // Phase G.1-G.2 world lighting/time state. public readonly AcDream.Core.World.WorldTimeService WorldTime = @@ -1785,6 +1786,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // writes under the chosen character's // name (or "default" pre-login). settingsStore.SaveCharacter(_activeToonKey, character); + if (string.Equals( + _activeToonKey, + "default", + StringComparison.OrdinalIgnoreCase)) + { + _persistedCharacter = character; + } Console.WriteLine( $"settings: character[{_activeToonKey}] saved to " + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); @@ -2946,23 +2954,125 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private void ClearInboundEntityState() { - EndMouseLookAndRestoreCursor(); - ResetTeleportTransitState(clearSession: true); - if (_playerController is not null) - _playerController.State = AcDream.App.Input.PlayerState.InWorld; + (_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(); + } - // Session-scoped origin readiness. A reconnect's first logical player - // CreateObject must initialize the render/streaming center anew; - // projection recovery inside one session never crosses this boundary. + private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() => + AcDream.App.Net.LiveSessionResetManifest.Create(new() + { + MouseCapture = ResetSessionMouseCapture, + PlayerPresentation = ResetSessionPlayerPresentation, + TeleportTransit = () => ResetTeleportTransitState(clearSession: true), + SessionDialogs = () => _retailUiRuntime?.ResetSessionDialogs(), + ChatCommandTargets = () => _retailChatVm?.ResetSessionTargets(), + SettingsCharacterContext = () => + _settingsVm?.LoadCharacterContext(_persistedCharacter), + EquippedChildren = () => _equippedChildRenderer?.Clear(), + ExternalContainer = () => _externalContainers.Reset(), + InteractionAndSelection = ResetSessionInteraction, + SelectionPresentation = () => _retailSelectionScene?.Reset(), + ObjectTable = Objects.Clear, + Spellbook = SpellBook.Clear, + MagicRuntime = () => _magicRuntime?.Reset(), + CombatAttack = () => _combatAttackController?.ResetSession(), + CombatState = Combat.Clear, + ItemMana = ItemMana.Clear, + LocalPlayer = LocalPlayer.Clear, + Friends = Friends.Clear, + Squelch = Squelch.Clear, + TurbineChat = TurbineChat.Reset, + ParticleVisibility = _particleVisibility.Reset, + InboundEventFifo = _inboundEntityEvents.Clear, + LiveLiveness = () => _liveEntityLiveness?.Clear(), + LiveRuntime = ResetSessionLiveRuntime, + SessionIdentity = ResetSessionIdentity, + RemoteTeleport = () => _remoteTeleportController?.Clear(), + NetworkEffects = () => _entityEffects?.ClearNetworkState(), + AnimationHookFrames = () => _animationHookFrames?.Clear(), + LivePresentation = () => _liveEntityPresentation?.Clear(), + RemoteMovementDiagnostics = _remoteLastMove.Clear, + PhysicsHostIndex = _physicsHosts.Clear, + }); + + private void ResetSessionMouseCapture() + { + bool wasActive = _mouseLook?.Active == true; + _mouseLook?.Release(); + if (wasActive || _mouseLookSavedCursorMode.HasValue) + RestoreCursorAfterMouseLook(); + } + + private void ResetSessionPlayerPresentation() + { + _playerModeAutoEntry?.Cancel(); + try + { + _cameraController?.ExitChaseMode(); + } + finally + { + _playerMode = false; + _playerController = null; + _playerHost = null; + _chaseCamera = null; + _retailChaseCamera = null; + _playerMouseDeltaX = 0f; + _rmbHeld = false; + _autoRunActive = false; + _chaseModeEverEntered = false; + _lastMovementTruthOutbound = null; + _lastLocalPlayerShadow = null; + _spawnClaimRangeMemo = null; + } + } + + private void ResetSessionIdentity() + { + AcDream.App.Net.LiveSessionEntityRuntimeReset.RequireConvergence(_liveEntities); + + // PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired + // components, and character option objects. CPlayerSystem::Begin + // @ 0x0055D410 resets player identity and session fields. The option + // default comes from PlayerModule::PlayerModule @ 0x005D51F0. + _playerServerGuid = 0u; + _vitalsVm?.SetLocalPlayerGuid(0u); + Chat.ResetSessionIdentity(); + AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u; + _activeToonKey = "default"; + _characterOptions1 = + AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; + _playerMotionTableId = null; + _lastSeenRunSkill = -1; + _lastSeenJumpSkill = -1; + _lastLivePlayerLandblockId = null; + _liveSpawnReceived = 0; + _liveSpawnHydrated = 0; + _liveDropReasonNoPos = 0; + _liveDropReasonNoSetup = 0; + _liveDropReasonSetupDatMissing = 0; + _liveDropReasonNoMeshRefs = 0; + _liveAnimRejectNoCycle = 0; + _liveAnimRejectFramerate = 0; + _liveAnimRejectSingleFrame = 0; + _liveAnimRejectPartFrames = 0; + Shortcuts = Array.Empty(); + DesiredComponents = Array.Empty<(uint Id, uint Amount)>(); + _paperdollDollDirty = true; + + // X/Y are ignored until the next logical player CreateObject claims a + // new origin. Keeping the last values avoids inventing a synthetic + // landblock while still forcing first-spawn initialization. _liveCenterKnown = false; + } - // Attachment projections own GL-backed world registrations, so tear - // them down before dropping the canonical GUID/timestamp snapshots. - _equippedChildRenderer?.Clear(); - _externalContainers.Reset(); - Objects.Clear(); - SpellBook.Clear(); - _magicRuntime?.Reset(); + private void ResetSessionLiveRuntime() + { + AcDream.App.Net.LiveSessionEntityRuntimeReset.ClearAndRequireConvergence( + _liveEntities); + } + + private void ResetSessionInteraction() + { if (_selectionInteractions is { } interactions) interactions.ResetSession(); else @@ -2970,36 +3080,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _itemInteractionController?.ResetSession(); _selection.Reset(); } - _retailSelectionScene?.Reset(); - _particleVisibility.Reset(); - try - { - _liveEntityLiveness?.Clear(); - _liveEntities?.Clear(); - } - finally - { - try - { - // A pending teleport is operational state outside the live - // record. Clear it independently even if a resource callback - // failed while the canonical runtime was draining. - _remoteTeleportController?.Clear(); - } - finally - { - try - { - // F754/F755 can precede CreateObject, so the mixed pending FIFO - // may contain owners with no LiveEntityRecord to tear down. - _entityEffects?.ClearNetworkState(); - } - finally - { - _animationHookFrames?.Clear(); - } - } - } } /// diff --git a/src/AcDream.App/UI/GameplayConfirmationController.cs b/src/AcDream.App/UI/GameplayConfirmationController.cs index c8b81136..b23686d0 100644 --- a/src/AcDream.App/UI/GameplayConfirmationController.cs +++ b/src/AcDream.App/UI/GameplayConfirmationController.cs @@ -66,6 +66,17 @@ public sealed class GameplayConfirmationController : IDisposable return _dialogs.CloseDialog(_dialogContext); } + /// + /// Forget any tuple left after the dialog factory has completed its normal + /// retail close/reset callbacks. + /// + public void ResetSession() + { + _dialogContext = 0u; + _serverType = 0u; + _serverContext = 0u; + } + public void Dispose() { if (_disposed) return; diff --git a/src/AcDream.App/UI/InteractionState.cs b/src/AcDream.App/UI/InteractionState.cs index 49254dd7..2c80651d 100644 --- a/src/AcDream.App/UI/InteractionState.cs +++ b/src/AcDream.App/UI/InteractionState.cs @@ -42,6 +42,31 @@ public sealed class InteractionState public bool Clear() => Set(InteractionMode.None); + /// + /// Publishes the session-reset edge even when the mode is already clear so + /// a retry can repair any observer that missed an earlier notification. + /// + public void ResetSession() + { + InteractionMode previous = Current; + Current = InteractionMode.None; + Action? listeners = Changed; + if (listeners is null) + return; + + var transition = new InteractionModeTransition(previous, InteractionMode.None); + List? failures = null; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(transition); } + catch (Exception error) { (failures ??= []).Add(error); } + } + if (failures is not null) + throw new AggregateException( + "One or more interaction-mode reset observers failed.", + failures); + } + private bool Set(InteractionMode mode) { if (Current == mode) diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 9f0c80fc..5d6c9bca 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -1009,7 +1009,14 @@ public sealed class ItemInteractionController : IDisposable }; private void OnInteractionModeChanged(InteractionModeTransition _) - => StateChanged?.Invoke(); + { + List failures = []; + DispatchAll(StateChanged, failures); + if (failures.Count != 0) + throw new AggregateException( + "One or more item-interaction observers failed.", + failures); + } private void OnInventoryObjectMoved(ClientObjectMove move) { @@ -1124,17 +1131,50 @@ public sealed class ItemInteractionController : IDisposable /// public void ResetSession() { - CancelPendingBackpackPlacement(); - bool busyChanged = _busyCount != 0 || _pendingInventoryRequest is not null; - bool modeChanged = IsAnyTargetModeActive; + PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement; + _pendingBackpackPlacement = null; _busyCount = 0; _pendingInventoryRequest = null; _lastUseMs = long.MinValue / 2; _consumedPrimaryClickTarget = 0u; _consumedPrimaryClickMs = long.MinValue / 2; - _interactionState.Clear(); - if (busyChanged && !modeChanged) - StateChanged?.Invoke(); + + List failures = []; + try { _interactionState.ResetSession(); } + catch (Exception error) { failures.Add(error); } + + if (pendingPlacement is { } pending) + DispatchAll(PendingBackpackPlacementCancelled, pending, failures); + + if (failures.Count != 0) + throw new AggregateException( + "One or more item-interaction reset observers failed.", + failures); + } + + private static void DispatchAll(Action? listeners, List failures) + { + if (listeners is null) + return; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(); } + catch (Exception error) { failures.Add(error); } + } + } + + private static void DispatchAll( + Action? listeners, + T value, + List failures) + { + if (listeners is null) + return; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(value); } + catch (Exception error) { failures.Add(error); } + } } private bool ConsumeUseThrottle() diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index 2311e278..f4c71c2f 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -26,6 +26,7 @@ public sealed class RetailDialogFactory : IDisposable private readonly Dictionary> _pending = new(); private readonly List _openOrder = new(); private uint _globalContext; + private bool _resetting; private bool _disposed; public RetailDialogFactory( @@ -192,18 +193,46 @@ public sealed class RetailDialogFactory : IDisposable /// public void Reset() { - DialogInfo[] infos = _activeNonQueued.Values - .Concat(_activeQueued.Values) - .Concat(_pending.Values.SelectMany(static queue => queue)) - .Distinct() - .ToArray(); + if (_resetting) + return; - _activeNonQueued.Clear(); - _activeQueued.Clear(); - _pending.Clear(); - foreach (DialogInfo info in infos) - DialogDone(info); - RefreshModal(); + _resetting = true; + List? failures = null; + try + { + while (true) + { + DialogInfo[] infos = _activeNonQueued.Values + .Concat(_activeQueued.Values) + .Concat(_pending.Values.SelectMany(static queue => queue)) + .Distinct() + .ToArray(); + if (infos.Length == 0) + break; + + // Callbacks may synchronously create another dialog. Retire + // this exact snapshot, then loop until those reentrant infos + // have also completed through DialogDone. + _activeNonQueued.Clear(); + _activeQueued.Clear(); + _pending.Clear(); + foreach (DialogInfo info in infos) + { + try { DialogDone(info); } + catch (Exception error) { (failures ??= []).Add(error); } + } + } + } + finally + { + _resetting = false; + RefreshModal(); + } + + if (failures is not null) + throw new AggregateException( + "One or more dialogs failed while the factory reset.", + failures); } public void Dispose() @@ -283,10 +312,10 @@ public sealed class RetailDialogFactory : IDisposable { if (info.View is { } view) { - info.View = null; view.DetachHandlers(); - _openOrder.Remove(info); _host.RemoveChild(view.Root); + info.View = null; + _openOrder.Remove(info); } RefreshModal(); } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 77075738..5d59a1cd 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -386,6 +386,25 @@ public sealed class RetailUiRuntime : IDisposable data => completed(data.GetBoolean(RetailDialogProperty.ConfirmationResult))) ?? 0u; } + /// + /// Close character-session dialogs through retail's normal callback and + /// close-notice path, then forget the gameplay confirmation tuple. + /// gmGamePlayUI::~gmGamePlayUI @ 0x004EA2A0 closes its retained + /// contexts before DialogFactory::Reset @ 0x00477950 drains the + /// remaining factory entries. + /// + public void ResetSessionDialogs() + { + try + { + DialogFactory?.Reset(); + } + finally + { + _gameplayConfirmationController?.ResetSession(); + } + } + public void UpdateCursor(IEnumerable mice) { CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root); diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index aeedcc31..6b5cbea8 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -62,14 +62,27 @@ public sealed class ChatLog /// /// Push the authoritative local-player GUID from WorldSession. - /// One-way setter — only GameWindow should call it, exactly - /// once per live session. Used by to + /// The host sets it after character selection and resets it at session + /// teardown. Used by to /// recognize ACE's HearSpeech echo of our own /say (server's /// HandleActionTalk broadcasts to all in range INCLUDING the /// sender) and re-render it as "You say, ...". /// public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid; + /// + /// Reset per-session speaker classification and duplicate suppression + /// while preserving the visible transcript. Retail has an explicit + /// ChatInterface::RecvNotice_ClearChatBuffer @ 0x004F2F60 path; + /// character-session teardown does not use that clear-buffer notice. + /// + public void ResetSessionIdentity() + { + _localPlayerGuid = 0u; + _lastSystemText = string.Empty; + _lastSystemAt = DateTime.MinValue; + } + // ── Inbound adapters ───────────────────────────────────────────────────── /// Local or ranged HearSpeech (0x02BB / 0x02BC). diff --git a/src/AcDream.Core/Chat/TurbineChatState.cs b/src/AcDream.Core/Chat/TurbineChatState.cs index f87818be..1b6ff08c 100644 --- a/src/AcDream.Core/Chat/TurbineChatState.cs +++ b/src/AcDream.Core/Chat/TurbineChatState.cs @@ -93,6 +93,33 @@ public sealed class TurbineChatState SocietyRadiantBloodRoom = societyRadiantBloodRoom; } + /// + /// Return the server-assigned room table and outbound context sequence to + /// their pre-login values. Room ids and cookies belong to one negotiated + /// character session and must never cross a reconnect boundary. + /// + /// + /// Retail CCommunicationSystem construction at 0x005566C0 + /// starts Turbine chat disabled; SetTurbineChatChannels (0x0295) supplies + /// rooms per character. Cookie 1 is the Holtburger protocol-reference + /// initial value, with zero reserved for no context. + /// + public void Reset() + { + Enabled = false; + AllegianceRoom = 0u; + GeneralRoom = 0u; + TradeRoom = 0u; + LfgRoom = 0u; + RoleplayRoom = 0u; + OlthoiRoom = 0u; + SocietyRoom = 0u; + SocietyCelestialHandRoom = 0u; + SocietyEldrytchWebRoom = 0u; + SocietyRadiantBloodRoom = 0u; + _nextContextId = 1u; + } + /// /// Look up the runtime room id for a Turbine /// . Returns 0 if the channel is not diff --git a/src/AcDream.Core/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs index 4a18a3ae..f1952f3f 100644 --- a/src/AcDream.Core/Combat/CombatState.cs +++ b/src/AcDream.Core/Combat/CombatState.cs @@ -178,6 +178,21 @@ public sealed class CombatState public void Clear() { _healthByGuid.Clear(); - SetCombatMode(CombatMode.NonCombat); + CurrentMode = CombatMode.NonCombat; + + Action? listeners = CombatModeChanged; + if (listeners is null) + return; + + List? failures = null; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(CombatMode.NonCombat); } + catch (Exception error) { (failures ??= []).Add(error); } + } + if (failures is not null) + throw new AggregateException( + "One or more combat reset observers failed.", + failures); } } diff --git a/src/AcDream.Core/Items/ExternalContainerState.cs b/src/AcDream.Core/Items/ExternalContainerState.cs index 72b033a9..d42eebc4 100644 --- a/src/AcDream.Core/Items/ExternalContainerState.cs +++ b/src/AcDream.Core/Items/ExternalContainerState.cs @@ -101,12 +101,24 @@ public sealed class ExternalContainerState bool changed = previous != 0u || RequestedContainerId != 0u; CurrentContainerId = 0u; RequestedContainerId = 0u; - if (changed) + + var transition = new ExternalContainerTransition( + ExternalContainerTransitionKind.Reset, + previous, + 0u); + Action? listeners = Changed; + if (listeners is not null) { - Changed?.Invoke(new ExternalContainerTransition( - ExternalContainerTransitionKind.Reset, - previous, - 0u)); + List? failures = null; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(transition); } + catch (Exception error) { (failures ??= []).Add(error); } + } + if (failures is not null) + throw new AggregateException( + "One or more external-container reset observers failed.", + failures); } return changed; } diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs index 961a7041..89fd8480 100644 --- a/src/AcDream.Core/Player/LocalPlayerState.cs +++ b/src/AcDream.Core/Player/LocalPlayerState.cs @@ -454,6 +454,39 @@ public sealed class LocalPlayerState return true; } + /// + /// Return the character snapshot to its pre-login state. The object itself + /// is process-lived because UI view models subscribe to it once; session + /// replacement clears its contents instead of replacing the owner. + /// + /// + /// Retail: CPlayerSystem::OnEndCharacterSession @ 0x00562870 + /// calls End @ 0x005606C0, which invokes + /// PlayerModule::Clear @ 0x005D48A0, then immediately calls + /// CPlayerSystem::Begin @ 0x0055D410. Re-publishing invalidation + /// events is acdream's process-lived-view adaptation. + /// + public void Clear() + { + _health = null; + _stamina = null; + _mana = null; + _attrs.Clear(); + _skills.Clear(); + _positions.Clear(); + _properties = new PropertyBundle(); + + // Process-lived views pull through this object and use these events as + // their invalidation edge. Publish every category even when Clear is + // repeated so a failed reset attempt can safely converge on retry. + Changed?.Invoke(VitalKind.Health); + Changed?.Invoke(VitalKind.Stamina); + Changed?.Invoke(VitalKind.Mana); + foreach (AttributeKind kind in Enum.GetValues()) + AttributeChanged?.Invoke(kind); + CharacterChanged?.Invoke(); + } + private static uint SaturatingAdd(uint value, uint delta) => uint.MaxValue - value < delta ? uint.MaxValue : value + delta; diff --git a/src/AcDream.Core/Selection/SelectionState.cs b/src/AcDream.Core/Selection/SelectionState.cs index 1bef0d44..22114ac2 100644 --- a/src/AcDream.Core/Selection/SelectionState.cs +++ b/src/AcDream.Core/Selection/SelectionState.cs @@ -78,20 +78,25 @@ public sealed class SelectionState : ISelectionService public bool Reset(SelectionChangeSource source = SelectionChangeSource.System) { uint? old = SelectedObjectId; + bool changed = old is not null + || PreviousObjectId is not null + || PreviousValidObjectId is not null; SelectedObjectId = null; PreviousObjectId = null; PreviousValidObjectId = null; - if (old is null) - return false; var transition = new SelectionTransition( old, null, source, SelectionChangeReason.SessionReset); - Changed?.Invoke(transition); + List? failures = DispatchChanged(transition); DispatchPluginChanged(new SelectionChangedEvent(old, null)); - return true; + if (failures is not null) + throw new AggregateException( + "One or more selection reset observers failed.", + failures); + return changed; } bool ISelectionService.Select(uint objectId) @@ -120,6 +125,21 @@ public sealed class SelectionState : ISelectionService return true; } + private List? DispatchChanged(SelectionTransition transition) + { + Action? listeners = Changed; + if (listeners is null) + return null; + + List? failures = null; + foreach (Action listener in listeners.GetInvocationList()) + { + try { listener(transition); } + catch (Exception error) { (failures ??= []).Add(error); } + } + return failures; + } + private void DispatchPluginChanged(SelectionChangedEvent change) { Action? listeners = PluginChanged; diff --git a/src/AcDream.Core/Social/SquelchState.cs b/src/AcDream.Core/Social/SquelchState.cs index 201a9ddd..de0b8aa8 100644 --- a/src/AcDream.Core/Social/SquelchState.cs +++ b/src/AcDream.Core/Social/SquelchState.cs @@ -16,6 +16,17 @@ public sealed class SquelchState ArgumentNullException.ThrowIfNull(database); lock (_gate) _database = database; } + + /// + /// Drop the server-owned squelch database for the old session. Retail + /// CPlayerSystem::LogOnCharacter @ 0x0055F890 calls + /// gmCCommunicationSystem::ClearSquelchDB @ 0x00589240 before + /// entering the next character. + /// + public void Clear() + { + lock (_gate) _database = SquelchDatabase.Empty; + } } public sealed record SquelchInfo( diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 4fa8593e..04fd3871 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -116,6 +116,17 @@ public sealed class ChatVM /// public void Clear() => _log.Clear(); + /// + /// Forget per-session reply/retell destinations while retaining the shared + /// transcript. Old character names must not become command targets after a + /// reconnect. + /// + public void ResetSessionTargets() + { + LastIncomingTellSender = null; + LastOutgoingTellTarget = null; + } + /// /// Print the current framerate into chat. Used by /// /framerate / @framerate. Falls back to a diff --git a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs index 5dd8433d..929fbb90 100644 --- a/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs @@ -55,8 +55,8 @@ public sealed class VitalsVM /// /// Push the authoritative local-player GUID from WorldSession. - /// One-way setter — only GameWindow should call it, exactly once - /// per live session. + /// The host assigns it after character selection and resets it to zero at + /// session teardown. /// public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid; diff --git a/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs b/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs index 8e02c90c..750a9827 100644 --- a/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs +++ b/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs @@ -215,6 +215,34 @@ public sealed class CombatAttackControllerTests } } + [Fact] + public void ResetSession_RestoresRetailBeginDefaultsWithoutSendingCancel() + { + double now = 1d; + int cancels = 0; + var combat = new CombatState(); + using var controller = new CombatAttackController( + combat, + canStartAttack: () => true, + sendAttack: (_, _) => true, + sendCancelAttack: () => cancels++, + now: () => now); + combat.SetCombatMode(CombatMode.Melee); + controller.SetDesiredPower(1f); + controller.PressAttack(AttackHeight.High); + now = 1.5d; + + controller.ResetSession(); + + Assert.False(controller.AttackRequestInProgress); + Assert.False(controller.BuildInProgress); + Assert.Equal(0f, controller.RequestedAttackPower); + Assert.Equal(0f, controller.PowerBarLevel); + Assert.Equal(AttackHeight.Medium, controller.RequestedHeight); + Assert.Equal(CombatAttackController.InitialDesiredPower, controller.DesiredPower); + Assert.Equal(0, cancels); + } + private static CombatAttackController Create( CombatState combat, Func now, diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs new file mode 100644 index 00000000..e55baf92 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs @@ -0,0 +1,387 @@ +using AcDream.App.Net; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Chat; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Player; +using AcDream.Core.Selection; +using AcDream.Core.Social; +using AcDream.Core.Spells; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Net; + +public sealed class LiveSessionResetPlanTests +{ + private sealed class FailingOnceResources : ILiveEntityResourceLifecycle + { + private bool _failurePending = true; + + public void Register(WorldEntity entity) { } + + public void Unregister(WorldEntity entity) + { + if (_failurePending) + { + _failurePending = false; + throw new InvalidOperationException("transient teardown"); + } + } + } + + [Fact] + public void Execute_EmptyPlanConverges() + { + var plan = new LiveSessionResetPlan([]); + + plan.Execute(); + plan.Execute(); + + Assert.Empty(plan.StageNames); + } + + [Fact] + public void Execute_AttemptsEveryStageAndAggregatesNamedFailures() + { + var calls = new List(); + var plan = new LiveSessionResetPlan( + [ + new("first", () => + { + calls.Add("first"); + throw new InvalidOperationException("one"); + }), + new("second", () => calls.Add("second")), + new("third", () => + { + calls.Add("third"); + throw new ArgumentException("three"); + }), + ]); + + AggregateException error = Assert.Throws(plan.Execute); + + Assert.Equal(["first", "second", "third"], calls); + Assert.Collection( + error.InnerExceptions, + first => Assert.Equal( + "first", + Assert.IsType(first).StageName), + third => Assert.Equal( + "third", + Assert.IsType(third).StageName)); + } + + [Fact] + public void Execute_AfterFailedAttemptCanConvergeWithoutSkippingStages() + { + int firstCalls = 0; + int secondCalls = 0; + var plan = new LiveSessionResetPlan( + [ + new("transient", () => + { + firstCalls++; + if (firstCalls == 1) + throw new InvalidOperationException("transient"); + }), + new("always", () => secondCalls++), + ]); + + Assert.Throws(plan.Execute); + plan.Execute(); + + Assert.Equal(2, firstCalls); + Assert.Equal(2, secondCalls); + } + + [Fact] + public void Execute_ReentrantAttemptIsReportedButLaterStagesStillRun() + { + LiveSessionResetPlan? plan = null; + bool tailRan = false; + plan = new LiveSessionResetPlan( + [ + new("reentrant", () => plan!.Execute()), + new("tail", () => tailRan = true), + ]); + + AggregateException error = Assert.Throws(plan.Execute); + + Assert.True(tailRan); + LiveSessionResetStageException stage = Assert.IsType( + Assert.Single(error.InnerExceptions)); + Assert.Equal("reentrant", stage.StageName); + Assert.IsType(stage.InnerException); + } + + [Fact] + public void Constructor_RejectsDuplicateStageNames() + { + Assert.Throws(() => new LiveSessionResetPlan( + [ + new("duplicate", () => { }), + new("duplicate", () => { }), + ])); + } + + [Fact] + public void Manifest_MutatedA_FailedDrain_RetryConvergesWithoutClearingRetainedWorld() + { + const uint playerA = 0x50000001u; + const uint objectA = 0x70000001u; + const uint landblock = 0x0101FFFFu; + var staticEntity = Entity(42u, 0u); + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + new List { staticEntity })); + var live = new LiveEntityRuntime(spatial, new FailingOnceResources()); + WorldSession.EntitySpawn spawn = Spawn(playerA, 1, 1, 0x01010001u); + live.RegisterLiveEntity(spawn); + live.MaterializeLiveEntity( + playerA, + 0x01010001u, + id => Entity(id, playerA)); + spatial.MarkPersistent(playerA); + + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject { ObjectId = objectA, Name = "A" }); + var spells = new Spellbook(); + spells.OnSpellLearned(123u, 1f); + var combat = new CombatState(); + combat.OnUpdateHealth(objectA, 0.5f); + combat.SetCombatMode(CombatMode.Melee); + var mana = new ItemManaState(); + mana.OnQueryItemManaResponse(objectA, 0.5f, valid: true); + var localPlayer = new LocalPlayerState(spells); + localPlayer.OnVitalUpdate(7u, 1u, 100u, 5u, 80u); + var friends = new FriendsState(); + friends.Apply(new FriendsUpdate( + FriendsUpdateType.Full, + [new FriendEntry(objectA, "A", true, false, [], [])])); + var squelch = new SquelchState(); + squelch.Replace(new SquelchDatabase( + new Dictionary { ["A"] = 1u }, + new Dictionary(), + new SquelchInfo("A", false, new HashSet()))); + var turbine = new TurbineChatState(); + turbine.OnChannelsReceived(1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u); + var selection = new SelectionState(); + selection.Select(objectA, SelectionChangeSource.World); + var external = new ExternalContainerState(); + external.RequestOpen(objectA); + external.ApplyViewContents(objectA); + var chat = new ChatLog(); + chat.SetLocalPlayerGuid(playerA); + chat.OnSystemMessage("retained transcript", 1u); + + uint sessionIdentity = playerA; + int savedDefaultSetting = 73; + int activeSetting = savedDefaultSetting; + var calls = new Dictionary(StringComparer.Ordinal); + var dirty = ExpectedManifestNames().ToDictionary( + static name => name, + static _ => true, + StringComparer.Ordinal); + + Action Stage(string name, Action? reset = null) => () => + { + calls[name] = calls.GetValueOrDefault(name) + 1; + reset?.Invoke(); + dirty[name] = false; + }; + + LiveSessionResetPlan plan = LiveSessionResetManifest.Create(new() + { + MouseCapture = Stage("mouse capture"), + PlayerPresentation = Stage("player presentation"), + TeleportTransit = Stage("teleport transit"), + SessionDialogs = Stage("session dialogs"), + ChatCommandTargets = Stage("chat command targets"), + SettingsCharacterContext = Stage( + "settings character context", + () => activeSetting = savedDefaultSetting), + EquippedChildren = Stage("equipped children"), + ExternalContainer = Stage("external container", () => external.Reset()), + InteractionAndSelection = Stage( + "interaction and selection", + () => selection.Reset()), + SelectionPresentation = Stage("selection presentation"), + ObjectTable = Stage("object table", objects.Clear), + Spellbook = Stage("spellbook", spells.Clear), + MagicRuntime = Stage("magic runtime"), + CombatAttack = Stage("combat attack"), + CombatState = Stage("combat state", combat.Clear), + ItemMana = Stage("item mana", mana.Clear), + LocalPlayer = Stage("local player", localPlayer.Clear), + Friends = Stage("friends", friends.Clear), + Squelch = Stage("squelch", squelch.Clear), + TurbineChat = Stage("turbine chat", turbine.Reset), + ParticleVisibility = Stage("particle visibility"), + InboundEventFifo = Stage("inbound event fifo"), + LiveLiveness = Stage("live liveness"), + LiveRuntime = Stage( + "live runtime", + () => LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(live)), + SessionIdentity = Stage("session identity", () => + { + LiveSessionEntityRuntimeReset.RequireConvergence(live); + sessionIdentity = 0u; + chat.ResetSessionIdentity(); + }), + RemoteTeleport = Stage("remote teleport"), + NetworkEffects = Stage("network effects"), + AnimationHookFrames = Stage("animation hook frames"), + LivePresentation = Stage("live presentation"), + RemoteMovementDiagnostics = Stage("remote movement diagnostics"), + PhysicsHostIndex = Stage("physics host index"), + }); + + Assert.Equal(ExpectedManifestNames(), plan.StageNames); + AggregateException first = Assert.Throws(plan.Execute); + + Assert.Equal( + ["live runtime", "session identity"], + first.InnerExceptions + .Cast() + .Select(static error => error.StageName)); + Assert.Equal(playerA, sessionIdentity); + Assert.Equal(1, live.PendingTeardownCount); + Assert.False(dirty["physics host index"]); + Assert.Contains(staticEntity, spatial.Entities); + Assert.Equal(1, spatial.PersistentGuidCount); + + plan.Execute(); + + Assert.All(ExpectedManifestNames(), name => Assert.Equal(2, calls[name])); + Assert.DoesNotContain(dirty, static pair => pair.Value); + Assert.Equal(0u, sessionIdentity); + Assert.Equal(savedDefaultSetting, activeSetting); + Assert.Equal(0, live.Count); + Assert.Equal(0, live.PendingTeardownCount); + Assert.Equal(0, live.MaterializedCount); + Assert.Equal(0, spatial.PersistentGuidCount); + Assert.Contains(staticEntity, spatial.Entities); + Assert.True(spatial.IsLoaded(landblock)); + Assert.Null(objects.Get(objectA)); + Assert.Equal(0, spells.LearnedCount); + Assert.Equal(CombatMode.NonCombat, combat.CurrentMode); + Assert.False(combat.HasHealth(objectA)); + Assert.False(mana.HasMana(objectA)); + Assert.Null(localPlayer.Get(LocalPlayerState.VitalKind.Health)); + Assert.Empty(friends.Snapshot()); + Assert.Same(SquelchDatabase.Empty, squelch.Snapshot()); + Assert.False(turbine.Enabled); + Assert.Null(selection.SelectedObjectId); + Assert.Equal(0u, external.CurrentContainerId); + Assert.Equal(1, chat.Count); + chat.OnLocalSpeech("A", "after", playerA, isRanged: false); + Assert.Equal("A", chat.Snapshot()[1].Sender); + } + + private static string[] ExpectedManifestNames() => + [ + "mouse capture", + "player presentation", + "teleport transit", + "session dialogs", + "chat command targets", + "settings character context", + "equipped children", + "external container", + "interaction and selection", + "selection presentation", + "object table", + "spellbook", + "magic runtime", + "combat attack", + "combat state", + "item mana", + "local player", + "friends", + "squelch", + "turbine chat", + "particle visibility", + "inbound event fifo", + "live liveness", + "live runtime", + "session identity", + "remote teleport", + "network effects", + "animation hook frames", + "live presentation", + "remote movement diagnostics", + "physics host index", + ]; + + private static WorldEntity Entity(uint id, uint guid) => new() + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort instance, + ushort positionSequence, + uint cell) + { + const PhysicsStateFlags state = PhysicsStateFlags.ReportCollisions; + var position = new CreateObject.ServerPosition( + cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + positionSequence, 1, 1, 1, 0, 1, 0, 1, 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, + "fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)state, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: positionSequence, + Physics: physics); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs index 7bddfe52..124a5375 100644 --- a/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs @@ -92,4 +92,33 @@ public class CameraControllerTests CameraDiagnostics.UseRetailChaseCamera = saved; } } + + [Fact] + public void ExitChaseMode_AlreadyOfflinePreservesOrbitCamera() + { + var orbit = new OrbitCamera(); + var ctl = new CameraController(orbit, new FlyCamera()); + + ctl.ExitChaseMode(); + + Assert.Same(orbit, ctl.Active); + Assert.False(ctl.IsFlyMode); + Assert.False(ctl.IsChaseMode); + } + + [Fact] + public void ExitChaseMode_AfterChaseToFlyReleasesRetainedSessionCameras() + { + var (ctl, _, _) = MakeChaseFixture(); + ctl.ToggleFly(); + Assert.True(ctl.IsFlyMode); + Assert.NotNull(ctl.Chase); + Assert.NotNull(ctl.RetailChase); + + ctl.ExitChaseMode(); + + Assert.True(ctl.IsFlyMode); + Assert.Null(ctl.Chase); + Assert.Null(ctl.RetailChase); + } } diff --git a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs index 98da961c..f45ce004 100644 --- a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs @@ -55,4 +55,25 @@ public sealed class GameplayConfirmationControllerTests Assert.Equal([(7u, 99u, false)], responses); } + [Fact] + public void FactoryReset_CompletesResponseBeforeSessionTupleIsForgotten() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var factory = new RetailDialogFactory(root, _ => + FixtureLoader.LoadConfirmationDialog()); + var responses = new List<(uint Type, uint Context, bool Accepted)>(); + using var controller = new GameplayConfirmationController( + factory, + (type, context, accepted) => responses.Add((type, context, accepted))); + controller.HandleRequest( + new GameEvents.CharacterConfirmationRequest(7u, 99u, "Proceed?")); + + factory.Reset(); + controller.ResetSession(); + + Assert.Equal(0u, controller.ActiveDialogContext); + Assert.Equal([(7u, 99u, false)], responses); + Assert.False(factory.IsOpen); + } + } diff --git a/tests/AcDream.App.Tests/UI/InteractionStateTests.cs b/tests/AcDream.App.Tests/UI/InteractionStateTests.cs index c60f1e75..2fdeb2dd 100644 --- a/tests/AcDream.App.Tests/UI/InteractionStateTests.cs +++ b/tests/AcDream.App.Tests/UI/InteractionStateTests.cs @@ -29,4 +29,31 @@ public sealed class InteractionStateTests Assert.Throws(() => state.EnterUseItemOnTarget(0)); Assert.Equal(InteractionMode.None, state.Current); } + + [Fact] + public void ResetSession_RetryRepublishesAndOneObserverCannotStarveAnother() + { + var state = new InteractionState(); + state.EnterExamine(); + bool fail = true; + int delivered = 0; + state.Changed += _ => + { + if (fail) + { + fail = false; + throw new InvalidOperationException("transient"); + } + }; + state.Changed += transition => + { + Assert.Equal(InteractionMode.None, transition.Current); + delivered++; + }; + + Assert.Throws(state.ResetSession); + Assert.Equal(1, delivered); + state.ResetSession(); + Assert.Equal(2, delivered); + } } diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 36b8275e..404e75c5 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -122,6 +122,33 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.UseWithTarget); } + [Fact] + public void ResetSession_RetryNotifiesEveryStateObserver() + { + var h = new Harness(); + h.Controller.InteractionState.EnterExamine(); + h.Controller.IncrementBusyCount(); + bool fail = true; + int delivered = 0; + h.Controller.StateChanged += () => + { + if (fail) + { + fail = false; + throw new InvalidOperationException("transient"); + } + }; + h.Controller.StateChanged += () => delivered++; + + Assert.Throws(h.Controller.ResetSession); + Assert.Equal(1, delivered); + Assert.Equal(0, h.Controller.BusyCount); + Assert.Equal(InteractionMode.None, h.Controller.InteractionState.Current); + + h.Controller.ResetSession(); + Assert.Equal(2, delivered); + } + [Fact] public void ExternalContainerUse_RequestsGroundObjectAndSendsUse() { diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs index fecb6e43..8ae2d1d5 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -187,6 +187,74 @@ public sealed class RetailDialogFactoryTests Assert.Null(root.Modal); } + [Fact] + public void Reset_CompletesEveryDialogAndKeepsContextSequenceMonotonic() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + int callbacks = 0; + int notices = 0; + factory.DialogClosed += (_, _) => notices++; + + uint first = factory.MakeConfirmation("active", _ => callbacks++); + factory.MakeConfirmation("pending", _ => callbacks++); + factory.Reset(); + + Assert.Equal(1u, first); + Assert.Equal(2, callbacks); + Assert.Equal(2, notices); + Assert.False(factory.IsOpen); + Assert.Equal(0, factory.PendingCount); + Assert.Null(root.Modal); + Assert.Empty(root.Children); + Assert.Equal(3u, factory.MakeConfirmation("new session")); + } + + [Fact] + public void Reset_AttemptsEveryDialogWhenOneCallbackThrows() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + int completed = 0; + + factory.MakeConfirmation("active", _ => throw new InvalidOperationException("boom")); + factory.MakeConfirmation("pending", _ => completed++); + + AggregateException error = Assert.Throws(factory.Reset); + + Assert.Contains("boom", error.ToString()); + Assert.Equal(1, completed); + Assert.False(factory.IsOpen); + Assert.Equal(0, factory.PendingCount); + Assert.Null(root.Modal); + Assert.Empty(root.Children); + factory.Reset(); + } + + [Fact] + public void Reset_DrainsDialogCreatedReentrantlyByCompletionCallback() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = CreateFactory(root, layouts); + int completed = 0; + factory.MakeConfirmation("first", _ => + { + completed++; + factory.MakeConfirmation("reentrant", _ => completed++); + }); + + factory.Reset(); + + Assert.Equal(2, completed); + Assert.False(factory.IsOpen); + Assert.Equal(0, factory.PendingCount); + Assert.Null(root.Modal); + Assert.Empty(root.Children); + } + private static RetailDialogFactory CreateFactory( UiRoot root, List layouts) diff --git a/tests/AcDream.Core.Tests/Chat/ChatLogLocalGuidTests.cs b/tests/AcDream.Core.Tests/Chat/ChatLogLocalGuidTests.cs index bd280642..2c5dd3f4 100644 --- a/tests/AcDream.Core.Tests/Chat/ChatLogLocalGuidTests.cs +++ b/tests/AcDream.Core.Tests/Chat/ChatLogLocalGuidTests.cs @@ -53,4 +53,27 @@ public sealed class ChatLogLocalGuidTests Assert.Equal("You", log.Snapshot()[0].Sender); Assert.Equal(ChatKind.RangedSpeech, log.Snapshot()[0].Kind); } + + [Fact] + public void ResetSessionIdentity_RetainsTranscriptButClearsGuidAndDedupeWindow() + { + var log = new ChatLog(); + const uint oldGuid = 0x50000001u; + const uint newGuid = 0x50000002u; + log.SetLocalPlayerGuid(oldGuid); + log.OnSystemMessage("session boundary", 1u); + log.OnLocalSpeech("Old", "before", oldGuid, isRanged: false); + + log.ResetSessionIdentity(); + log.OnSystemMessage("session boundary", 1u); + log.OnLocalSpeech("Old", "after", oldGuid, isRanged: false); + log.SetLocalPlayerGuid(newGuid); + log.OnLocalSpeech("New", "new", newGuid, isRanged: false); + + ChatEntry[] entries = log.Snapshot(); + Assert.Equal(5, entries.Length); + Assert.Equal("You", entries[1].Sender); + Assert.Equal("Old", entries[3].Sender); + Assert.Equal("You", entries[4].Sender); + } } diff --git a/tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs b/tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs index d3c77036..eb9b3bb4 100644 --- a/tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs +++ b/tests/AcDream.Core.Tests/Chat/TurbineChatStateTests.cs @@ -8,6 +8,32 @@ namespace AcDream.Core.Tests.Chat; /// public sealed class TurbineChatStateTests { + [Fact] + public void Reset_ClearsRoomsAndRestartsSessionCookieAtOne() + { + var state = new TurbineChatState(); + state.OnChannelsReceived(1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u); + Assert.Equal(1u, state.NextContextId()); + Assert.Equal(2u, state.NextContextId()); + + state.Reset(); + + Assert.False(state.Enabled); + Assert.Equal(0u, state.AllegianceRoom); + Assert.Equal(0u, state.GeneralRoom); + Assert.Equal(0u, state.TradeRoom); + Assert.Equal(0u, state.LfgRoom); + Assert.Equal(0u, state.RoleplayRoom); + Assert.Equal(0u, state.OlthoiRoom); + Assert.Equal(0u, state.SocietyRoom); + Assert.Equal(0u, state.SocietyCelestialHandRoom); + Assert.Equal(0u, state.SocietyEldrytchWebRoom); + Assert.Equal(0u, state.SocietyRadiantBloodRoom); + Assert.Equal(1u, state.NextContextId()); + state.Reset(); + Assert.Equal(1u, state.NextContextId()); + } + [Fact] public void InitialState_DisabledAndZeroRooms_NextContextIdStartsAt1() { diff --git a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs index 0a0ad587..c0542b60 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs @@ -141,4 +141,31 @@ public sealed class CombatStateTests Assert.Equal(0, state.TrackedTargetCount); Assert.Equal(CombatMode.NonCombat, state.CurrentMode); } + + [Fact] + public void Clear_RetryRepublishesAndOneObserverCannotStarveAnother() + { + var state = new CombatState(); + state.SetCombatMode(CombatMode.Melee); + bool fail = true; + int delivered = 0; + state.CombatModeChanged += _ => + { + if (fail) + { + fail = false; + throw new InvalidOperationException("transient"); + } + }; + state.CombatModeChanged += mode => + { + Assert.Equal(CombatMode.NonCombat, mode); + delivered++; + }; + + Assert.Throws(state.Clear); + Assert.Equal(1, delivered); + state.Clear(); + Assert.Equal(2, delivered); + } } diff --git a/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs b/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs index 1fb7cdfb..b3bc83e6 100644 --- a/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs +++ b/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs @@ -68,6 +68,32 @@ public sealed class ExternalContainerStateTests Assert.False(state.ApplyViewContents(2u)); } + [Fact] + public void Reset_RetryRepublishesAndOneObserverCannotStarveAnother() + { + var state = Open(1u); + bool fail = true; + int delivered = 0; + state.Changed += _ => + { + if (fail) + { + fail = false; + throw new InvalidOperationException("transient"); + } + }; + state.Changed += transition => + { + Assert.Equal(ExternalContainerTransitionKind.Reset, transition.Kind); + delivered++; + }; + + Assert.Throws(() => state.Reset()); + Assert.Equal(1, delivered); + Assert.False(state.Reset()); + Assert.Equal(2, delivered); + } + private static ExternalContainerState Open(uint id) { var state = new ExternalContainerState(); diff --git a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs index 66cb4efb..82d22ca6 100644 --- a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs +++ b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs @@ -436,4 +436,45 @@ public sealed class LocalPlayerStateTests s.OnProperties(props); Assert.False(s.DebitInt64Property(2u, 100L)); // already 0 — no negative banking } + + [Fact] + public void Clear_ReturnsEveryCharacterSnapshotToPreLoginState() + { + var s = new LocalPlayerState(); + var properties = new PropertyBundle(); + properties.Ints[1u] = 42; + s.OnVitalUpdate(7u, 1u, 2u, 3u, 4u); + s.OnAttributeUpdate(1u, 5u, 6u, 7u); + s.OnSkillUpdate(8u, 9u, 2u, 10u, 11u, 12u, 13d, 14u); + s.OnPositions(new Dictionary + { + [1u] = new AcDream.Core.Physics.Position( + 0xA9B40001u, + new System.Numerics.Vector3(1f, 2f, 3f), + System.Numerics.Quaternion.Identity), + }); + s.OnProperties(properties); + int characterChanges = 0; + var vitalChanges = new List(); + var attributeChanges = new List(); + s.CharacterChanged += () => characterChanges++; + s.Changed += vitalChanges.Add; + s.AttributeChanged += attributeChanges.Add; + + s.Clear(); + + Assert.Null(s.Get(LocalPlayerState.VitalKind.Health)); + Assert.Null(s.GetAttribute(LocalPlayerState.AttributeKind.Strength)); + Assert.Empty(s.Skills); + Assert.Empty(s.Positions); + Assert.Empty(s.Properties.Ints); + Assert.Equal(Enum.GetValues(), vitalChanges); + Assert.Equal(Enum.GetValues(), attributeChanges); + Assert.Equal(1, characterChanges); + + s.Clear(); + Assert.Null(s.Get(LocalPlayerState.VitalKind.Health)); + Assert.Empty(s.Skills); + Assert.Empty(s.Properties.Ints); + } } diff --git a/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs b/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs index be4b1a24..5210e6be 100644 --- a/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs +++ b/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs @@ -24,6 +24,33 @@ public sealed class SelectionStateTests Assert.False(state.SelectPrevious()); } + [Fact] + public void Reset_RetryRepublishesAndOneObserverCannotStarveAnother() + { + var state = new SelectionState(); + state.Select(0x50000001u, SelectionChangeSource.World); + bool fail = true; + int delivered = 0; + state.Changed += _ => + { + if (fail) + { + fail = false; + throw new InvalidOperationException("transient"); + } + }; + state.Changed += transition => + { + Assert.Equal(SelectionChangeReason.SessionReset, transition.Reason); + delivered++; + }; + + Assert.Throws(() => state.Reset()); + Assert.Equal(1, delivered); + Assert.False(state.Reset()); + Assert.Equal(2, delivered); + } + [Fact] public void Select_CommitsOneTransitionAndTracksPreviousLikeRetail() { diff --git a/tests/AcDream.Core.Tests/Social/SocialStateTests.cs b/tests/AcDream.Core.Tests/Social/SocialStateTests.cs index ef4feb11..a97a0382 100644 --- a/tests/AcDream.Core.Tests/Social/SocialStateTests.cs +++ b/tests/AcDream.Core.Tests/Social/SocialStateTests.cs @@ -4,6 +4,30 @@ namespace AcDream.Core.Tests.Social; public sealed class SocialStateTests { + [Fact] + public void SquelchState_Clear_ReturnsEmptyDatabase() + { + var state = new SquelchState(); + state.Replace(new SquelchDatabase( + new Dictionary { ["Account"] = 1u }, + new Dictionary + { + [2u] = new("Character", false, new HashSet { 3u }), + }, + new SquelchInfo("Global", true, new HashSet { 4u }))); + + state.Clear(); + + SquelchDatabase snapshot = state.Snapshot(); + Assert.Empty(snapshot.Accounts); + Assert.Empty(snapshot.Characters); + Assert.Empty(snapshot.Global.MessageTypes); + Assert.Equal(string.Empty, snapshot.Global.Name); + Assert.False(snapshot.Global.AccountWide); + state.Clear(); + Assert.Empty(state.Snapshot().Accounts); + } + [Fact] public void FriendsState_AppliesRetailIncrementalUpdateKindsByObjectId() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs index a29cca19..81a9510f 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs @@ -13,6 +13,23 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat; /// public sealed class ChatVMRetellAndProvidersTests { + [Fact] + public void ResetSessionTargets_ClearsReplyAndRetellWithoutClearingTranscript() + { + var log = new ChatLog(); + var vm = new ChatVM(log); + log.OnTellReceived("Bestie", "incoming", 0x50000001u); + log.OnSelfSent(ChatKind.Tell, "outgoing", targetOrChannel: "Caith"); + Assert.NotNull(vm.LastIncomingTellSender); + Assert.NotNull(vm.LastOutgoingTellTarget); + + vm.ResetSessionTargets(); + + Assert.Null(vm.LastIncomingTellSender); + Assert.Null(vm.LastOutgoingTellTarget); + Assert.Equal(2, log.Count); + } + [Fact] public void LastOutgoingTellTarget_StartsNull() { From 23b43d1859469c461f498edd57864bd222b411af Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 12:01:12 +0200 Subject: [PATCH 32/98] docs(plan): record convergent session reset checkpoint --- docs/plans/2026-07-21-gamewindow-slice-3-live-session.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md index 5e2689b0..96229955 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md +++ b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md @@ -29,8 +29,13 @@ character-selection UI or redesign the transport. concurrent and reentrant disposal serialization, and delayed-dialog indirection that makes displaced transports both inert and collectible. Three-agent review clean. Release: 6,636 passed / 5 skipped. -- [ ] C — convergent session reset (in progress). -- [ ] D — complete lifecycle controller. +- [x] C — convergent session reset (`4f31a508`): fixed ordered manifest, + all-attempt named transaction, retry-observable owner resets, canonical + live-runtime convergence/identity gate, retained-world preservation, + retail dialog completion semantics, and actual-owner A → failed drain → + retry → clean-state coverage. Three-agent review clean. Release: 6,659 + passed / 5 skipped. +- [ ] D — complete lifecycle controller (in progress). - [ ] E — GameWindow cutover. - [ ] F — final review, connected gate, and documentation closeout. From d9ccf8a6b94cd0691aa3900e853c9d4ea7169026 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 12:20:55 +0200 Subject: [PATCH 33/98] refactor(net): own complete live session lifecycle --- src/AcDream.App/Net/LiveSessionController.cs | 752 +++++++++++++-- src/AcDream.Core.Net/AcDream.Core.Net.csproj | 1 + src/AcDream.Core.Net/WorldSession.cs | 51 +- .../Net/LiveSessionControllerTests.cs | 864 ++++++++++++++++++ .../WorldSessionConstructionTests.cs | 41 + 5 files changed, 1618 insertions(+), 91 deletions(-) create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionConstructionTests.cs diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs index eb714f05..9204f8b4 100644 --- a/src/AcDream.App/Net/LiveSessionController.cs +++ b/src/AcDream.App/Net/LiveSessionController.cs @@ -1,114 +1,688 @@ -using System; using System.Net; using System.Net.Sockets; using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.UI.Abstractions; namespace AcDream.App.Net; -/// -/// Owns the network-side lifecycle of a live — -/// DNS resolution, endpoint construction, session instantiation, per-frame -/// Tick, and disposal. The post-construction work (event wiring, -/// Connect, character validation, EnterWorld, post-login UI -/// state setup) stays in GameWindow for now because it touches -/// renderer / chat / player-controller state that hasn't been extracted yet. -/// -/// -/// -/// Step 2 of the extraction sequence described in -/// docs/architecture/code-structure.md §4. Future expansions can -/// fold more of TryStartLiveSession into this controller as the -/// surrounding state (event handlers, command bus, settings VM) gets -/// extracted in later steps. -/// -/// -/// Behavior preservation contract: this class produces -/// byte-identical console output and event-wireup sequencing to the -/// pre-refactor inline code path. The DNS-resolution lines, the -/// "live: connecting to ..." line, and the wiring-vs-Connect ordering -/// all match the previous flow. -/// -/// -public sealed class LiveSessionController : IDisposable +internal enum LiveSessionStartStatus { - /// - /// Active session, or when offline / before - /// succeeded / after . - /// - public WorldSession? Session { get; private set; } + Disabled, + MissingCredentials, + NoCharacters, + Connected, + Deferred, + Failed, +} - /// - /// Resolves the endpoint, instantiates the , - /// hands it to for caller-side event - /// subscriptions, and returns the live session. The caller is - /// responsible for the subsequent Connect / - /// EnterWorld dance. - /// - public WorldSession? CreateAndWire(RuntimeOptions options, Action wireEvents) +internal sealed record LiveSessionCharacterSelection( + int ActiveIndex, + uint CharacterId, + string CharacterName, + string AccountName); + +internal sealed record LiveSessionStartResult( + LiveSessionStartStatus Status, + LiveSessionCharacterSelection? Selection = null, + Exception? Error = null); + +/// +/// Narrow App composition boundary for one exact WorldSession generation. +/// The host owns domain sinks and presentation state; the controller owns the +/// connect/enter/stop transaction and never reaches into GameWindow state. +/// +internal interface ILiveSessionLifecycleHost +{ + LiveSessionBinding BindSession(WorldSession session); + void ResetSessionState(); + void ReportConnecting(string host, int port, string user); + void ReportConnected(); + void ApplySelectedCharacter(LiveSessionCharacterSelection selection); + void ApplyEnteredWorld(LiveSessionCharacterSelection selection); + void DetachSession(WorldSession session); +} + +/// +/// Owns routing and commands for one exact session. Teardown is ordered and +/// retryable: outbound commands become inert before inbound subscriptions are +/// detached. +/// +internal sealed class LiveSessionBinding : IDisposable +{ + private readonly Action _activateCommands; + private readonly Action _deactivateCommands; + private readonly Action _detachEvents; + private bool _commandsDeactivated; + private bool _eventsDetached; + private bool _commandsActivated; + + public LiveSessionBinding( + WorldSession session, + ICommandBus commands, + Action activateCommands, + Action deactivateCommands, + Action detachEvents) { - if (options is null) throw new ArgumentNullException(nameof(options)); - if (wireEvents is null) throw new ArgumentNullException(nameof(wireEvents)); - - if (!options.LiveMode) return null; - - if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass)) - { - Console.WriteLine("live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"); - return null; - } - - try - { - var endpoint = ResolveEndpoint(options.LiveHost, options.LivePort); - Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}"); - Session = new WorldSession(endpoint); - wireEvents(Session); - return Session; - } - catch (Exception ex) - { - Console.WriteLine($"live: session setup failed: {ex.Message}"); - Session?.Dispose(); - Session = null; - return null; - } + Session = session ?? throw new ArgumentNullException(nameof(session)); + Commands = commands ?? throw new ArgumentNullException(nameof(commands)); + _activateCommands = activateCommands ?? throw new ArgumentNullException(nameof(activateCommands)); + _deactivateCommands = deactivateCommands ?? throw new ArgumentNullException(nameof(deactivateCommands)); + _detachEvents = detachEvents ?? throw new ArgumentNullException(nameof(detachEvents)); } - /// - /// Drains the inbound network queue. Proxies to - /// ; no-op when - /// is . - /// - public void Tick() => Session?.Tick(); + public WorldSession Session { get; } + public ICommandBus Commands { get; } + + public void ActivateCommands() + { + if (_commandsDeactivated || _eventsDetached) + throw new ObjectDisposedException(nameof(LiveSessionBinding)); + if (_commandsActivated) + return; + _activateCommands(); + if (_commandsDeactivated || _eventsDetached) + return; + _commandsActivated = true; + } - /// - /// Tears down the live session. Safe to call multiple times. - /// public void Dispose() { - Session?.Dispose(); - Session = null; + if (!_commandsDeactivated) + { + _deactivateCommands(); + _commandsDeactivated = true; + } + if (!_eventsDetached) + { + _detachEvents(); + _eventsDetached = true; + } } +} - /// - /// Resolve a host string (literal IP or DNS name) to an - /// . Pre-refactor logic preserved exactly: - /// try first, fall back to - /// , prefer IPv4 (ACE + retail use - /// IPv4 UDP exclusively), throw on empty resolution. - /// - private static IPEndPoint ResolveEndpoint(string host, int port) +internal interface ILiveSessionOperations +{ + IPEndPoint ResolveEndpoint(string host, int port); + WorldSession CreateSession(IPEndPoint endpoint); + void Connect(WorldSession session, string user, string password); + CharacterList.Parsed? GetCharacters(WorldSession session); + void EnterWorld(WorldSession session, int activeCharacterIndex); + void Tick(WorldSession session); + void DisposeSession(WorldSession session); +} + +internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations +{ + public static ProductionLiveSessionOperations Instance { get; } = new(); + + private ProductionLiveSessionOperations() { } + + public IPEndPoint ResolveEndpoint(string host, int port) { IPAddress ip; if (!IPAddress.TryParse(host, out ip!)) { - var addrs = Dns.GetHostAddresses(host); - ip = Array.Find(addrs, a => a.AddressFamily == AddressFamily.InterNetwork) - ?? (addrs.Length > 0 - ? addrs[0] - : throw new Exception($"DNS resolved no addresses for '{host}'")); + IPAddress[] addresses = Dns.GetHostAddresses(host); + ip = Array.Find( + addresses, + static address => address.AddressFamily == AddressFamily.InterNetwork) + ?? (addresses.Length != 0 + ? addresses[0] + : throw new InvalidOperationException( + $"DNS resolved no addresses for '{host}'")); Console.WriteLine($"live: resolved {host} → {ip}"); } return new IPEndPoint(ip, port); } + + public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint); + + public void Connect(WorldSession session, string user, string password) => + session.Connect(user, password); + + public CharacterList.Parsed? GetCharacters(WorldSession session) => session.Characters; + + public void EnterWorld(WorldSession session, int activeCharacterIndex) => + session.EnterWorld(activeCharacterIndex); + + public void Tick(WorldSession session) => session.Tick(); + + public void DisposeSession(WorldSession session) => session.Dispose(); +} + +/// +/// Sole owner of the App lifetime for a live WorldSession: endpoint resolution, +/// complete pre-Connect routing, character validation/entry, active command +/// publication, exact-generation Tick, graceful replacement, and convergent +/// teardown. Retail wire behavior remains inside WorldSession. +/// +public sealed class LiveSessionController : IDisposable +{ + private sealed class SessionScope( + WorldSession session, + ILiveSessionLifecycleHost host) + { + private int _teardownStage; + + public WorldSession Session { get; } = session; + public ILiveSessionLifecycleHost Host { get; } = host; + public LiveSessionBinding? Binding { get; set; } + public bool HostAttached { get; set; } + + public void DrainTeardown(ILiveSessionOperations operations) + { + if (_teardownStage == 0) + { + Binding?.Dispose(); + _teardownStage = 1; + } + if (_teardownStage == 1) + { + operations.DisposeSession(Session); + _teardownStage = 2; + } + if (_teardownStage == 2) + { + if (HostAttached) + Host.DetachSession(Session); + _teardownStage = 3; + } + if (_teardownStage == 3) + { + Host.ResetSessionState(); + _teardownStage = 4; + } + } + + public bool IsTeardownComplete => _teardownStage == 4; + } + + private enum PendingKind + { + Stop, + Reconnect, + Dispose, + } + + private sealed record PendingOperation( + PendingKind Kind, + RuntimeOptions? Options = null, + ILiveSessionLifecycleHost? Host = null); + + private readonly object _gate = new(); + private readonly ILiveSessionOperations _operations; + private SessionScope? _scope; + private SessionScope? _retiredScope; + private ILiveSessionLifecycleHost? _pendingInitialResetHost; + private PendingOperation? _pendingOperation; + private WorldSession? _legacySession; + private int _operationDepth; + private bool _inWorld; + private bool _disposeRequested; + private bool _disposed; + private ulong _generation; + private LiveSessionCharacterSelection? _activeSelection; + + public LiveSessionController() + : this(ProductionLiveSessionOperations.Instance) + { + } + + internal LiveSessionController(ILiveSessionOperations operations) + { + _operations = operations ?? throw new ArgumentNullException(nameof(operations)); + } + + public WorldSession? CurrentSession + { + get { lock (_gate) return _scope?.Session ?? _legacySession; } + } + + /// Temporary Slice-3 compatibility alias; removed at GameWindow cutover. + public WorldSession? Session => CurrentSession; + + public ICommandBus Commands + { + get + { + lock (_gate) + return _inWorld && _scope?.Binding is { } binding + ? binding.Commands + : NullCommandBus.Instance; + } + } + + public bool IsInWorld + { + get { lock (_gate) return _inWorld; } + } + + public ulong SessionGeneration + { + get { lock (_gate) return _generation; } + } + + internal LiveSessionStartResult Start( + RuntimeOptions options, + ILiveSessionLifecycleHost host) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(host); + lock (_gate) + { + ThrowIfDisposing(); + if (_operationDepth != 0) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + if (_inWorld) + return ConnectedResult(); + return RunTopLevel(() => StartCore(options, host, resetHost: true)); + } + } + + internal LiveSessionStartResult Reconnect( + RuntimeOptions options, + ILiveSessionLifecycleHost host) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(host); + lock (_gate) + { + ThrowIfDisposing(); + if (_operationDepth != 0) + { + Schedule(new PendingOperation(PendingKind.Reconnect, options, host)); + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + } + return RunTopLevel(() => ReconnectCore(options, host)); + } + } + + internal void Stop() + { + lock (_gate) + { + if (_disposed) + return; + if (_operationDepth != 0) + { + Schedule(new PendingOperation(PendingKind.Stop)); + return; + } + RunTopLevel(StopCore); + } + } + + public void Tick() + { + lock (_gate) + { + if (_disposed) + return; + if (_legacySession is not null && _scope is null) + { + _legacySession.Tick(); + return; + } + if (!_inWorld || _scope is null || _operationDepth != 0) + return; + + RunTopLevel(() => + { + SessionScope scope = _scope; + ulong generation = _generation; + try + { + _operations.Tick(scope.Session); + if (!IsCurrent(scope, generation)) + return; + } + catch (Exception tickError) + { + Exception error = StopAfterFailure(tickError); + throw error; + } + }); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposeRequested = true; + if (_operationDepth != 0) + { + Schedule(new PendingOperation(PendingKind.Dispose)); + return; + } + + RunTopLevel(DisposeCore); + } + } + + private LiveSessionStartResult ReconnectCore( + RuntimeOptions options, + ILiveSessionLifecycleHost host) + { + ILiveSessionLifecycleHost? oldHost = + _scope?.Host ?? _retiredScope?.Host ?? _pendingInitialResetHost; + ulong generationBeforeStop = _generation; + try + { + StopCore(); + } + catch (Exception error) + { + return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error); + } + if (_generation != unchecked(generationBeforeStop + 1)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + return StartCore(options, host, resetHost: !ReferenceEquals(oldHost, host)); + } + + private LiveSessionStartResult StartCore( + RuntimeOptions options, + ILiveSessionLifecycleHost host, + bool resetHost) + { + ulong generation = ++_generation; + try + { + DrainRetiredScope(); + if (_generation != generation) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + if (resetHost) + ResetHostBeforeStart(host, generation); + if (_generation != generation) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + } + catch (Exception error) + { + return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error); + } + + if (!options.LiveMode) + return new LiveSessionStartResult(LiveSessionStartStatus.Disabled); + if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass)) + return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials); + + SessionScope? scope = null; + try + { + IPEndPoint endpoint = _operations.ResolveEndpoint(options.LiveHost, options.LivePort); + if (_generation != generation) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}"); + WorldSession session = _operations.CreateSession(endpoint); + scope = new SessionScope(session, host); + _scope = scope; + _inWorld = false; + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + LiveSessionBinding binding = host.BindSession(session); + scope.Binding = binding; + scope.HostAttached = true; + if (!ReferenceEquals(binding.Session, session)) + { + throw new InvalidOperationException( + "The live-session host returned a binding for a different session."); + } + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + host.ReportConnecting(options.LiveHost, options.LivePort, options.LiveUser); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + _operations.Connect(session, options.LiveUser, options.LivePass); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + host.ReportConnected(); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + CharacterList.Parsed? characters = _operations.GetCharacters(session); + if (characters is null + || !CharacterList.TrySelectFirstAvailable(characters, out CharacterList.Selection selected)) + { + Console.WriteLine("live: no available characters on account; disconnecting"); + StopCore(); + return new LiveSessionStartResult(LiveSessionStartStatus.NoCharacters); + } + + var selection = new LiveSessionCharacterSelection( + selected.ActiveIndex, + selected.Character.Id, + selected.Character.Name, + characters.AccountName); + host.ApplySelectedCharacter(selection); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + Console.WriteLine( + $"live: entering world as 0x{selection.CharacterId:X8} {selection.CharacterName}"); + _operations.EnterWorld(session, selection.ActiveIndex); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + binding.ActivateCommands(); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + _inWorld = true; + _activeSelection = selection; + host.ApplyEnteredWorld(selection); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + + Console.WriteLine("live: in world — CreateObject stream active"); + return new LiveSessionStartResult( + LiveSessionStartStatus.Connected, + selection); + } + catch (Exception startError) + { + return new LiveSessionStartResult( + LiveSessionStartStatus.Failed, + Error: StopAfterFailure(startError)); + } + } + + private Exception StopAfterFailure(Exception operationError) + { + try + { + StopCore(); + return operationError; + } + catch (Exception cleanupError) + { + return new AggregateException( + "Live-session operation and cleanup both failed.", + operationError, + cleanupError); + } + } + + private void StopCore() + { + ++_generation; + _inWorld = false; + _activeSelection = null; + if (_scope is { } scope) + { + _scope = null; + if (_retiredScope is not null && !ReferenceEquals(_retiredScope, scope)) + throw new InvalidOperationException( + "A second live-session scope cannot retire before the first converges."); + _retiredScope = scope; + } + DrainRetiredScope(); + DrainPendingInitialReset(); + } + + private void DrainRetiredScope() + { + if (_retiredScope is not { } retired) + return; + retired.DrainTeardown(_operations); + if (retired.IsTeardownComplete) + _retiredScope = null; + } + + private void ResetHostBeforeStart( + ILiveSessionLifecycleHost host, + ulong generation) + { + bool requestedHostAlreadyReset = false; + if (_pendingInitialResetHost is { } pending) + { + pending.ResetSessionState(); + _pendingInitialResetHost = null; + requestedHostAlreadyReset = ReferenceEquals(pending, host); + } + if (requestedHostAlreadyReset || _generation != generation) + return; + + try + { + host.ResetSessionState(); + } + catch + { + _pendingInitialResetHost = host; + throw; + } + } + + private void DrainPendingInitialReset() + { + if (_pendingInitialResetHost is not { } host) + return; + host.ResetSessionState(); + _pendingInitialResetHost = null; + } + + private void Schedule(PendingOperation operation) + { + if (_pendingOperation?.Kind == PendingKind.Dispose) + return; + _pendingOperation = operation; + ++_generation; + _inWorld = false; + _scope?.Binding?.Dispose(); + } + + private T RunTopLevel(Func operation) + { + _operationDepth++; + try + { + return operation(); + } + finally + { + _operationDepth--; + if (_operationDepth == 0) + DrainPendingOperations(); + } + } + + private void RunTopLevel(Action operation) => + RunTopLevel(() => + { + operation(); + return true; + }); + + private void DrainPendingOperations() + { + while (_pendingOperation is { } pending) + { + _pendingOperation = null; + _operationDepth++; + try + { + switch (pending.Kind) + { + case PendingKind.Stop: + StopCore(); + break; + case PendingKind.Reconnect: + _ = ReconnectCore(pending.Options!, pending.Host!); + break; + case PendingKind.Dispose: + DisposeCore(); + break; + } + } + finally + { + _operationDepth--; + } + } + } + + private void DisposeCore() + { + StopCore(); + if (_legacySession is { } legacy) + { + _operations.DisposeSession(legacy); + _legacySession = null; + } + _disposed = true; + } + + private bool IsCurrent(SessionScope scope, ulong generation) => + ReferenceEquals(_scope, scope) && _generation == generation; + + private LiveSessionStartResult ConnectedResult() + => new(LiveSessionStartStatus.Connected, _activeSelection); + + private void ThrowIfDisposing() + { + if (_disposeRequested || _disposed) + throw new ObjectDisposedException(nameof(LiveSessionController)); + } + + // Temporary compatibility surface for Slice 3 Commit E. The complete + // controller above is exercised independently before GameWindow cuts over. + public WorldSession? CreateAndWire(RuntimeOptions options, Action wireEvents) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(wireEvents); + lock (_gate) + { + ThrowIfDisposing(); + if (!options.LiveMode) + return null; + if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass)) + { + Console.WriteLine( + "live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"); + return null; + } + + try + { + IPEndPoint endpoint = _operations.ResolveEndpoint(options.LiveHost, options.LivePort); + Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}"); + _legacySession = _operations.CreateSession(endpoint); + wireEvents(_legacySession); + return _legacySession; + } + catch (Exception error) + { + Console.WriteLine($"live: session setup failed: {error.Message}"); + if (_legacySession is not null) + _operations.DisposeSession(_legacySession); + _legacySession = null; + return null; + } + } + } } diff --git a/src/AcDream.Core.Net/AcDream.Core.Net.csproj b/src/AcDream.Core.Net/AcDream.Core.Net.csproj index 49c20a3d..e52d1627 100644 --- a/src/AcDream.Core.Net/AcDream.Core.Net.csproj +++ b/src/AcDream.Core.Net/AcDream.Core.Net.csproj @@ -12,5 +12,6 @@ + diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 1f27bc25..e000ee0d 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -10,6 +10,29 @@ using AcDream.Core.Net.Packets; namespace AcDream.Core.Net; +internal interface IWorldSessionTransport : IDisposable +{ + void Send(ReadOnlySpan datagram); + void Send(IPEndPoint remote, ReadOnlySpan datagram); + byte[]? Receive(TimeSpan timeout, out IPEndPoint? from); +} + +internal sealed class NetClientWorldSessionTransport(IPEndPoint remote) + : IWorldSessionTransport +{ + private readonly NetClient _client = new(remote); + + public void Send(ReadOnlySpan datagram) => _client.Send(datagram); + + public void Send(IPEndPoint endpoint, ReadOnlySpan datagram) => + _client.Send(endpoint, datagram); + + public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) => + _client.Receive(timeout, out from); + + public void Dispose() => _client.Dispose(); +} + /// /// High-level AC client session: owns a , drives /// the full handshake + character-enter-world flow, and converts the @@ -600,7 +623,7 @@ public sealed class WorldSession : IDisposable public CharacterList.Parsed? Characters { get; private set; } - private readonly NetClient _net; + private readonly IWorldSessionTransport _net; private long _lastInboundPacketTicks = Stopwatch.GetTimestamp(); private long _lastPingRequestTicks; private long _lastPingRoundTripBits = BitConverter.DoubleToInt64Bits(double.NaN); @@ -676,10 +699,34 @@ public sealed class WorldSession : IDisposable private uint _gameActionSequence; public WorldSession(IPEndPoint serverLogin) + : this( + serverLogin, + static endpoint => new NetClientWorldSessionTransport(endpoint)) { + } + + internal WorldSession( + IPEndPoint serverLogin, + IWorldSessionTransport transport) + : this(serverLogin, _ => transport) + { + } + + internal WorldSession( + IPEndPoint serverLogin, + Func transportFactory) + { + ArgumentNullException.ThrowIfNull(serverLogin); + ArgumentNullException.ThrowIfNull(transportFactory); + if (serverLogin.Port == ushort.MaxValue) + throw new ArgumentOutOfRangeException( + nameof(serverLogin), + "The login endpoint must leave room for the adjacent connect port."); + _loginEndpoint = serverLogin; _connectEndpoint = new IPEndPoint(serverLogin.Address, serverLogin.Port + 1); - _net = new NetClient(serverLogin); + _net = transportFactory(serverLogin) + ?? throw new InvalidOperationException("The session transport factory returned null."); // Phase I.6: SetTurbineChatChannels (0x0295) is a GameEvent // sub-opcode of 0xF7B0, not a top-level opcode. Route it through diff --git a/tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs new file mode 100644 index 00000000..3c56847d --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs @@ -0,0 +1,864 @@ +using System.Net; +using AcDream.App.Net; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Tests.Net; + +public sealed class LiveSessionControllerTests +{ + private sealed class TestTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) { } + + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + + public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) + { + from = null; + return null; + } + + public void Dispose() { } + } + + private sealed class TestCommandBus : ICommandBus + { + public bool Active { get; set; } + public int PublishCount { get; private set; } + + public void Publish(T command) where T : notnull + { + if (Active) + PublishCount++; + } + } + + private sealed class TestOperations(List calls) : ILiveSessionOperations + { + public CharacterList.Parsed? Characters { get; set; } = AvailableCharacters(); + public Action? OnResolve { get; set; } + public Action? OnCreate { get; set; } + public Action? OnConnect { get; set; } + public Action? OnCharacters { get; set; } + public Action? OnEnterWorld { get; set; } + public Action? OnTick { get; set; } + public Action? OnDispose { get; set; } + public bool ThrowOnConnect { get; set; } + public bool ThrowOnCreate { get; set; } + public bool ThrowOnCharacters { get; set; } + public bool ThrowOnEnterWorld { get; set; } + public bool ThrowOnTick { get; set; } + public bool FailDisposeOnce { get; set; } + public List Sessions { get; } = []; + public Dictionary DisposeCounts { get; } = []; + public int EnterWorldCount { get; private set; } + public int TickCount { get; private set; } + + public IPEndPoint ResolveEndpoint(string host, int port) + { + calls.Add("resolve"); + OnResolve?.Invoke(); + return new IPEndPoint(IPAddress.Loopback, port); + } + + public WorldSession CreateSession(IPEndPoint endpoint) + { + calls.Add("create"); + OnCreate?.Invoke(); + if (ThrowOnCreate) + throw new InvalidOperationException("create failure"); + var session = new WorldSession(endpoint, new TestTransport()); + Sessions.Add(session); + return session; + } + + public void Connect(WorldSession session, string user, string password) + { + calls.Add("connect"); + OnConnect?.Invoke(); + if (ThrowOnConnect) + throw new InvalidOperationException("connect failure"); + } + + public CharacterList.Parsed? GetCharacters(WorldSession session) + { + OnCharacters?.Invoke(); + if (ThrowOnCharacters) + throw new InvalidOperationException("characters failure"); + return Characters; + } + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + calls.Add($"enter:{activeCharacterIndex}"); + EnterWorldCount++; + OnEnterWorld?.Invoke(); + if (ThrowOnEnterWorld) + throw new InvalidOperationException("enter failure"); + } + + public void Tick(WorldSession session) + { + calls.Add("tick"); + TickCount++; + OnTick?.Invoke(); + if (ThrowOnTick) + throw new InvalidOperationException("tick failure"); + } + + public void DisposeSession(WorldSession session) + { + calls.Add("dispose-session"); + OnDispose?.Invoke(); + if (FailDisposeOnce) + { + FailDisposeOnce = false; + throw new InvalidOperationException("dispose failure"); + } + DisposeCounts[session] = DisposeCounts.GetValueOrDefault(session) + 1; + } + } + + private sealed class TestHost(List calls) : ILiveSessionLifecycleHost + { + public Action? OnBind { get; set; } + public Action? OnReset { get; set; } + public Action? OnConnecting { get; set; } + public Action? OnConnected { get; set; } + public Action? OnSelected { get; set; } + public Action? OnActivate { get; set; } + public Action? OnEntered { get; set; } + public Action? OnDetach { get; set; } + public bool FailResetOnce { get; set; } + public bool FailDetachOnce { get; set; } + public bool FailDeactivateOnce { get; set; } + public bool FailEventDetachOnce { get; set; } + public bool ThrowOnBind { get; set; } + public bool ThrowOnConnecting { get; set; } + public bool ThrowOnConnected { get; set; } + public bool ThrowOnSelected { get; set; } + public bool ThrowOnActivate { get; set; } + public bool ThrowOnEntered { get; set; } + public WorldSession? BindingSessionOverride { get; set; } + public int ResetCount { get; private set; } + public int DetachCount { get; private set; } + public int DeactivateCount { get; private set; } + public int EventDetachCount { get; private set; } + public int ActivateCount { get; private set; } + public List CommandBuses { get; } = []; + public List Selections { get; } = []; + + public LiveSessionBinding BindSession(WorldSession session) + { + calls.Add("bind"); + OnBind?.Invoke(); + if (ThrowOnBind) + throw new InvalidOperationException("bind failure"); + var commands = new TestCommandBus(); + CommandBuses.Add(commands); + return new LiveSessionBinding( + BindingSessionOverride ?? session, + commands, + activateCommands: () => + { + calls.Add("activate"); + ActivateCount++; + commands.Active = true; + OnActivate?.Invoke(); + if (ThrowOnActivate) + throw new InvalidOperationException("activate failure"); + }, + deactivateCommands: () => + { + calls.Add("deactivate"); + DeactivateCount++; + commands.Active = false; + if (FailDeactivateOnce) + { + FailDeactivateOnce = false; + throw new InvalidOperationException("deactivate failure"); + } + }, + detachEvents: () => + { + calls.Add("detach-events"); + EventDetachCount++; + if (FailEventDetachOnce) + { + FailEventDetachOnce = false; + throw new InvalidOperationException("event detach failure"); + } + }); + } + + public void ResetSessionState() + { + calls.Add("reset"); + ResetCount++; + OnReset?.Invoke(); + if (FailResetOnce) + { + FailResetOnce = false; + throw new InvalidOperationException("reset failure"); + } + } + + public void ReportConnecting(string host, int port, string user) + { + calls.Add("report-connecting"); + OnConnecting?.Invoke(); + if (ThrowOnConnecting) + throw new InvalidOperationException("connecting failure"); + } + + public void ReportConnected() + { + calls.Add("report-connected"); + OnConnected?.Invoke(); + if (ThrowOnConnected) + throw new InvalidOperationException("connected failure"); + } + + public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) + { + calls.Add("selected"); + Selections.Add(selection); + OnSelected?.Invoke(); + if (ThrowOnSelected) + throw new InvalidOperationException("selected failure"); + } + + public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) + { + calls.Add("entered"); + OnEntered?.Invoke(); + if (ThrowOnEntered) + throw new InvalidOperationException("entered failure"); + } + + public void DetachSession(WorldSession session) + { + calls.Add("detach-session"); + DetachCount++; + OnDetach?.Invoke(); + if (FailDetachOnce) + { + FailDetachOnce = false; + throw new InvalidOperationException("detach failure"); + } + } + } + + public enum ReentrantStopPoint + { + Reset, + Resolve, + Create, + Bind, + Connecting, + Connect, + Connected, + Selected, + EnterWorld, + Activate, + Entered, + } + + [Fact] + public void Start_BindsBeforeConnectAndPublishesCanonicalSelectionAfterEnterWorld() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Connected, result.Status); + Assert.Equal( + [ + "reset", "resolve", "create", "bind", "report-connecting", + "connect", "report-connected", "selected", "enter:1", + "activate", "entered", + ], + calls); + Assert.Equal( + new LiveSessionCharacterSelection(1, 0x50000002u, "Ready", "Canonical"), + result.Selection); + Assert.True(controller.IsInWorld); + Assert.Same(operations.Sessions[0], controller.CurrentSession); + Assert.Same(host.CommandBuses[0], controller.Commands); + Assert.True(host.CommandBuses[0].Active); + } + + [Fact] + public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + Assert.Equal( + LiveSessionStartStatus.Disabled, + controller.Start(LiveOptions(live: false), host).Status); + Assert.Equal( + LiveSessionStartStatus.MissingCredentials, + controller.Start(LiveOptions(user: null), host).Status); + + Assert.Equal(2, host.ResetCount); + Assert.Empty(operations.Sessions); + Assert.Equal(NullCommandBus.Instance, controller.Commands); + } + + [Fact] + public void Start_NoAvailableCharacterTearsDownExactScope() + { + var calls = new List(); + var operations = new TestOperations(calls) + { + Characters = new CharacterList.Parsed( + 0u, + [new CharacterList.Character(0x50000001u, "Grey", 5u)], + [], + 11, + "Canonical", + true, + true), + }; + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.NoCharacters, result.Status); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, host.DeactivateCount); + Assert.Equal(1, host.EventDetachCount); + Assert.Equal(1, host.DetachCount); + Assert.Equal(2, host.ResetCount); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + } + + [Fact] + public void Start_ConnectFailureConvergesOffline() + { + var calls = new List(); + var operations = new TestOperations(calls) { ThrowOnConnect = true }; + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Failed, result.Status); + Assert.Contains("connect failure", result.Error!.ToString()); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + Assert.Equal(1, host.DetachCount); + } + + [Theory] + [InlineData("create")] + [InlineData("bind")] + [InlineData("enter")] + public void Start_OtherConstructionAndEntryFailuresConvergeOffline(string phase) + { + var calls = new List(); + var operations = new TestOperations(calls) + { + ThrowOnCreate = phase == "create", + ThrowOnEnterWorld = phase == "enter", + }; + var host = new TestHost(calls) { ThrowOnBind = phase == "bind" }; + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Failed, result.Status); + Assert.Contains($"{phase} failure", result.Error!.ToString()); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + if (phase == "create") + { + Assert.Empty(operations.Sessions); + Assert.Equal(1, host.ResetCount); + } + else + { + Assert.Single(operations.Sessions); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + Assert.Equal(2, host.ResetCount); + } + Assert.Equal(phase == "enter" ? 1 : 0, host.DetachCount); + } + + [Fact] + public void Start_HealthyDuplicateIsIdempotent() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + LiveSessionStartResult first = controller.Start(LiveOptions(), host); + + LiveSessionStartResult duplicate = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Connected, duplicate.Status); + Assert.Equal(first.Selection, duplicate.Selection); + Assert.Single(operations.Sessions); + Assert.Equal(1, host.ResetCount); + Assert.Equal(1, host.ActivateCount); + } + + [Fact] + public void Reconnect_QuiescesAThenDisposesDetachesResetsBeforeConstructingB() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + WorldSession sessionA = operations.Sessions[0]; + TestCommandBus commandsA = host.CommandBuses[0]; + calls.Clear(); + + LiveSessionStartResult result = controller.Reconnect(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Connected, result.Status); + Assert.Equal( + [ + "deactivate", "detach-events", "dispose-session", "detach-session", + "reset", "resolve", "create", "bind", "report-connecting", + "connect", "report-connected", "selected", "enter:1", + "activate", "entered", + ], + calls); + commandsA.Publish(new object()); + Assert.Equal(0, commandsA.PublishCount); + Assert.Equal(1, operations.DisposeCounts[sessionA]); + Assert.Equal(2, operations.Sessions.Count); + Assert.Same(operations.Sessions[1], controller.CurrentSession); + } + + [Theory] + [InlineData("detach", "stop")] + [InlineData("detach", "dispose")] + [InlineData("detach", "reconnect")] + [InlineData("reset", "stop")] + [InlineData("reset", "dispose")] + [InlineData("reset", "reconnect")] + public void Reconnect_ReentrantTeardownRequestNeverPublishesSupersededB( + string callbackPhase, + string request) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + bool requested = false; + Action callback = () => + { + if (requested) + return; + requested = true; + switch (request) + { + case "stop": controller.Stop(); break; + case "dispose": controller.Dispose(); break; + case "reconnect": + Assert.Equal( + LiveSessionStartStatus.Deferred, + controller.Reconnect(LiveOptions(), host).Status); + break; + } + }; + if (callbackPhase == "detach") + host.OnDetach = callback; + else + host.OnReset = callback; + + LiveSessionStartResult outer = controller.Reconnect(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Deferred, outer.Status); + Assert.Equal(request == "reconnect" ? 2 : 1, operations.Sessions.Count); + Assert.Equal(request == "reconnect", controller.IsInWorld); + if (request == "dispose") + Assert.Throws(() => controller.Start(LiveOptions(), host)); + controller.Dispose(); + } + + [Fact] + public void MismatchedBindingWithInterruptedDetachRetainsCleanupOwnershipForRetry() + { + var calls = new List(); + var operations = new TestOperations(calls); + using var mismatchedSession = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9001), + new TestTransport()); + var host = new TestHost(calls) + { + BindingSessionOverride = mismatchedSession, + FailEventDetachOnce = true, + }; + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Failed, result.Status); + Assert.Contains("different session", result.Error!.ToString()); + Assert.Equal(0, operations.DisposeCounts.GetValueOrDefault(operations.Sessions[0])); + Assert.Equal(1, host.DeactivateCount); + Assert.Equal(1, host.EventDetachCount); + + controller.Stop(); + + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + Assert.Equal(1, host.DeactivateCount); + Assert.Equal(2, host.EventDetachCount); + Assert.Equal(1, host.DetachCount); + controller.Dispose(); + } + + [Theory] + [InlineData(ReentrantStopPoint.Reset)] + [InlineData(ReentrantStopPoint.Resolve)] + [InlineData(ReentrantStopPoint.Create)] + [InlineData(ReentrantStopPoint.Bind)] + [InlineData(ReentrantStopPoint.Connecting)] + [InlineData(ReentrantStopPoint.Connect)] + [InlineData(ReentrantStopPoint.Connected)] + [InlineData(ReentrantStopPoint.Selected)] + [InlineData(ReentrantStopPoint.EnterWorld)] + [InlineData(ReentrantStopPoint.Activate)] + [InlineData(ReentrantStopPoint.Entered)] + public void ReentrantStop_InvalidatesOuterGenerationAndCannotResurrect( + ReentrantStopPoint point) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + Action stop = controller.Stop; + switch (point) + { + case ReentrantStopPoint.Reset: host.OnReset = stop; break; + case ReentrantStopPoint.Resolve: operations.OnResolve = stop; break; + case ReentrantStopPoint.Create: operations.OnCreate = stop; break; + case ReentrantStopPoint.Bind: host.OnBind = stop; break; + case ReentrantStopPoint.Connecting: host.OnConnecting = stop; break; + case ReentrantStopPoint.Connect: operations.OnConnect = stop; break; + case ReentrantStopPoint.Connected: host.OnConnected = stop; break; + case ReentrantStopPoint.Selected: host.OnSelected = stop; break; + case ReentrantStopPoint.EnterWorld: operations.OnEnterWorld = stop; break; + case ReentrantStopPoint.Activate: host.OnActivate = stop; break; + case ReentrantStopPoint.Entered: host.OnEntered = stop; break; + } + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Deferred, result.Status); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Same(NullCommandBus.Instance, controller.Commands); + if (operations.Sessions.Count == 0) + { + Assert.Contains( + point, + new[] { ReentrantStopPoint.Reset, ReentrantStopPoint.Resolve }); + } + else + { + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + } + Assert.Equal( + point is ReentrantStopPoint.Reset + or ReentrantStopPoint.Resolve + or ReentrantStopPoint.Create + ? 0 + : 1, + host.DetachCount); + } + + [Fact] + public void ReentrantDuplicateStartIsDeferredWithoutDisturbingOuterAttempt() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + LiveSessionStartResult? nested = null; + host.OnSelected = () => nested = controller.Start(LiveOptions(), host); + + LiveSessionStartResult outer = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Deferred, nested!.Status); + Assert.Equal(LiveSessionStartStatus.Connected, outer.Status); + Assert.True(controller.IsInWorld); + Assert.Single(operations.Sessions); + } + + [Fact] + public void ReentrantStartFromFailingEnteredCallbackCannotObserveUncommittedSession() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + LiveSessionStartResult? nested = null; + host.OnEntered = () => + { + nested = controller.Start(LiveOptions(), host); + throw new InvalidOperationException("entered failure"); + }; + + LiveSessionStartResult outer = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Deferred, nested!.Status); + Assert.Equal(LiveSessionStartStatus.Failed, outer.Status); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + controller.Dispose(); + } + + [Theory] + [InlineData("reconnect")] + [InlineData("dispose")] + public void ReentrantLifecycleRequestDuringActivationDoesNotLeakActiveCommands( + string request) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + bool requested = false; + host.OnActivate = () => + { + if (requested) + return; + requested = true; + if (request == "reconnect") + controller.Reconnect(LiveOptions(), host); + else + controller.Dispose(); + }; + + LiveSessionStartResult outer = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Deferred, outer.Status); + Assert.False(host.CommandBuses[0].Active); + Assert.Equal(request == "reconnect" ? 2 : 1, operations.Sessions.Count); + Assert.Equal(request == "reconnect", controller.IsInWorld); + controller.Dispose(); + } + + [Theory] + [InlineData("connecting")] + [InlineData("connected")] + [InlineData("characters")] + [InlineData("selected")] + [InlineData("activate")] + [InlineData("entered")] + public void Start_CallbackFailureConvergesOffline(string phase) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + switch (phase) + { + case "connecting": host.ThrowOnConnecting = true; break; + case "connected": host.ThrowOnConnected = true; break; + case "characters": operations.ThrowOnCharacters = true; break; + case "selected": host.ThrowOnSelected = true; break; + case "activate": host.ThrowOnActivate = true; break; + case "entered": host.ThrowOnEntered = true; break; + } + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Failed, result.Status); + Assert.Contains($"{phase} failure", result.Error!.ToString()); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + Assert.Same(NullCommandBus.Instance, controller.Commands); + controller.Dispose(); + } + + [Theory] + [InlineData("deactivate")] + [InlineData("events")] + [InlineData("session")] + [InlineData("detach")] + [InlineData("reset")] + public void Stop_RetriesExactFailedTeardownStageWithoutRepeatingCompletedWork( + string phase) + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + WorldSession session = operations.Sessions[0]; + switch (phase) + { + case "deactivate": host.FailDeactivateOnce = true; break; + case "events": host.FailEventDetachOnce = true; break; + case "session": operations.FailDisposeOnce = true; break; + case "detach": host.FailDetachOnce = true; break; + case "reset": host.FailResetOnce = true; break; + } + + Assert.Throws(controller.Stop); + controller.Stop(); + + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[session]); + Assert.Equal(phase == "detach" ? 2 : 1, host.DetachCount); + Assert.Equal(phase == "reset" ? 3 : 2, host.ResetCount); + controller.Dispose(); + } + + [Fact] + public void ReconnectRequestedDuringTickRunsAfterTickAndReplacesExactGeneration() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + WorldSession sessionA = operations.Sessions[0]; + bool requested = false; + operations.OnTick = () => + { + if (requested) + return; + requested = true; + LiveSessionStartResult nested = controller.Reconnect(LiveOptions(), host); + Assert.Equal(LiveSessionStartStatus.Deferred, nested.Status); + }; + + controller.Tick(); + + Assert.True(controller.IsInWorld); + Assert.Equal(2, operations.Sessions.Count); + Assert.Equal(1, operations.DisposeCounts[sessionA]); + Assert.Same(operations.Sessions[1], controller.CurrentSession); + } + + [Fact] + public void ResetFailureBlocksConstructionUntilRetryConverges() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls) { FailResetOnce = true }; + var controller = new LiveSessionController(operations); + + LiveSessionStartResult first = controller.Start(LiveOptions(), host); + LiveSessionStartResult second = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Failed, first.Status); + Assert.Equal(LiveSessionStartStatus.Connected, second.Status); + Assert.Equal(2, host.ResetCount); + Assert.Single(operations.Sessions); + } + + [Fact] + public void FailedDetachRetainsRetiredScopeAndBlocksBFactoryUntilRetry() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls) { FailDetachOnce = true }; + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + WorldSession sessionA = operations.Sessions[0]; + + LiveSessionStartResult first = controller.Reconnect(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Failed, first.Status); + Assert.Single(operations.Sessions); + Assert.Equal(1, operations.DisposeCounts[sessionA]); + Assert.False(controller.IsInWorld); + + LiveSessionStartResult retry = controller.Reconnect(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Connected, retry.Status); + Assert.Equal(2, operations.Sessions.Count); + Assert.Equal(1, operations.DisposeCounts[sessionA]); + Assert.Equal(2, host.DetachCount); + } + + [Fact] + public void TickFailureCleansScopeBeforeRethrowing() + { + var calls = new List(); + var operations = new TestOperations(calls) { ThrowOnTick = true }; + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + WorldSession session = operations.Sessions[0]; + + Assert.Throws(controller.Tick); + + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[session]); + } + + [Fact] + public void DisposeIsIdempotentAndMakesOldCommandsInert() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + WorldSession session = operations.Sessions[0]; + TestCommandBus commands = host.CommandBuses[0]; + + controller.Dispose(); + controller.Dispose(); + commands.Publish(new object()); + + Assert.Equal(1, operations.DisposeCounts[session]); + Assert.Equal(0, commands.PublishCount); + Assert.False(controller.IsInWorld); + Assert.Same(NullCommandBus.Instance, controller.Commands); + Assert.Throws(() => controller.Start(LiveOptions(), host)); + } + + private static RuntimeOptions LiveOptions( + bool live = true, + string? user = "user") + { + var environment = new Dictionary + { + ["ACDREAM_LIVE"] = live ? "1" : "0", + ["ACDREAM_TEST_HOST"] = "127.0.0.1", + ["ACDREAM_TEST_PORT"] = "9000", + ["ACDREAM_TEST_USER"] = user, + ["ACDREAM_TEST_PASS"] = "password", + }; + return RuntimeOptions.Parse( + "dat", + name => environment.GetValueOrDefault(name)); + } + + private static CharacterList.Parsed AvailableCharacters() => new( + 0u, + [ + new CharacterList.Character(0x50000001u, "Grey", 10u), + new CharacterList.Character(0x50000002u, "Ready", 0u), + ], + [new CharacterList.Character(0x50000003u, "Deleted", 0u)], + 11, + "Canonical", + true, + true); +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionConstructionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionConstructionTests.cs new file mode 100644 index 00000000..0718a742 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionConstructionTests.cs @@ -0,0 +1,41 @@ +using System.Net; + +namespace AcDream.Core.Net.Tests; + +public sealed class WorldSessionConstructionTests +{ + [Fact] + public void InvalidEndpointIsRejectedBeforeTransportAllocation() + { + int allocationCount = 0; + IWorldSessionTransport Allocate(IPEndPoint _) + { + allocationCount++; + return new TestTransport(); + } + + Assert.Throws( + () => new WorldSession(null!, Allocate)); + Assert.Throws( + () => new WorldSession( + new IPEndPoint(IPAddress.Loopback, ushort.MaxValue), + Allocate)); + + Assert.Equal(0, allocationCount); + } + + private sealed class TestTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) { } + + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + + public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) + { + from = null; + return null; + } + + public void Dispose() { } + } +} From 783ef1d6dbd370b2e89230402796ec93f6118aca Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 12:21:34 +0200 Subject: [PATCH 34/98] docs(plan): record complete session lifecycle checkpoint --- docs/plans/2026-07-21-gamewindow-slice-3-live-session.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md index 96229955..d7dab902 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md +++ b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md @@ -35,8 +35,13 @@ character-selection UI or redesign the transport. retail dialog completion semantics, and actual-owner A → failed drain → retry → clean-state coverage. Three-agent review clean. Release: 6,659 passed / 5 skipped. -- [ ] D — complete lifecycle controller (in progress). -- [ ] E — GameWindow cutover. +- [x] D — complete lifecycle controller (`d9ccf8a6`): sole App ownership + of resolve/create/bind/Connect/select/EnterWorld/Tick/stop/reconnect, + generation-gated reentrancy, pre-publication command activation, + convergent staged teardown, transport-free deterministic session tests, + and invalid-endpoint validation before UDP allocation. Three-agent review + clean. Release: 6,707 passed / 5 skipped. +- [ ] E — GameWindow cutover (in progress). - [ ] F — final review, connected gate, and documentation closeout. ## 1. Outcome From 6a5d9e2e6a262a3c271a9113d5040c4b6ede2640 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 12:37:22 +0200 Subject: [PATCH 35/98] refactor(net): cut GameWindow over to session owner --- src/AcDream.App/Net/LiveSessionController.cs | 52 +-- .../Net/LiveSessionLifecycleHost.cs | 65 ++++ src/AcDream.App/Rendering/GameWindow.cs | 367 +++++++----------- .../GameWindowLiveSessionOwnershipTests.cs | 37 ++ .../Net/LiveSessionLifecycleHostTests.cs | 110 ++++++ 5 files changed, 363 insertions(+), 268 deletions(-) create mode 100644 src/AcDream.App/Net/LiveSessionLifecycleHost.cs create mode 100644 tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs index 9204f8b4..13acfa40 100644 --- a/src/AcDream.App/Net/LiveSessionController.cs +++ b/src/AcDream.App/Net/LiveSessionController.cs @@ -216,7 +216,6 @@ public sealed class LiveSessionController : IDisposable private SessionScope? _retiredScope; private ILiveSessionLifecycleHost? _pendingInitialResetHost; private PendingOperation? _pendingOperation; - private WorldSession? _legacySession; private int _operationDepth; private bool _inWorld; private bool _disposeRequested; @@ -236,12 +235,9 @@ public sealed class LiveSessionController : IDisposable public WorldSession? CurrentSession { - get { lock (_gate) return _scope?.Session ?? _legacySession; } + get { lock (_gate) return _scope?.Session; } } - /// Temporary Slice-3 compatibility alias; removed at GameWindow cutover. - public WorldSession? Session => CurrentSession; - public ICommandBus Commands { get @@ -319,11 +315,6 @@ public sealed class LiveSessionController : IDisposable { if (_disposed) return; - if (_legacySession is not null && _scope is null) - { - _legacySession.Tick(); - return; - } if (!_inWorld || _scope is null || _operationDepth != 0) return; @@ -629,11 +620,6 @@ public sealed class LiveSessionController : IDisposable private void DisposeCore() { StopCore(); - if (_legacySession is { } legacy) - { - _operations.DisposeSession(legacy); - _legacySession = null; - } _disposed = true; } @@ -649,40 +635,4 @@ public sealed class LiveSessionController : IDisposable throw new ObjectDisposedException(nameof(LiveSessionController)); } - // Temporary compatibility surface for Slice 3 Commit E. The complete - // controller above is exercised independently before GameWindow cuts over. - public WorldSession? CreateAndWire(RuntimeOptions options, Action wireEvents) - { - ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(wireEvents); - lock (_gate) - { - ThrowIfDisposing(); - if (!options.LiveMode) - return null; - if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass)) - { - Console.WriteLine( - "live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"); - return null; - } - - try - { - IPEndPoint endpoint = _operations.ResolveEndpoint(options.LiveHost, options.LivePort); - Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}"); - _legacySession = _operations.CreateSession(endpoint); - wireEvents(_legacySession); - return _legacySession; - } - catch (Exception error) - { - Console.WriteLine($"live: session setup failed: {error.Message}"); - if (_legacySession is not null) - _operations.DisposeSession(_legacySession); - _legacySession = null; - return null; - } - } - } } diff --git a/src/AcDream.App/Net/LiveSessionLifecycleHost.cs b/src/AcDream.App/Net/LiveSessionLifecycleHost.cs new file mode 100644 index 00000000..87596b75 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionLifecycleHost.cs @@ -0,0 +1,65 @@ +using AcDream.Core.Net; + +namespace AcDream.App.Net; + +internal sealed record LiveSessionLifecycleBindings( + Func Bind, + Action Reset, + Action Connecting, + Action Connected, + Action Selected, + Action Entered); + +/// +/// Focused adapter between the session lifetime owner and App composition. +/// It tracks only the exact borrowed session attached to the host; domain and +/// presentation state remain behind the supplied lifecycle callbacks. +/// +internal sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost +{ + private readonly LiveSessionLifecycleBindings _bindings; + private WorldSession? _boundSession; + + public LiveSessionLifecycleHost(LiveSessionLifecycleBindings bindings) + { + _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); + ArgumentNullException.ThrowIfNull(bindings.Bind); + ArgumentNullException.ThrowIfNull(bindings.Reset); + ArgumentNullException.ThrowIfNull(bindings.Connecting); + ArgumentNullException.ThrowIfNull(bindings.Connected); + ArgumentNullException.ThrowIfNull(bindings.Selected); + ArgumentNullException.ThrowIfNull(bindings.Entered); + } + + public LiveSessionBinding BindSession(WorldSession session) + { + ArgumentNullException.ThrowIfNull(session); + if (_boundSession is not null) + throw new InvalidOperationException("A live session is already attached to this host."); + + LiveSessionBinding binding = _bindings.Bind(session); + _boundSession = session; + return binding; + } + + public void ResetSessionState() => _bindings.Reset(); + + public void ReportConnecting(string host, int port, string user) => + _bindings.Connecting(host, port, user); + + public void ReportConnected() => _bindings.Connected(); + + public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) => + _bindings.Selected(selection); + + public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) => + _bindings.Entered(selection); + + public void DetachSession(WorldSession session) + { + if (!ReferenceEquals(_boundSession, session)) + throw new InvalidOperationException( + "The live-session controller attempted to detach a session that is not bound."); + _boundSession = null; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f6d9bcf7..57f81411 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -813,10 +813,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private bool DevToolsEnabled => _options.DevTools; private bool DumpMoveTruthEnabled => _options.DumpMoveTruth; - // Slice 3: exact session-generation owners. The command router is itself - // an ICommandBus and becomes inert before the displaced socket closes. - private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents; - private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands; + // Slice 3: the reset transaction remains cached by the host, while the + // lifecycle controller owns each exact event/command/session generation. private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan; // Phase G.1-G.2 world lighting/time state. @@ -954,7 +952,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private bool _autoRunActive; // Phase K.2 — auto-enter player mode after a successful login. Armed - // by the EnterWorld branch in BeginLiveSessionAsync; ticked from + // by ApplyLiveSessionEnteredWorld; ticked from // OnUpdate; disarmed if the user manually enters fly mode (or any // other path that pre-empts the chase camera). Skipped entirely // offline (orbit camera stays the foreground). The class internally @@ -1004,12 +1002,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Phase 4.7: optional live connection to an ACE server. Enabled only when // ACDREAM_LIVE=1 is in the environment — fully backward compatible with // the offline rendering pipeline. - // Step 2 re-attempt (2026-05-16, debug pass): the network-side lifecycle - // (DNS, endpoint, WorldSession construction, Tick, Dispose) lives in - // LiveSessionController. _liveSession remains as a convenience handle - // for the ~60 outbound SendXxx call sites; it tracks Controller.Session. + // Slice 3: the controller is the sole App owner. Outbound feature + // callbacks resolve this borrowed handle at call time and never cache it. private AcDream.App.Net.LiveSessionController? _liveSessionController; - private AcDream.Core.Net.WorldSession? _liveSession; + private AcDream.Core.Net.WorldSession? LiveSession => + _liveSessionController?.CurrentSession; private int _liveCenterX; private int _liveCenterY; // #192 (2026-07-09): true once the player's first canonical Position has @@ -1184,13 +1181,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _localPlayerProjection.Project(controller, movement, hidden), sendPreNetwork: (controller, movement, hidden) => _localPlayerOutbound.SendPreNetworkActions( - _liveSession, + LiveSession, controller, movement, hidden), sendPostNetwork: (controller, hidden) => _localPlayerOutbound.SendPostNetworkPosition( - _liveSession, + LiveSession, controller, hidden), objectClockDisposition: () => @@ -1324,9 +1321,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // when the entity isn't ready (the third predicate // guarantees readiness before this fires). _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( - isLiveInWorld: () => _liveSession is not null - && _liveSession.CurrentState == - AcDream.Core.Net.WorldSession.State.InWorld, + isLiveInWorld: () => _liveSessionController?.IsInWorld == true, isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid), isPlayerControllerReady: () => true, // Retail SmartBox::UseTime (0x00455410) completes the player @@ -1781,7 +1776,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext try { // _activeToonKey is updated by - // BeginLiveSessionAsync after EnterWorld + // ApplyLiveSessionEnteredWorld // so saving character settings always // writes under the chosen character's // name (or "default" pre-login). @@ -2015,7 +2010,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext CanStartLiveCombatAttack, SendLiveCombatAttack, prepareAttackRequest: PreparePlayerForAttackRequest, - sendCancelAttack: () => _liveSession?.SendCancelAttack(), + sendCancelAttack: () => LiveSession?.SendCancelAttack(), isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle == AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle, playerReadyForAttack: IsPlayerReadyForCombatAttack, @@ -2028,7 +2023,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController( _externalContainers, Objects, - guid => _liveSession?.SendNoLongerViewingContents(guid)); + guid => LiveSession?.SendNoLongerViewingContents(guid)); AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!; _itemInteractionController = new AcDream.App.UI.ItemInteractionController( @@ -2041,25 +2036,24 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // WorldSession directly here made keyboard R approach a corpse // without completing the same open transaction as double-click. sendUse: SendUse, - sendExamine: g => _liveSession?.SendAppraise(g), - sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target), - sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), - sendDrop: item => _liveSession?.SendDropItem(item), + sendExamine: g => LiveSession?.SendAppraise(g), + sendUseWithTarget: (source, target) => LiveSession?.SendUseWithTarget(source, target), + sendWield: (item, mask) => LiveSession?.SendGetAndWieldItem(item, mask), + sendDrop: item => LiveSession?.SendDropItem(item), sendGive: (target, item, amount) => - _liveSession?.SendGiveObject(target, item, amount), + LiveSession?.SendGiveObject(target, item, amount), dragOnPlayerOpensSecureTrade: () => (_characterOptions1 & AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 .DragItemOnPlayerOpensSecureTrade) != 0, toast: text => _debugVm?.AddToast(text), - readyForInventoryRequest: () => _liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld, + readyForInventoryRequest: () => _liveSessionController?.IsInWorld == true, playerOnGround: GetDebugPlayerOnGround, inNonCombatMode: () => Combat.CurrentMode == AcDream.Core.Combat.CombatMode.NonCombat, combatState: Combat, sendChangeCombatMode: mode => - _liveSession?.SendChangeCombatMode(mode), + LiveSession?.SendChangeCombatMode(mode), isComponentPack: magicCatalog.IsComponentPack, placeInBackpack: SendPickUp, backpackContainerId: () => @@ -2071,14 +2065,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // the retained controller remains alive. groundObjectId: () => _externalContainers.CurrentContainerId, sendSplitToWorld: (item, amount) => - _liveSession?.SendStackableSplitTo3D(item, amount), + LiveSession?.SendStackableSplitTo3D(item, amount), selectedObjectId: () => _selection.SelectedObjectId ?? 0u, stackSplitQuantity: _stackSplitQuantity, systemMessage: text => Chat.OnSystemMessage(text, 0x1Au), sendPutItemInContainer: (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement), + LiveSession?.SendPutItemInContainer(item, container, placement), sendSplitToContainer: (item, container, placement, amount) => - _liveSession?.SendStackableSplitToContainer( + LiveSession?.SendStackableSplitToContainer( item, container, placement, amount), requestExternalContainer: guid => _externalContainers.RequestOpen(guid)); @@ -2101,12 +2095,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext playerGuid: () => _playerServerGuid, activeToonName: () => _activeToonKey, fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter, - canSendRaise: () => _liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld, - sendRaiseAttribute: (statId, cost) => _liveSession?.SendRaiseAttribute(statId, cost), - sendRaiseVital: (statId, cost) => _liveSession?.SendRaiseVital(statId, cost), - sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost), - sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits)); + canSendRaise: () => _liveSessionController?.IsInWorld == true, + sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost), + sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(statId, cost), + sendRaiseSkill: (statId, cost) => LiveSession?.SendRaiseSkill(statId, cost), + sendTrainSkill: (statId, credits) => LiveSession?.SendTrainSkill(statId, credits)); _magicRuntime = AcDream.App.Spells.MagicRuntime.Create( magicCatalog, SpellBook, @@ -2115,16 +2108,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext localPlayerId: () => _playerServerGuid, // SpellFormula::Randomize hashes the canonical account spelling // delivered by CharacterList. - accountName: () => _liveSession?.Characters?.AccountName + accountName: () => LiveSession?.Characters?.AccountName ?? _options.LiveUser ?? string.Empty, stopCompletely: PreparePlayerForAttackRequest, - sendUntargeted: spellId => _liveSession?.SendCastUntargetedSpell(spellId), - sendTargeted: (target, spellId) => _liveSession?.SendCastTargetedSpell(target, spellId), + sendUntargeted: spellId => LiveSession?.SendCastUntargetedSpell(spellId), + sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId), displayMessage: text => Chat.OnSystemMessage(text, 0x1Au), incrementBusy: () => _itemInteractionController.IncrementBusyCount(), - canSend: () => _liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld); + canSend: () => _liveSessionController?.IsInWorld == true); _uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside; // Feed Silk input to the UiRoot tree so windows drag / close / select. @@ -2223,8 +2215,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm), Chat: new AcDream.App.UI.ChatRuntimeBindings( retailChatVm, - () => _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus) - AcDream.UI.Abstractions.NullCommandBus.Instance), + () => _liveSessionController?.Commands + ?? AcDream.UI.Abstractions.NullCommandBus.Instance), Radar: new AcDream.App.UI.RadarRuntimeBindings( radarSnapshotProvider.BuildSnapshot, _selection, @@ -2252,13 +2244,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext guid, AcDream.Core.Selection.SelectionChangeSource.Inventory), UseItemByGuid, (tab, position, spellId) => - _liveSession?.SendAddSpellFavorite(spellId, position, tab), + LiveSession?.SendAddSpellFavorite(spellId, position, tab), (tab, spellId) => - _liveSession?.SendRemoveSpellFavorite(spellId, tab), - filters => _liveSession?.SendSpellbookFilter(filters), - spellId => _liveSession?.SendRemoveSpell(spellId), + LiveSession?.SendRemoveSpellFavorite(spellId, tab), + filters => LiveSession?.SendSpellbookFilter(filters), + spellId => LiveSession?.SendRemoveSpell(spellId), (componentId, amount) => - _liveSession?.SendSetDesiredComponentLevel(componentId, amount), + LiveSession?.SendSetDesiredComponentLevel(componentId, amount), ClientTimerNow), JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings( () => _playerController?.JumpCharge ?? default), @@ -2284,10 +2276,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext () => LocalPlayer.GetAttribute( AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } strength ? (int?)strength.Current : null, - () => _liveSession?.LinkStatus + () => LiveSession?.LinkStatus ?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected, ClientTimerNow, - () => _liveSession?.RequestLinkStatusPing(), + () => LiveSession?.RequestLinkStatusPing(), () => _window?.Close()), Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings( Objects, @@ -2299,8 +2291,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext ItemMana, ToggleLiveCombatMode, _itemInteractionController, - entry => _liveSession?.SendAddShortcut(entry), - index => _liveSession?.SendRemoveShortcut(index), + entry => LiveSession?.SendAddShortcut(entry), + index => LiveSession?.SendRemoveShortcut(index), _selection, handler => Combat.HealthChanged += handler, handler => Combat.HealthChanged -= handler, @@ -2309,11 +2301,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Combat.GetHealthPercent, Combat.HasHealth, guid => (uint)(Objects.Get(guid)?.StackSize ?? 0), - guid => _liveSession?.SendQueryHealth(guid), - guid => _liveSession?.SendQueryItemMana(guid), + guid => LiveSession?.SendQueryHealth(guid), + guid => LiveSession?.SendQueryItemMana(guid), () => _playerServerGuid, (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement)), + LiveSession?.SendPutItemInContainer(item, container, placement)), Character: new AcDream.App.UI.CharacterRuntimeBindings(_characterSheetProvider), Inventory: new AcDream.App.UI.InventoryRuntimeBindings( Objects, @@ -2323,14 +2315,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext () => LocalPlayer.GetAttribute( AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } strength ? (int?)strength.Current : null, - guid => _liveSession?.SendUse(guid), + guid => LiveSession?.SendUse(guid), (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement), + LiveSession?.SendPutItemInContainer(item, container, placement), (item, container, placement, amount) => - _liveSession?.SendStackableSplitToContainer( + LiveSession?.SendStackableSplitToContainer( item, container, placement, amount), (source, target, amount) => - _liveSession?.SendStackableMerge(source, target, amount), + LiveSession?.SendStackableMerge(source, target, amount), _itemInteractionController, _selection), ExternalContainer: new AcDream.App.UI.ExternalContainerRuntimeBindings( @@ -2342,11 +2334,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext iconComposer.GetDragIcon(type, icon, under, over, effects), _itemInteractionController, _selection, - guid => _liveSession?.SendUse(guid), + guid => LiveSession?.SendUse(guid), (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement), + LiveSession?.SendPutItemInContainer(item, container, placement), (item, container, placement, amount) => - _liveSession?.SendStackableSplitToContainer( + LiveSession?.SendStackableSplitToContainer( item, container, placement, amount), IsWithinExternalContainerUseRange), Cursor: new AcDream.App.UI.RetailUiCursorBindings( @@ -2354,7 +2346,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext retailCursorManager), Confirmations: new AcDream.App.UI.ConfirmationRuntimeBindings( (type, context, accepted) => - _liveSession?.SendConfirmationResponse(type, context, accepted)), + LiveSession?.SendConfirmationResponse(type, context, accepted)), StackSplitQuantity: _stackSplitQuantity, Plugins: _uiRegistry, Persistence: persistence, @@ -2704,7 +2696,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _worldSelectionQuery, itemInteractions, new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport( - () => _liveSession), + () => LiveSession), new AcDream.App.Interaction.PlayerInteractionMovementSink( () => _playerController), text => _debugVm?.AddToast(text)); @@ -2854,107 +2846,63 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // gated behind ACDREAM_LIVE=1 so the default run path is unchanged. _liveCenterX = centerX; _liveCenterY = centerY; - TryStartLiveSession(); - } - - private void TryStartLiveSession() - { - ClearInboundEntityState(); - - // Step 2 (2026-05-16): delegate pre-Connect setup to LiveSessionController. - // The controller owns DNS resolution + WorldSession instantiation + the - // wireEvents callback; this method keeps the Connect → CharacterList → - // EnterWorld → post-setup dance because those touch GameWindow state. _liveSessionController = new AcDream.App.Net.LiveSessionController(); - _liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents); - if (_liveSession is null) + AcDream.App.Net.LiveSessionStartResult liveStart = + _liveSessionController.Start( + _options, + CreateLiveSessionLifecycleHost()); + switch (liveStart.Status) { - try - { - DisposeLiveSessionRouting(); - } - finally - { - _liveSessionController.Dispose(); - _liveSessionController = null; - } - return; - } - - var user = _options.LiveUser!; - var pass = _options.LivePass!; - var host = _options.LiveHost; - var port = _options.LivePort; - try - { - Chat.OnSystemMessage($"connecting to {host}:{port} as {user}", chatType: 1); - _liveSession.Connect(user, pass); - Chat.OnSystemMessage("connected — character list received", chatType: 1); - - if (_liveSession.Characters is null - || !AcDream.Core.Net.Messages.CharacterList.TrySelectFirstAvailable( - _liveSession.Characters, - out AcDream.Core.Net.Messages.CharacterList.Selection selection)) - { - Console.WriteLine("live: no available characters on account; disconnecting"); - try - { - DisposeLiveSessionRouting(); - } - finally - { - _liveSessionController.Dispose(); - _liveSessionController = null; - _liveSession = null; - } - ClearInboundEntityState(); - return; - } - - var chosen = selection.Character; - _playerServerGuid = chosen.Id; - _vitalsVm?.SetLocalPlayerGuid(chosen.Id); - Chat.SetLocalPlayerGuid(chosen.Id); - _worldState.MarkPersistent(chosen.Id); - AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = chosen.Id; // TEMP #138-B probe - Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); - Combat.Clear(); - _liveSession.EnterWorld(characterIndex: selection.ActiveIndex); - _liveSessionCommands?.Activate(); - - _activeToonKey = chosen.Name; - _retailUiRuntime?.RestoreLayout(); - SyncToolbarWindowButtons(); - if (_settingsStore is not null && _settingsVm is not null) - { - var toonBag = _settingsStore.LoadCharacter(_activeToonKey); - _settingsVm.LoadCharacterContext(toonBag); - Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences"); - } - _playerModeAutoEntry?.Arm(); - Console.WriteLine($"live: in world — CreateObject stream active " + - $"(so far: {_liveSpawnReceived} received, {_liveSpawnHydrated} hydrated)"); - } - catch (Exception ex) - { - Console.WriteLine($"live: session failed: {ex.Message}"); - try - { - DisposeLiveSessionRouting(); - } - finally - { - _liveSessionController?.Dispose(); - _liveSessionController = null; - _liveSession = null; - } - ClearInboundEntityState(); + case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials: + Console.WriteLine( + "live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"); + break; + case AcDream.App.Net.LiveSessionStartStatus.Failed: + Console.WriteLine($"live: session failed: {liveStart.Error}"); + break; } } - private void ClearInboundEntityState() + private AcDream.App.Net.LiveSessionLifecycleHost CreateLiveSessionLifecycleHost() => + new(new AcDream.App.Net.LiveSessionLifecycleBindings( + Bind: CreateLiveSessionBinding, + Reset: () => + (_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(), + Connecting: (host, port, user) => + Chat.OnSystemMessage( + $"connecting to {host}:{port} as {user}", + chatType: 1), + Connected: () => + Chat.OnSystemMessage( + "connected — character list received", + chatType: 1), + Selected: ApplyLiveSessionSelection, + Entered: ApplyLiveSessionEnteredWorld)); + + private void ApplyLiveSessionSelection( + AcDream.App.Net.LiveSessionCharacterSelection selection) { - (_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(); + _playerServerGuid = selection.CharacterId; + _vitalsVm?.SetLocalPlayerGuid(selection.CharacterId); + Chat.SetLocalPlayerGuid(selection.CharacterId); + _worldState.MarkPersistent(selection.CharacterId); + AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = selection.CharacterId; + Combat.Clear(); + } + + private void ApplyLiveSessionEnteredWorld( + AcDream.App.Net.LiveSessionCharacterSelection selection) + { + _activeToonKey = selection.CharacterName; + _retailUiRuntime?.RestoreLayout(); + SyncToolbarWindowButtons(); + if (_settingsStore is not null && _settingsVm is not null) + { + var toonBag = _settingsStore.LoadCharacter(_activeToonKey); + _settingsVm.LoadCharacterContext(toonBag); + Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences"); + } + _playerModeAutoEntry?.Arm(); } private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() => @@ -3087,12 +3035,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// Registration completes before Connect; outbound commands remain inert /// until EnterWorld succeeds. /// - private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session) + private AcDream.App.Net.LiveSessionBinding CreateLiveSessionBinding( + AcDream.Core.Net.WorldSession session) { - _liveSession = session; - if (_liveSessionEvents is not null || _liveSessionCommands is not null) - throw new InvalidOperationException("live-session routing is already bound"); - var skillTable = _dats?.Get(0x0E000004u); if (_characterSheetProvider is not null && _dats is not null) { @@ -3123,8 +3068,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext commands = new AcDream.App.Net.LiveSessionCommandRouter( CreateLiveSessionCommandBindings(session)); - _liveSessionEvents = events; - _liveSessionCommands = commands; + return new AcDream.App.Net.LiveSessionBinding( + session, + commands, + activateCommands: commands.Activate, + deactivateCommands: commands.Dispose, + detachEvents: events.Dispose); } catch { @@ -3311,22 +3260,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - private void DisposeLiveSessionRouting() - { - AcDream.App.Net.LiveSessionCommandRouter? commands = _liveSessionCommands; - AcDream.App.Net.LiveSessionEventRouter? events = _liveSessionEvents; - _liveSessionCommands = null; - _liveSessionEvents = null; - try - { - commands?.Dispose(); - } - finally - { - events?.Dispose(); - } - } - private enum LiveProjectionPurpose { LogicalRegistration, @@ -4936,8 +4869,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext uint guid, AcDream.App.World.AcceptedPhysicsTimestamps timestamps) { - if (guid != _playerServerGuid || _liveSession is null) return; - _liveSession.PublishAcceptedLocalPhysicsTimestamps( + AcDream.Core.Net.WorldSession? session = LiveSession; + if (guid != _playerServerGuid || session is null) return; + session.PublishAcceptedLocalPhysicsTimestamps( timestamps.Instance, timestamps.ServerControlledMove, timestamps.Teleport, @@ -6460,7 +6394,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// private void SendImmediateLocalPositionEvent() { - if (_liveSession is null + AcDream.Core.Net.WorldSession? session = LiveSession; + if (session is null || _playerController is null || !_playerController.CanSendPositionEvent) { @@ -6477,18 +6412,18 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext return; } - uint sequence = _liveSession.NextGameActionSequence(); + uint sequence = session.NextGameActionSequence(); var body = AcDream.Core.Net.Messages.AutonomousPosition.Build( gameActionSequence: sequence, cellId: cellId, position: position, rotation: rotation, - instanceSequence: _liveSession.InstanceSequence, - serverControlSequence: _liveSession.ServerControlSequence, - teleportSequence: _liveSession.TeleportSequence, - forcePositionSequence: _liveSession.ForcePositionSequence, + instanceSequence: session.InstanceSequence, + serverControlSequence: session.ServerControlSequence, + teleportSequence: session.TeleportSequence, + forcePositionSequence: session.ForcePositionSequence, lastContact: 1); - _liveSession.SendGameAction(body); + session.SendGameAction(body); _playerController.NotePositionSent( new AcDream.Core.Physics.Position(cellId, position, rotation), _playerController.ContactPlane, @@ -9409,8 +9344,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // The world-geometry RENDER gate (IsLiveModeWaitingForLogin at the // draw site) is unchanged — the pre-entry screen still shows sky // only. - bool liveInWorld = _liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld; + bool liveInWorld = _liveSessionController?.IsInWorld == true; // #192: liveInWorld alone used to be sufficient here — but InWorld fires // right after the login handshake, before the player's own spawn // CreateObject (and hence the real center) has arrived. Streaming that @@ -9456,8 +9390,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext observerCx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); } - else if (_liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld) + else if (liveInWorld) { // Live, not yet in player mode: the login auto-entry hold, or a live // fly-camera spectator. Follow the PLAYER's server-known landblock; if it @@ -9657,7 +9590,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _playerController.State = AcDream.App.Input.PlayerState.InWorld; // holtburger client/messages.rs:434 — re-send LoginComplete after // each portal transition. - _liveSession?.SendGameAction( + LiveSession?.SendGameAction( AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); _worldReveal?.Complete(); ResetTeleportTransitState(); @@ -9682,7 +9615,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Phase K.2 — auto-enter player mode at login. The guard // returns true on the firing tick (one-shot); subsequent ticks - // are no-ops. Skipped offline (no _liveSession → IsLiveInWorld + // are no-ops. Skipped offline (no active session → IsLiveInWorld // predicate stays false). Cancelled by manual fly-toggle in // OnInputAction (Ctrl+Tab → TogglePlayerMode) or DebugPanel. _playerModeAutoEntry?.TryEnter(); @@ -11038,8 +10971,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // is up so panel-emitted SendChatCmd actually flows server-ward. // Fall back to NullCommandBus for offline / pre-connect renders. AcDream.UI.Abstractions.ICommandBus bus = - _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus) - AcDream.UI.Abstractions.NullCommandBus.Instance; + _liveSessionController?.Commands + ?? AcDream.UI.Abstractions.NullCommandBus.Instance; var ctx = new AcDream.UI.Abstractions.PanelContext( (float)deltaSeconds, bus); @@ -12519,7 +12452,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel; private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm; // L.0: settings.json store + active toon key. The store is held as - // a field so BeginLiveSessionAsync can re-load the chosen toon's + // a field so ApplyLiveSessionEnteredWorld can re-load the chosen toon's // bag once we know its name (post-EnterWorld). Toon key starts as // "default" and gets swapped to the actual character name on the // first EnterWorld. @@ -12654,7 +12587,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _persistedGameplay = _settingsStore.LoadGameplay(); _persistedChat = _settingsStore.LoadChat(); // _activeToonKey is "default" pre-EnterWorld; the post-login - // branch in BeginLiveSessionAsync swaps to the chosen toon's + // ApplyLiveSessionEnteredWorld swaps to the chosen toon's // name and re-loads via SettingsVM.LoadCharacterContext. _persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey); @@ -13153,8 +13086,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private void ToggleLiveCombatMode() { - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + AcDream.Core.Net.WorldSession? session = LiveSession; + if (_liveSessionController?.IsInWorld != true || session is null) return; IReadOnlyList orderedEquipment = @@ -13165,7 +13098,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Combat.CurrentMode, defaultMode); _itemInteractionController?.NotifyExplicitCombatModeRequest(); - _liveSession.SendChangeCombatMode(nextMode); + session.SendChangeCombatMode(nextMode); Combat.SetCombatMode(nextMode); string text = $"Combat mode {nextMode}"; Console.WriteLine($"combat: {text}"); @@ -13185,8 +13118,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private bool CanStartLiveCombatAttack() { - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + if (_liveSessionController?.IsInWorld != true) return false; if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)) @@ -13214,16 +13146,20 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext if (!CanStartLiveCombatAttack()) return false; + AcDream.Core.Net.WorldSession? session = LiveSession; + if (session is null) + return false; + uint target = _selection.SelectedObjectId!.Value; power = Math.Clamp(power, 0f, 1f); if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile) { - _liveSession!.SendMissileAttack(target, height, power); + session.SendMissileAttack(target, height, power); Console.WriteLine($"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}"); } else { - _liveSession!.SendMeleeAttack(target, height, power); + session.SendMeleeAttack(target, height, power); Console.WriteLine($"combat: melee attack target=0x{target:X8} height={height} power={power:F2}"); } return true; @@ -13252,7 +13188,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result) => _localPlayerOutbound.TrySendMovement( - _liveSession, + LiveSession, _playerController, result); @@ -13291,11 +13227,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // sends directly without speculative TurnToObject/MoveToObject. private void UseItemByGuid(uint guid) { - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + AcDream.Core.Net.WorldSession? session = LiveSession; + if (_liveSessionController?.IsInWorld != true || session is null) return; - uint sequence = _liveSession.NextGameActionSequence(); - _liveSession.SendGameAction( + uint sequence = session.NextGameActionSequence(); + session.SendGameAction( AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid)); Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={sequence}"); } @@ -13337,8 +13273,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private void TogglePlayerMode() { // Phase B.2 guard: only active when a live session is in-world. - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) + if (_liveSessionController?.IsInWorld != true) return; // Manual toggle pre-empts the K.2 auto-entry trigger regardless @@ -14223,14 +14158,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _magicCatalog = null; }), new("streamer", () => _streamer?.Dispose()), - new("live session routing", DisposeLiveSessionRouting), - new("equipped children", () => _equippedChildRenderer?.Dispose()), new("live session", () => { _liveSessionController?.Dispose(); _liveSessionController = null; - _liveSession = null; }), + new("equipped children", () => _equippedChildRenderer?.Dispose()), ]), // Retained tombstones are retried while every callback owner is alive. new ResourceShutdownStage("live entities", diff --git a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs new file mode 100644 index 00000000..8d65f140 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.App.Net; +using AcDream.Core.Net; + +namespace AcDream.App.Tests.Net; + +public sealed class GameWindowLiveSessionOwnershipTests +{ + private const BindingFlags PrivateInstance = + BindingFlags.Instance | BindingFlags.NonPublic; + + [Fact] + public void GameWindowRetainsOnlyTheLifecycleControllerSessionOwner() + { + FieldInfo[] fields = typeof(GameWindow).GetFields(PrivateInstance); + + Assert.Contains( + fields, + field => field.Name == "_liveSessionController" + && field.FieldType == typeof(LiveSessionController)); + Assert.DoesNotContain(fields, field => field.Name == "_liveSession"); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(WorldSession)); + Assert.DoesNotContain(fields, field => field.Name == "_liveSessionEvents"); + Assert.DoesNotContain(fields, field => field.Name == "_liveSessionCommands"); + } + + [Theory] + [InlineData("TryStartLiveSession")] + [InlineData("ClearInboundEntityState")] + [InlineData("WireLiveSessionEvents")] + [InlineData("DisposeLiveSessionRouting")] + public void DisplacedLifecycleBodiesAreAbsent(string methodName) + { + Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateInstance)); + } +} diff --git a/tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs new file mode 100644 index 00000000..a0ed5b26 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionLifecycleHostTests.cs @@ -0,0 +1,110 @@ +using System.Net; +using AcDream.App.Net; +using AcDream.Core.Net; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Tests.Net; + +public sealed class LiveSessionLifecycleHostTests +{ + [Fact] + public void HostRoutesLifecycleAndReleasesOnlyTheExactBoundSession() + { + var calls = new List(); + using var sessionA = CreateSession(9000); + using var sessionB = CreateSession(9001); + var host = CreateHost(calls); + + LiveSessionBinding binding = host.BindSession(sessionA); + host.ResetSessionState(); + host.ReportConnecting("host", 9000, "user"); + host.ReportConnected(); + var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account"); + host.ApplySelectedCharacter(selection); + binding.ActivateCommands(); + host.ApplyEnteredWorld(selection); + + Assert.Throws(() => host.DetachSession(sessionB)); + binding.Dispose(); + host.DetachSession(sessionA); + LiveSessionBinding replacement = host.BindSession(sessionB); + + Assert.Equal( + [ + "bind", "reset", "connecting:host:9000:user", + "connected", "selected:toon", "activate", "entered:toon", + "deactivate", "detach-events", "bind", + ], + calls); + replacement.Dispose(); + host.DetachSession(sessionB); + } + + [Fact] + public void FailedBindingFactoryDoesNotClaimTheHost() + { + var calls = new List(); + using var session = CreateSession(9000); + bool fail = true; + LiveSessionLifecycleHost host = CreateHost(calls, _ => + { + if (fail) + { + fail = false; + throw new InvalidOperationException("bind failure"); + } + return CreateBinding(session, calls); + }); + + Assert.Throws(() => host.BindSession(session)); + LiveSessionBinding retry = host.BindSession(session); + + retry.Dispose(); + host.DetachSession(session); + } + + private static LiveSessionLifecycleHost CreateHost( + List calls, + Func? bind = null) => + new(new LiveSessionLifecycleBindings( + Bind: bind ?? (session => CreateBinding(session, calls)), + Reset: () => calls.Add("reset"), + Connecting: (host, port, user) => + calls.Add($"connecting:{host}:{port}:{user}"), + Connected: () => calls.Add("connected"), + Selected: selection => calls.Add($"selected:{selection.CharacterName}"), + Entered: selection => calls.Add($"entered:{selection.CharacterName}"))); + + private static LiveSessionBinding CreateBinding( + WorldSession session, + List calls) + { + calls.Add("bind"); + return new LiveSessionBinding( + session, + NullCommandBus.Instance, + activateCommands: () => calls.Add("activate"), + deactivateCommands: () => calls.Add("deactivate"), + detachEvents: () => calls.Add("detach-events")); + } + + private static WorldSession CreateSession(int port) => + new( + new IPEndPoint(IPAddress.Loopback, port), + new TestTransport()); + + private sealed class TestTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) { } + + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + + public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) + { + from = null; + return null; + } + + public void Dispose() { } + } +} From 485b3fb6edc7d7bbde2a6428e9edbee24a174c15 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 12:37:38 +0200 Subject: [PATCH 36/98] docs(plan): record GameWindow session cutover --- docs/plans/2026-07-21-gamewindow-slice-3-live-session.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md index d7dab902..b4fc6b7c 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md +++ b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md @@ -41,8 +41,13 @@ character-selection UI or redesign the transport. convergent staged teardown, transport-free deterministic session tests, and invalid-endpoint validation before UDP allocation. Three-agent review clean. Release: 6,707 passed / 5 skipped. -- [ ] E — GameWindow cutover (in progress). -- [ ] F — final review, connected gate, and documentation closeout. +- [x] E — GameWindow cutover (`6a5d9e2e`): one controller field, focused + lifecycle host, transactional event/command binding, call-time borrowed + session resolution, controller-backed retained/dev UI command providers, + unified shutdown, and structural regression gates proving the displaced + fields and lifecycle bodies remain absent. Three-agent review clean. + Release: 6,714 passed / 5 skipped. +- [ ] F — final review, connected gate, and documentation closeout (in progress). ## 1. Outcome From ed9dc36643be230135c94fbd4b94b97da44573ed Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 12:44:48 +0200 Subject: [PATCH 37/98] docs(architecture): close live-session ownership slice --- AGENTS.md | 18 ++++--- CLAUDE.md | 18 ++++--- docs/ISSUES.md | 3 +- docs/architecture/acdream-architecture.md | 12 +++-- docs/architecture/code-structure.md | 47 ++++++++++++------- docs/plans/2026-04-11-roadmap.md | 16 +++++-- docs/plans/2026-05-12-milestones.md | 7 +-- ...6-07-21-gamewindow-slice-3-live-session.md | 9 +++- 8 files changed, 80 insertions(+), 50 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6a5b179b..898b51c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,19 +115,17 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 6,575 tests pass / 5 skip (17 tracked warnings, #228). +Release builds; 6,714 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. -Selection/interaction Slice 1 is complete: `WorldSelectionQuery` and -`SelectionInteractionController` own the domain, exact-incarnation queued work, -and retail's shared inventory-request transaction. Live-animation-presentation -Slice 2 is also complete: `LiveEntityAnimationPresenter` owns final visual and -rigid PartArray composition, effect poses, and MotionDone; scheduler handoffs -are incarnation-, clock-, projection-, and appearance-bound. `GameWindow` is -14,546 lines, 277 fields, and 190 methods. Slice 3 completes live-session -ownership next; later slices finish live-entity App integration, landblock -presentation, update/render orchestration, and composition cleanup. +Selection/interaction Slice 1, live-animation-presentation Slice 2, and +complete live-session ownership Slice 3 are landed. `LiveSessionController` +now owns the exact resolve/connect/enter/tick/reconnect/graceful-close +generation; GameWindow has no parallel session/router/command fields or old +lifecycle bodies. `GameWindow` is 14,310 lines, 274 fields, and 190 methods. +Slice 4 extracts live-entity App integration next; later slices finish +landblock presentation, update/render orchestration, and composition cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/CLAUDE.md b/CLAUDE.md index 2ed93be2..a1743ce6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,19 +113,17 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 6,575 tests pass / 5 skip (17 tracked warnings, #228). +Release builds; 6,714 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. -Selection/interaction Slice 1 is complete: `WorldSelectionQuery` and -`SelectionInteractionController` own the domain, exact-incarnation queued work, -and retail's shared inventory-request transaction. Live-animation-presentation -Slice 2 is also complete: `LiveEntityAnimationPresenter` owns final visual and -rigid PartArray composition, effect poses, and MotionDone; scheduler handoffs -are incarnation-, clock-, projection-, and appearance-bound. `GameWindow` is -14,546 lines, 277 fields, and 190 methods. Slice 3 completes live-session -ownership next; later slices finish live-entity App integration, landblock -presentation, update/render orchestration, and composition cleanup. +Selection/interaction Slice 1, live-animation-presentation Slice 2, and +complete live-session ownership Slice 3 are landed. `LiveSessionController` +now owns the exact resolve/connect/enter/tick/reconnect/graceful-close +generation; GameWindow has no parallel session/router/command fields or old +lifecycle bodies. `GameWindow` is 14,310 lines, 274 fields, and 190 methods. +Slice 4 extracts live-entity App integration next; later slices finish +landblock presentation, update/render orchestration, and composition cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b988fa83..e4ba7c64 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -27,7 +27,8 @@ What does NOT go here: ## Current queue — 2026-07-21 - **Active structural work:** `GameWindow` decomposition. Selection/interaction - ownership is complete; Slice 2 live animation presentation is next in + ownership, live animation presentation, and complete live-session ownership + are landed; Slice 4 live-entity App integration is next in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. - **Separate rendering gate:** `#225`, lifestone/particle alpha ordering. Its diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 97e0556f..1b2c5e75 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -834,11 +834,13 @@ Current movement/collision ownership: Linux/headless extraction. - **The behavior-preserving thin-`GameWindow` ownership campaign is the active prerequisite before new M4 subsystem bodies.** Selection/interaction Slice 1 - landed 2026-07-21 with `WorldSelectionQuery`, - `SelectionInteractionController`, and the shared retail inventory-request - owner; live animation presentation is next. The reconciled ledger and - sequence live in `docs/architecture/code-structure.md`. This decision does - not reclassify the remaining L.2 conformance work or Track LH. + landed with `WorldSelectionQuery`, `SelectionInteractionController`, and the + shared retail inventory-request owner; Slice 2 moved final animation + presentation into `LiveEntityAnimationPresenter`; Slice 3 moved the complete + session generation into `LiveSessionController` plus its focused host and + routers. Slice 4 live-entity App integration is next. The reconciled ledger + and sequence live in `docs/architecture/code-structure.md`. This decision + does not reclassify the remaining L.2 conformance work or Track LH. Full `GameEntity` type aggregation remains a separate, higher-risk target after the thin-`GameWindow` campaign. The current campaign reuses canonical diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index cb96795c..0f1e6591 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -1,7 +1,7 @@ # acdream — code structure & extraction sequence **Status:** Living document. Created 2026-05-16; implementation reconciliation -completed 2026-07-21; Slices 1 and 2 landed the same day. This is the active +completed 2026-07-21; Slices 1–3 landed the same day. This is the active structural program before new M4 subsystems enter the App layer. **Purpose:** Describe the desired structural state of the App layer, explain the rules we've adopted, and lay out the safe extraction @@ -25,15 +25,16 @@ The structural debt is concentrated in **one file**: baseline cf50ee3d 15,723 lines / 278 fields / 205 methods after Slice 1 14,912 lines / 278 fields / 191 methods after Slice 2 14,546 lines / 277 fields / 190 methods +after Slice 3 14,310 lines / 274 fields / 190 methods ``` `GameWindow` is the single object that: - Owns the GL context, the window, input, and shaders. - Reads ~40 different environment variables across its lifetime. -- Still owns live-session bootstrap, character entry, roughly 440 lines of - event subscription, and most App-side packet handlers even though - `LiveSessionController` owns the socket/session shell. +- Composes the shipped `LiveSessionController`/host/router boundary; it no + longer owns a parallel session, command bus, subscription lifetime, connect, + character-entry, reconnect, or graceful-close body. - Owns the adapters that hydrate canonical `LiveEntityRuntime` records into animation, collision, rendering, and DAT-backed appearance resources. - Composes the shipped `WorldSelectionQuery` and @@ -187,6 +188,7 @@ src/AcDream.App/ │ └── Vfx/ # (already exists) ├── Net/ │ ├── LiveSessionController.cs # owns complete WorldSession connect/enter/logout/reconnect lifecycle +│ ├── LiveSessionLifecycleHost.cs # narrow App composition + exact borrowed-session attachment │ └── LiveSessionEventRouter.cs # typed subscriptions → focused domain handlers ├── Physics/ │ ├── ProjectileController.cs # canonical live-record projectile orchestration @@ -387,7 +389,7 @@ useful ordering seam, but its ownership status is **partial**. | Area | Status | Current truth | |---|---|---| | Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. | -| Network session | **Partial** | `LiveSessionController` owns endpoint resolution, `WorldSession` construction, ticking, and disposal (`0b25df53`). `TryStartLiveSession`, character choice/entry, session clearing, about 440 lines of subscriptions, and most App packet handlers remain in `GameWindow`. | +| Network session | **Complete** | `LiveSessionController` owns resolve/create/bind/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal; `LiveSessionLifecycleHost` is the narrow App composition boundary; `LiveSessionEventRouter` and `LiveSessionCommandRouter` own exact inbound/outbound lifetimes. `GameWindow` retains one controller field and focused domain sink factories only (`d9ccf8a6`, `6a5d9e2e`). | | Live identity/lifetime | **Complete core owner** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, logical/spatial lifetime, and teardown. `GameWindow` still owns large hydration and wire-to-presentation adapter bodies. | | Inbound/object-frame order | **Partial** | `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveEntityAnimationScheduler`, `LiveEntityAnimationPresenter`, static scheduler, remote/projectile updaters, and teleport owners are shipped. `AdvanceLiveObjectRuntimeCore` retains the visible cross-owner order until Slice 6. | | World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. | @@ -468,19 +470,32 @@ rigid trailing poses. `GameWindow` lost 366 lines, two methods, and one net field. Three independent retail, architecture, and adversarial review loops finished clean; focused App/Core tests and the complete App suite pass. -#### Slice 3 — complete live-session ownership — NEXT +#### Slice 3 — complete live-session ownership — COMPLETE 2026-07-21 -Expand `LiveSessionController` to own connect, character-list validation, -character selection, EnterWorld, graceful character logout, reconnect, and -session-scoped disposal. Add `LiveSessionEventRouter` for typed subscriptions; -it routes events to focused domain handlers and owns unsubscription, but owns -no entity or UI state. +Detailed execution plan: +[`docs/plans/2026-07-21-gamewindow-slice-3-live-session.md`](../plans/2026-07-21-gamewindow-slice-3-live-session.md). -Move `TryStartLiveSession`, `ClearInboundEntityState`, and -`WireLiveSessionEvents` out of `GameWindow`. Do not create a giant context bag -or a second world-state owner. The connected gate must prove login, fresh world -reveal, command/chat, portal, exact `0xF653` logout, ACE endpoint disconnect, -and fresh-process reconnect. +`LiveSessionController` now owns the complete resolve/create/bind/Connect/ +CharacterList/select/EnterWorld/Tick/stop/reconnect/dispose transaction. Exact +generation and operation-depth gates prevent synchronous lifecycle callbacks +from resurrecting a superseded session. Staged teardown makes commands inert, +detaches events, gracefully disposes the exact `WorldSession`, detaches the +host, and runs the full reset manifest to convergence before reuse. + +`LiveSessionLifecycleHost` is the narrow App adapter; the router and command +owners are transactional and session-bound. `GameWindow` lost `_liveSession`, +the router/command fields, and the old startup/reset/wiring/disposal methods. +Both UI stacks resolve `controller.Commands`; direct feature sends borrow +`CurrentSession` at call time. Forty-seven controller cases plus host, +transport-allocation, and structural gates cover failure/reentrancy/reuse. +Three independent retail, architecture, and adversarial reviews finished +clean. The Release suite passes 6,714 tests with five intentional skips. The +306-second connected lifecycle gate passed capped login, five travel/revisit +checkpoints, exact graceful close, and uncapped fresh-process reconnect; both +client processes exited normally. + +`GameWindow` fell another 236 lines and three fields to 14,310 lines / 274 +fields / 190 methods. Slice 4 is next. #### Slice 4 — extract live-entity App integration diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index f9353755..9deb537e 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -28,9 +28,19 @@ projection, object-clock, and appearance-revision tokens reject stale work; ordinary/static frame handoffs own their buffers; retail short AnimFrames retain trailing visible and rigid poses. `GameWindow` is now 14,546 lines. -**Next:** Slice 3 completes live-session ownership by moving connection, -character entry, typed event subscriptions, graceful logout, reconnect, and -session-scoped cleanup behind `LiveSessionController`/`LiveSessionEventRouter`. +Slice 3 complete live-session ownership landed 2026-07-21: one +`LiveSessionController` now owns resolve through graceful teardown/reconnect, +with exact-generation reentrancy gates, transactional typed routing, dynamic +UI command publication, and convergent session reset. The old GameWindow +session/router/command fields and lifecycle bodies are gone. `GameWindow` is +now 14,310 lines, 274 fields, and 190 methods; the Release suite passes 6,714 +tests with five intentional skips. Its connected gate passed capped login, +five travel/revisit checkpoints, graceful close, and uncapped fresh-process +reconnect in 306 seconds. + +**Next:** Slice 4 extracts live-entity App integration into focused hydration +and network-update controllers over the existing canonical +`LiveEntityRuntime`, without adding a second GUID or resource owner. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 009d6bc8..51a2bb13 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -17,9 +17,10 @@ layer, the active structural prerequisite is the behavior-preserving `GameWindow` decomposition in [`docs/architecture/code-structure.md`](../architecture/code-structure.md). The 2026-07-21 audit baseline was 15,723 lines, 278 fields, and 205 methods. -Selection/interaction Slice 1 and live-animation-presentation Slice 2 are -complete. `GameWindow` is now 14,546 lines, 277 fields, and 190 methods. Slice 3 -— complete live-session ownership — is next. +Selection/interaction Slice 1, live-animation-presentation Slice 2, and +complete live-session ownership Slice 3 are complete. `GameWindow` is now +14,310 lines, 274 fields, and 190 methods. Slice 4 — live-entity App +integration — is active next. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md index b4fc6b7c..d05095fd 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md +++ b/docs/plans/2026-07-21-gamewindow-slice-3-live-session.md @@ -1,6 +1,6 @@ # GameWindow Slice 3 — complete live-session ownership -**Status:** Active 2026-07-21. +**Status:** Complete 2026-07-21. **Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 3. **Baseline:** `9a150e24`; `GameWindow.cs` is 14,546 lines, 277 fields, and 190 methods before this slice. @@ -47,7 +47,12 @@ character-selection UI or redesign the transport. unified shutdown, and structural regression gates proving the displaced fields and lifecycle bodies remain absent. Three-agent review clean. Release: 6,714 passed / 5 skipped. -- [ ] F — final review, connected gate, and documentation closeout (in progress). +- [x] F — final review, connected gate, and documentation closeout. All three + independent review tracks finished clean. Release: 6,714 passed / 5 skipped. + The 306-second connected lifecycle gate passed capped login, five travel/ + revisit checkpoints, exact graceful exit, and uncapped fresh-process + reconnect; both client processes exited normally. The sole warning was 25 + expected world-edge landblock misses already classified by the gate. ## 1. Outcome From db5a11707dd77e1ad3c11b0677552f5984b4cb10 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 12:59:45 +0200 Subject: [PATCH 38/98] docs(plan): pin live-entity integration slice --- docs/architecture/code-structure.md | 4 + .../retail-divergence-register.md | 6 +- ...ewindow-slice-4-live-entity-integration.md | 360 ++++++++++++++++++ 3 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 0f1e6591..625a0b6e 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -499,6 +499,10 @@ fields / 190 methods. Slice 4 is next. #### Slice 4 — extract live-entity App integration +Detailed execution ledger: +[`docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md`](../plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md) +(active). + This is two owners, not one replacement god object: - `LiveEntityHydrationController` owns CreateObject/ObjDesc/Parent/Pickup/Delete diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a4ce63c6..24a5d55c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -106,7 +106,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 85 active rows +## 3. Documented approximation (AP) — 87 active rows 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 @@ -180,7 +180,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-75 | **NARROWED 2026-07-19 — adapter-boundary `adjust_motion` only.** `SetCycle` remaps TurnLeft/SideStepLeft/WalkBackward to their mirror command with negated speed before dispatch. Retail performs that normalization in `CMotionInterp`; GameWindow's local-player adapter can still pass raw ids directly | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap) | Preserves raw local callers until every caller enters through `MotionInterpreter`; literal DAT velocity and omega now flow through CSequence's complete Frame | A future caller that already normalizes a raw left/back command but still passes the original id can be adjusted twice | `CMotionInterp::adjust_motion` @305343; retire with the remaining local caller unification | | AP-77 | **NARROWED 2026-07-19 — animation-less/headless movement fallback only.** When `MotionInterpreter.DefaultSink` or the local PartArray callback is absent, acdream writes grounded command-derived body velocity and applies the DAT-pinned Humanoid `TurnRight` rate (1.5 radians/second) directly to the body Frame. Production animated players/remotes bind `MotionTableDispatchSink` plus CSequence and instead consume the complete DAT-authored root Frame; that path preserves airborne orientation while suppressing only origin exactly like retail | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`); `src/AcDream.App/Input/PlayerMovementController.cs` (no-PartArray object-quantum fallback) | Keeps isolated/headless physics tests and a deliberately animation-less entity controllable without fabricating a PartArray | A future production entity missing its animation binding uses Humanoid-only yaw/velocity, can foot-slide, and can rotate through an airborne quantum differently from a real creature's DAT Frame | `CMotionInterp::apply_interpreted_movement` 0x00528600; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire when animation-less production objects have an explicit motion owner | | AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) | -| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) | +| AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks), moving to `LiveEntityNetworkUpdateController` in Slice 4 | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. | | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | | AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) | | AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | @@ -214,6 +214,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | | AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | | AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` | +| AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Rendering/GameWindow.cs` (`RouteSameGenerationCreateObject`); moving to `LiveEntityNetworkUpdateController` in Slice 4 | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | +| AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); App same-generation route in `GameWindow.cs`, moving to `LiveEntityNetworkUpdateController` in Slice 4 | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` | ## 4. Temporary stopgap (TS) — 34 active rows + 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) diff --git a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md new file mode 100644 index 00000000..687bedc0 --- /dev/null +++ b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md @@ -0,0 +1,360 @@ +# GameWindow Slice 4 — live-entity App integration + +**Status:** Active 2026-07-21. +**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 4. +**Baseline:** `ed9dc366`; `GameWindow.cs` is 14,310 lines, 274 fields, and +190 methods before this slice. Release baseline: 6,714 passed / 5 skipped. +**Behavior rule:** Preserve the accepted live-object identity, packet FIFO, +independent timestamp authorities, render/physics/effect lifetime, and connected +R6 behavior while moving their App integration to focused owners. Correct only +proven retail-conformance defects with an explicit oracle and test; do not hide +them inside a mechanical extraction. + +**Progress ledger:** + +- [ ] A — canonical seams: `LiveWorldOriginState`, single-bind runtime-component + lifecycle bridge, extracted `RemoteMotion`, and canonical physics-host + ownership. No wire or presentation behavior changes. +- [ ] B — pure appearance/projection helpers and controller-level + characterization tests. +- [ ] C — hydration create/materialize/landblock-recovery ownership and direct + CreateObject/streaming cutover. +- [ ] D — ObjDesc/Parent/Pickup/ready/withdraw ownership and exact retained + identity across leave-world/re-entry. +- [ ] E — Delete/prune and retryable exact-incarnation teardown ownership. +- [ ] F — Position/Vector/State/Movement controller, same-generation CreateObject + tail, independent authority gates, and direct inbound cutover. +- [ ] G — `GameWindow` cleanup, three-agent review cycle, full Release suite, + connected gates, documentation, and durable memory closeout. + +## 1. Outcome and non-goals + +This slice creates two cooperating App owners: + +- `LiveEntityHydrationController` owns CreateObject, ObjDesc, Parent, Pickup, + Delete/prune, DAT-backed appearance hydration, first materialization, + landblock rehydration, ready publication, projection withdrawal, and exact + App-component teardown over `LiveEntityRuntime`. +- `LiveEntityNetworkUpdateController` owns accepted Position, Vector, State, + and Movement routing into the existing motion, physics, projectile, + presentation, and teleport owners. It also consumes the same-generation + CreateObject update tail without reconstructing the entity. + +`LiveEntityRuntime` remains the only owner of server GUID ↔ local identity, +incarnation, accepted snapshots, independent authority versions, spatial +worksets, and teardown tombstones. Neither new controller may contain a GUID +dictionary or cache an incarnation outside the runtime record it is currently +validating. + +This is not a new entity model, physics algorithm, renderer, packet decoder, or +DAT reader. It does not retire the documented future-packet, visibility-PVS, +or null-parent-queue divergences. It does not redesign the accepted update +thread or `RetailInboundEventDispatcher` FIFO. + +## 2. Retail and shipped oracles + +The extraction is pinned to: + +- `SmartBox::HandleCreateObject @ 0x00454C80`; +- `ACCObjectMaint::CreateObject @ 0x00558870`; +- `SmartBox::ProcessObjectNetBlobs @ 0x00454B20` and + `QueueBlobForObject @ 0x00451B90`; +- `SmartBox::HandleReceivedPosition @ 0x00453FD0`; +- `SmartBox::DoPickupEvent @ 0x00452240` and + `DoParentEvent @ 0x00452290`; +- `SmartBox::HandleDeleteObject @ 0x00451EA0`; +- `CPhysicsObj::set_description @ 0x00514F40`; +- `CPhysicsObj::set_state @ 0x00514DD0`; +- `CPhysicsObj::change_cell @ 0x00513390`; +- `CPhysicsObj::exit_world @ 0x00514E60` and + `leave_world @ 0x005155A0`; +- [`2026-07-13-retail-projectile-vfx-pseudocode.md`](../research/2026-07-13-retail-projectile-vfx-pseudocode.md); +- [`2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md`](../research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md); +- [`2026-07-19-r6-update-object-order-pseudocode.md`](../research/2026-07-19-r6-update-object-order-pseudocode.md); +- ACE and Holtburger object-update handlers as interpretation and protocol + cross-checks, never as replacements for the named-retail ordering. + +The following rules are load-bearing: + +1. One logical object exists per accepted `INSTANCE_TS`. Equal generation + mutates it; newer generation tears down and replaces it; older generation + is ignored using retail's wrap-safe comparison, including the `0x8000` + boundary. +2. A fresh CreateObject constructs, describes, attaches, enters world, then + replays queued blobs. An equal-generation CreateObject applies ObjDesc, + Parent or Position/Pickup, Movement, State, Vector, then WeenieDesc without + recreating the object. +3. Position, Movement, State, Vector, ObjDesc, Teleport, ForcePosition, + ServerControlledMove, and Instance authorities remain independent. After + every reentrant callback, the exact record and relevant authority version + are revalidated. +4. Movement consumes strict `MOVEMENT_TS` before it can discover a stale + server-controlled timestamp; equality on the latter is accepted. +5. Parent, Pickup, and Position share `POSITION_TS`. Pickup unparents and + leaves world; a later fresh Position unparents and restores the same object + identity/resources. +6. `leave_world` is not deletion. It clears cell/shadow/contact/transient + presentation while retaining PartArray, movement, animation, scripts, + effects, timestamps, and identity. Logical destruction alone performs + `exit_world` manager cleanup. +7. Inbound processing is one FIFO. Nested heterogeneous packets wait until the + active packet reaches its complete tail. + +## 3. Architecture and interfaces + +### 3.1 Shared origin state + +`LiveWorldOriginState` owns the session-scoped world center currently spread +across `_liveCenterX`, `_liveCenterY`, and `_liveCenterKnown`: + +```csharp +bool IsKnown { get; } +double CenterX { get; } +double CenterY { get; } +bool TryInitialize(double worldX, double worldY); +void Recenter(double worldX, double worldY); +void Reset(); +``` + +Hydration, accepted Position/teleport, streaming, and render consumers share +this state directly. No GameWindow callback conceals origin mutation. + +### 3.2 Runtime component lifecycle + +Introduce: + +```csharp +interface ILiveEntityRuntimeComponentLifecycle +{ + void TearDown(LiveEntityRecord exactRecord); +} +``` + +`DeferredLiveEntityRuntimeComponentLifecycle` is a composition-only, +single-assignment bridge. Construction order is fixed: + +1. construct the unbound bridge; +2. construct `LiveEntityRuntime` with the bridge; +3. construct effect, projectile, presentation, child, and related owners; +4. construct hydration; +5. bind the bridge exactly once to hydration; +6. construct and bind the network-update sink; +7. only then permit a live session to start. + +Calling before bind fails fast; second bind is rejected. This removes the +circular GameWindow teardown façade without making runtime mutation optional. +The existing `CompositeLiveEntityResourceLifecycle` remains the sole logical +mesh/script registration transaction; hydration does not become a second +resource owner. + +### 3.3 Canonical runtime components + +Move `RemoteMotion` out of `GameWindow` into `AcDream.App.Physics` without +semantic changes. Store physics-host resolution on the exact +`LiveEntityRecord` (or an explicit runtime-owned component index) rather than +the duplicate `_physicsHosts` GUID dictionary. Local-player host publication +uses the same focused owner, not a second identity table. Preserve the full-cell +binding and remote-placement contract. + +### 3.4 Hydration API + +The controller surface is narrow and event-shaped: + +```csharp +void OnCreate(WorldSession.EntitySpawn spawn); +void OnObjDesc(ObjDescEvent.Parsed update); +void OnDelete(DeleteObject.Parsed update); +void OnPrune(LiveEntityPruneCandidate candidate); +void OnPickup(PickupEvent.Parsed update); +void OnParent(ParentEvent.Parsed update); +bool TryAcceptParentForRender(ParentEvent.Parsed update); +void OnLandblockLoaded(uint landblockId); +bool EnsureWorldOriginAndRecover( + LiveEntityRecord expected, + ulong positionAuthorityVersion, + WorldSession.EntitySpawn acceptedSpawn); +void OnEntityReady(uint guid); +bool WithdrawWorldProjection(uint guid); +``` + +Hydration receives focused concrete collaborators/ports: runtime and object +table, the sole `DatCollection` and shared DAT lock, physics data cache, +spawn/animation/collision builders, spatial state/origin/streaming, and the +existing effect/projectile/child/presentation/light owners. It must not receive +a generic GameWindow context or a bundle of callbacks to old substantial +methods. + +ObjDesc mutates the captured exact entity. Acquire-before-publish rollback and +post-callback incarnation checks preserve local ID, body, motion, physics host, +effect owner, and parent state. Parent-on-spawn may reenter; the canonical +snapshot is reread afterward. A materialized object only rebuckets; a pending, +never-materialized object hydrates once. Landblock load never replays +create-time logical resource activation. + +### 3.5 Network-update API + +The network owner accepts Position, Vector, State, Movement, and the equal- +generation CreateObject tail. It resolves through `LiveEntityRuntime`, captures +the relevant authority version, calls existing focused runtime owners, and +revalidates exact record plus authority after every callback. + +Hydration calls a once-bound `ILiveEntityNetworkUpdateSink` for the +same-generation tail. Network position recovery calls +`EnsureWorldOriginAndRecover`; no substantial dispatch returns to GameWindow. +Local ForcePosition, teleport presentation, projectile corrections, shadow +updates, movement animation, and static/headless branches retain their shipped +ordering. + +### 3.6 Direct composition + +At slice exit these callbacks point directly to the new owners: + +- session Create/Delete/Pickup/Parent/ObjDesc/Position/Vector/State/Movement; +- streaming landblock-loaded rehydration; +- liveness prune; +- equipped-child parent acceptance and entity-ready replay; +- runtime exact-component teardown. + +`GameWindow` keeps controller construction plus narrow frame calls. The old +handler, hydration, collision-registration, appearance-rebind, ready, +leave-world, and teardown method bodies must be absent, not retained as +one-line façades. + +## 4. Commit sequence and gates + +### A — canonical seams + +- Add and test `LiveWorldOriginState`. +- Add and test the fail-fast, single-bind lifecycle bridge. +- Extract `RemoteMotion` mechanically; retarget all 51 App-test references. +- Canonicalize physics-host ownership and remove `_physicsHosts` only after + resolution/teardown/local-player tests prove equivalence. +- Run App remote-motion, runtime, physics-host, session-reset, and structural + suites; full Release build/test; three-agent review and re-review. + +### B — presentation helpers and characterization + +- Extract `AppearanceUpdateState`, appearance rebind, default-pose, and + collision-build helpers behind focused App types. +- Preserve the exact DAT lock and existing extracted WorldBuilder pipeline. +- Add controller-harness fakes that require no Silk/GL context. +- Characterize current leave-world failure ordering rather than silently + redesigning it. +- Run appearance, animation, spawn-adapter, collision, resource-lifecycle, + and new characterization suites; full Release build/test; review cycle. + +### C — create and materialization + +- Implement hydration CreateObject registration, first materialization, + pending deferral, landblock recovery, and ready publication. +- Preserve prior-incarnation cleanup failure aggregation and rollback. +- Route session CreateObject and streaming callbacks directly. +- Tests: loaded create order; pending→loaded; materialized rebucket; same/ + older/new/wrapped generations; nested callback replacement; registration + failure/retry; no duplicate mesh/script/effect activation. +- Full Release build/test; review cycle. + +### D — appearance, parenting, pickup, withdrawal + +- Move ObjDesc, Parent, Pickup, child-ready, collision registration, and + projection withdrawal. +- Tests: identity/component preservation; acquire failure rollback; stale or + reentrant ObjDesc; parent-before-create canonical reread; Pickup/Parent + leave-world; Position re-entry with unchanged local ID/resources. +- Bind child callbacks directly and remove their GameWindow façades. +- Full Release build/test; review cycle. + +### E — delete and exact teardown + +- Move Delete/prune, teardown-plan construction, leave-world component cleanup, + and retry tombstone behavior. +- Bind runtime bridge and liveness directly. +- Tests: unknown/stale/current Delete; callback deletion; dual failure; + teardown retry; same-GUID newer generation during old teardown; reset + convergence; no surviving renderer/collision/script/effect/light owner. +- Full Release build/test; review cycle. + +### F — network-update owner + +- Move Movement, Vector, State, and Position routing plus the equal-generation + CreateObject tail. +- Preserve FIFO and independent authorities; test stale, equal, wrap, nested, + and callback-invalidated updates for every channel. +- Preserve ForcePosition, server-controlled move, teleport, projectile, + shadow, static, airborne, autonomous, animated, and headless branches. +- Route session callbacks directly and remove old GameWindow handlers. +- Full Release build/test; review cycle. + +### G — closeout + +- Structural tests prove both controllers lack identity dictionaries and + `GameWindow` lacks every moved field/type/method/body. +- Run all live-runtime, object-table, parent/attachment, appearance/animation, + effect/light/projectile, remote-motion/physics, inbound FIFO, liveness, + stress, session-reset, and router suites. +- Run `dotnet build AcDream.slnx -c Release` and full Release tests. +- Run the connected R6 route and graceful reconnect gate, then user visual + checks for inventory/equip/parenting, death/corpse, and portals when + available. +- Update architecture, roadmap, milestones, issues/divergence register, this + ledger, `AGENTS.md`/`CLAUDE.md` when current-state pointers change, and + durable GameWindow/live-runtime memory. + +## 5. Adversarial acceptance matrix + +Automated acceptance includes: + +- equal/old/new/wrapped `INSTANCE_TS`, including `0x8000`; +- full same-generation call trace and WeenieDesc tail ordering; +- nested heterogeneous FIFO and exact authority invalidation; +- fresh loaded create and pending→loaded without replay; +- loaded→loaded rebucket and loaded↔pending oscillation; +- ObjDesc identity/body/motion/host/effect/parent preservation; +- Parent/Pickup→cell-less→Position re-entry with unchanged local ID; +- unknown/stale/current Delete, callback delete, GUID reuse, and retry; +- state side-effect order and movement/ForcePosition timestamp order; +- projectile and remote-position corrections across landblocks; +- bridge unbound/second-bind failures; +- session reset with zero surviving live owners; +- 96-owner and repeated-recall stress with no resource growth; +- source/reflection ownership gates. + +Connected acceptance includes: + +- capped login and populated radar/world; +- remote creature motion and target/status projection; +- wield/dewield, bow/wand/melee switching, ammo parenting, inventory pickup; +- open and loot a corpse after approach; +- kill/death/corpse lifetime; +- recall and portal travel across landblocks, revisit, graceful close, and + fresh-process reconnect; +- uncapped post-reconnect performance checkpoint. + +## 6. Review discipline + +Every ownership commit receives three independent read-only reviews: + +1. retail conformance — symbols, ordering, timestamps, and documented + divergences; +2. architecture/integration — identity ownership, dependency direction, + update-thread rules, lifecycle symmetry, and GameWindow reduction; +3. adversarial testing — malformed/stale packets, callback reentrancy, + registration failure, GUID reuse, landblock churn, reset, and leaks. + +The primary agent reproduces and fixes confirmed findings, reruns focused and +full gates, and requests re-review until no actionable finding remains. No +subagent writes the implementation. + +## 7. Explicit carried divergences + +Slice 4 preserves, rather than disguises: + +- AD-32 future-generation non-effect packet dropping; +- AP-65 picked-up data-only weenie retention; +- AP-69 approximate 384-unit visibility envelope; +- TS-32 missing complete child-before-parent null-object queue; +- the equal-generation PES-table refresh and VectorUpdate airborne/state + adaptations recorded during Slice 4 planning. + +These remain visible in the divergence register and are not evidence that the +ownership extraction itself is incomplete. From d68c83d1a551a9bfc57b675d5a9ba2afca40bff1 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 13:09:58 +0200 Subject: [PATCH 39/98] refactor(world): establish live entity composition seams --- src/AcDream.App/Rendering/GameWindow.cs | 37 +++-- src/AcDream.App/World/LiveEntityRuntime.cs | 93 +++++++++++- src/AcDream.App/World/LiveWorldOriginState.cs | 49 +++++++ ...iveEntityRuntimeComponentLifecycleTests.cs | 137 ++++++++++++++++++ .../World/LiveWorldOriginStateTests.cs | 62 ++++++++ 5 files changed, 357 insertions(+), 21 deletions(-) create mode 100644 src/AcDream.App/World/LiveWorldOriginState.cs create mode 100644 tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs create mode 100644 tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 57f81411..fb194182 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1007,8 +1007,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.Net.LiveSessionController? _liveSessionController; private AcDream.Core.Net.WorldSession? LiveSession => _liveSessionController?.CurrentSession; - private int _liveCenterX; - private int _liveCenterY; + private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new(); + private int _liveCenterX => _liveWorldOrigin.CenterX; + private int _liveCenterY => _liveWorldOrigin.CenterY; // #192 (2026-07-09): true once the player's first canonical Position has // been accepted — i.e. _liveCenterX/Y reflects a real, server-confirmed // position, not the Holtburg startup placeholder. Renderability is not a @@ -1019,7 +1020,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // AcDream.Core.World.StreamingReadinessGate for the full mechanism this // closes (stale-centered geometry built during the InWorld-but-not-yet- // located window, applied late once its build finished). - private bool _liveCenterKnown; + private bool _liveCenterKnown => _liveWorldOrigin.IsKnown; // K-fix1 (2026-04-26): cached at startup so per-frame branches are // single-flag reads instead of env-var lookups. True iff @@ -2535,6 +2536,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _worldState, AdvanceLandblockPresentationRetirement); + var liveEntityComponentLifecycle = + new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle(); _liveEntities = new AcDream.App.World.LiveEntityRuntime( _worldState, new AcDream.App.World.CompositeLiveEntityResourceLifecycle( @@ -2544,7 +2547,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext new( entityScriptActivator.OnCreate, entityScriptActivator.OnRemove)), - TearDownLiveEntityRuntimeComponents); + liveEntityComponentLifecycle); + liveEntityComponentLifecycle.Bind( + new AcDream.App.World.DelegateLiveEntityRuntimeComponentLifecycle( + TearDownLiveEntityRuntimeComponents)); _liveEntities.ProjectionVisibilityChanged += (record, visible) => { if (record.WorldEntity is { } entity) @@ -2844,8 +2850,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // enter the world as the first character on the account, and stream // CreateObject messages into _worldGameState as they arrive. Entirely // gated behind ACDREAM_LIVE=1 so the default run path is unchanged. - _liveCenterX = centerX; - _liveCenterY = centerY; + _liveWorldOrigin.SetPlaceholder(centerX, centerY); _liveSessionController = new AcDream.App.Net.LiveSessionController(); AcDream.App.Net.LiveSessionStartResult liveStart = _liveSessionController.Start( @@ -3010,7 +3015,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // X/Y are ignored until the next logical player CreateObject claims a // new origin. Keeping the last values avoids inventing a synthetic // landblock while still forcing first-spawn initialization. - _liveCenterKnown = false; + _liveWorldOrigin.Reset(); } private void ResetSessionLiveRuntime() @@ -3409,21 +3414,22 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private void TryInitializeLiveCenter( AcDream.Core.Net.WorldSession.EntitySpawn spawn) { - if (_liveCenterKnown - || spawn.Guid != _playerServerGuid + if (spawn.Guid != _playerServerGuid || spawn.Position is not { } position) return; int lbX = (int)((position.LandblockId >> 24) & 0xFFu); int lbY = (int)((position.LandblockId >> 16) & 0xFFu); - _liveCenterKnown = true; - if (lbX != _liveCenterX || lbY != _liveCenterY) + int oldCenterX = _liveWorldOrigin.CenterX; + int oldCenterY = _liveWorldOrigin.CenterY; + if (!_liveWorldOrigin.TryInitialize(lbX, lbY)) + return; + + if (lbX != oldCenterX || lbY != oldCenterY) { Console.WriteLine( - $"live: first player position — recentering streaming from ({_liveCenterX},{_liveCenterY}) " + + $"live: first player position — recentering streaming from ({oldCenterX},{oldCenterY}) " + $"to ({lbX},{lbY}) @0x{position.LandblockId:X8}"); - _liveCenterX = lbX; - _liveCenterY = lbY; } if (_streamingController is not null && _dats is not null) @@ -7337,8 +7343,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // can be the abandoned first destination, not the unplaced player's source. _physicsEngine.RemoveLandblock(streamingOriginLandblockId); - _liveCenterX = lbX; - _liveCenterY = lbY; + _liveWorldOrigin.Recenter(lbX, lbY); newWorldPos = new System.Numerics.Vector3( p.PositionX, p.PositionY, p.PositionZ); diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 77400276..7b064a2a 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -80,6 +80,64 @@ public interface ILiveEntityResourceLifecycle void Unregister(WorldEntity entity); } +/// +/// Exact-incarnation App component cleanup invoked by the canonical runtime +/// after it has retired the active identity. Mesh/script resource lifetime is +/// owned separately by . +/// +internal interface ILiveEntityRuntimeComponentLifecycle +{ + void TearDown(LiveEntityRecord record); +} + +internal sealed class NullLiveEntityRuntimeComponentLifecycle : + ILiveEntityRuntimeComponentLifecycle +{ + public static NullLiveEntityRuntimeComponentLifecycle Instance { get; } = new(); + private NullLiveEntityRuntimeComponentLifecycle() { } + public void TearDown(LiveEntityRecord record) { } +} + +internal sealed class DelegateLiveEntityRuntimeComponentLifecycle( + Action tearDown) : ILiveEntityRuntimeComponentLifecycle +{ + private readonly Action _tearDown = + tearDown ?? throw new ArgumentNullException(nameof(tearDown)); + + public void TearDown(LiveEntityRecord record) => _tearDown(record); +} + +/// +/// Composition bridge for the runtime-to-hydration construction cycle. It is +/// deliberately fail-fast and single-assignment: a live session must not start +/// before the exact component owner is bound, and ownership cannot be replaced +/// during a session. +/// +internal sealed class DeferredLiveEntityRuntimeComponentLifecycle : + ILiveEntityRuntimeComponentLifecycle +{ + private ILiveEntityRuntimeComponentLifecycle? _inner; + + public bool IsBound => Volatile.Read(ref _inner) is not null; + + public void Bind(ILiveEntityRuntimeComponentLifecycle lifecycle) + { + ArgumentNullException.ThrowIfNull(lifecycle); + if (ReferenceEquals(lifecycle, this)) + throw new ArgumentException("A lifecycle bridge cannot bind to itself.", nameof(lifecycle)); + if (Interlocked.CompareExchange(ref _inner, lifecycle, null) is not null) + throw new InvalidOperationException("The live-entity runtime component lifecycle is already bound."); + } + + public void TearDown(LiveEntityRecord record) + { + ILiveEntityRuntimeComponentLifecycle lifecycle = Volatile.Read(ref _inner) + ?? throw new InvalidOperationException( + "The live-entity runtime component lifecycle has not been bound."); + lifecycle.TearDown(record); + } +} + /// Delegate adapter used by the App composition root. public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle { @@ -300,7 +358,7 @@ public sealed class LiveEntityRuntime private readonly GpuWorldState _spatial; private readonly ILiveEntityResourceLifecycle _resources; - private readonly Action? _tearDownRuntimeComponents; + private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle; private readonly InboundPhysicsStateController _inbound = new(); private readonly Dictionary _recordsByGuid = new(); // Records leave the active gameplay map before arbitrary teardown callbacks @@ -338,12 +396,38 @@ public sealed class LiveEntityRuntime public LiveEntityRuntime( GpuWorldState spatial, ILiveEntityResourceLifecycle resources, - Action? tearDownRuntimeComponents = null, + uint firstLocalEntityId = FirstLiveEntityId) + : this( + spatial, + resources, + NullLiveEntityRuntimeComponentLifecycle.Instance, + firstLocalEntityId) + { + } + + public LiveEntityRuntime( + GpuWorldState spatial, + ILiveEntityResourceLifecycle resources, + Action tearDownRuntimeComponents, + uint firstLocalEntityId = FirstLiveEntityId) + : this( + spatial, + resources, + new DelegateLiveEntityRuntimeComponentLifecycle(tearDownRuntimeComponents), + firstLocalEntityId) + { + } + + internal LiveEntityRuntime( + GpuWorldState spatial, + ILiveEntityResourceLifecycle resources, + ILiveEntityRuntimeComponentLifecycle? runtimeComponentLifecycle, uint firstLocalEntityId = FirstLiveEntityId) { _spatial = spatial ?? throw new ArgumentNullException(nameof(spatial)); _resources = resources ?? throw new ArgumentNullException(nameof(resources)); - _tearDownRuntimeComponents = tearDownRuntimeComponents; + _runtimeComponentLifecycle = runtimeComponentLifecycle + ?? throw new ArgumentNullException(nameof(runtimeComponentLifecycle)); if (firstLocalEntityId is < FirstLiveEntityId or > LastLiveEntityId) throw new ArgumentOutOfRangeException( nameof(firstLocalEntityId), @@ -2239,8 +2323,7 @@ public sealed class LiveEntityRuntime if (!record.RuntimeComponentsTeardownCompleted) { record.RuntimeComponentsTeardownCompleted = - _tearDownRuntimeComponents is null - || TryCleanup(() => _tearDownRuntimeComponents(record)); + TryCleanup(() => _runtimeComponentLifecycle.TearDown(record)); } if (record.WorldEntity is { } entity) diff --git a/src/AcDream.App/World/LiveWorldOriginState.cs b/src/AcDream.App/World/LiveWorldOriginState.cs new file mode 100644 index 00000000..370e411f --- /dev/null +++ b/src/AcDream.App/World/LiveWorldOriginState.cs @@ -0,0 +1,49 @@ +namespace AcDream.App.World; + +/// +/// Session-scoped owner of the landblock origin used to translate retail +/// full-cell coordinates into the current streamed world coordinate system. +/// A placeholder can exist before the first authoritative player position; +/// distinguishes it from a server-confirmed origin. +/// +/// +/// This owner is update/render-thread confined with the rest of live world +/// mutation. It intentionally exposes no cross-thread snapshot contract. +/// +internal sealed class LiveWorldOriginState +{ + public int CenterX { get; private set; } + public int CenterY { get; private set; } + public bool IsKnown { get; private set; } + + public void SetPlaceholder(int centerX, int centerY) + { + CenterX = centerX; + CenterY = centerY; + IsKnown = false; + } + + public bool TryInitialize(int centerX, int centerY) + { + if (IsKnown) + return false; + + CenterX = centerX; + CenterY = centerY; + IsKnown = true; + return true; + } + + public void Recenter(int centerX, int centerY) + { + CenterX = centerX; + CenterY = centerY; + } + + /// + /// Ends the accepted-origin lifetime while retaining the last coordinates + /// as the next placeholder. Consumers must gate them on + /// until the next accepted player position. + /// + public void Reset() => IsKnown = false; +} diff --git a/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs b/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs new file mode 100644 index 00000000..ec816fdb --- /dev/null +++ b/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs @@ -0,0 +1,137 @@ +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Tests.World; + +public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests +{ + [Fact] + public void TearDownBeforeBind_FailsFast() + { + var bridge = new DeferredLiveEntityRuntimeComponentLifecycle(); + + Assert.False(bridge.IsBound); + Assert.Throws(() => bridge.TearDown(null!)); + } + + [Fact] + public void Bind_IsSingleAssignmentAndForwardsExactRecord() + { + var bridge = new DeferredLiveEntityRuntimeComponentLifecycle(); + LiveEntityRecord? observed = null; + var owner = new DelegateLiveEntityRuntimeComponentLifecycle(record => observed = record); + var replacement = new DelegateLiveEntityRuntimeComponentLifecycle(_ => { }); + LiveEntityRecord record = CreateRecord(); + + bridge.Bind(owner); + bridge.TearDown(record); + + Assert.True(bridge.IsBound); + Assert.Same(record, observed); + Assert.Throws(() => bridge.Bind(replacement)); + } + + [Fact] + public void BindRejectsNullAndSelf() + { + var bridge = new DeferredLiveEntityRuntimeComponentLifecycle(); + + Assert.Throws(() => bridge.Bind(null!)); + Assert.Throws(() => bridge.Bind(bridge)); + } + + [Fact] + public async Task ConcurrentBind_HasOneWinnerAndPublishesThatOwner() + { + var bridge = new DeferredLiveEntityRuntimeComponentLifecycle(); + int firstCalls = 0; + int secondCalls = 0; + var owners = new ILiveEntityRuntimeComponentLifecycle[] + { + new DelegateLiveEntityRuntimeComponentLifecycle(_ => Interlocked.Increment(ref firstCalls)), + new DelegateLiveEntityRuntimeComponentLifecycle(_ => Interlocked.Increment(ref secondCalls)), + }; + using var ready = new CountdownEvent(2); + using var start = new ManualResetEventSlim(); + + Task[] attempts = owners.Select(owner => Task.Run(() => + { + ready.Signal(); + start.Wait(); + try + { + bridge.Bind(owner); + return true; + } + catch (InvalidOperationException) + { + return false; + } + })).ToArray(); + + ready.Wait(); + start.Set(); + bool[] results = await Task.WhenAll(attempts); + LiveEntityRecord record = CreateRecord(); + bridge.TearDown(record); + + Assert.Single(results, value => value); + Assert.True(bridge.IsBound); + Assert.Equal(1, firstCalls + secondCalls); + } + + [Fact] + public void RuntimeTeardownBeforeBind_RetainsTombstoneAndRetryConverges() + { + const uint guid = 0x70000002u; + var bridge = new DeferredLiveEntityRuntimeComponentLifecycle(); + var runtime = new LiveEntityRuntime( + new GpuWorldState(), + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), + bridge); + WorldSession.EntitySpawn spawn = CreateSpawn(guid); + LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!; + var delete = new DeleteObject.Parsed(guid, InstanceSequence: 1); + + Assert.Throws(() => + runtime.UnregisterLiveEntity(delete, isLocalPlayer: false)); + Assert.Equal(1, runtime.PendingTeardownCount); + + int calls = 0; + bridge.Bind(new DelegateLiveEntityRuntimeComponentLifecycle(exact => + { + Assert.Same(record, exact); + calls++; + })); + + Assert.True(runtime.UnregisterLiveEntity(delete, isLocalPlayer: false)); + Assert.Equal(1, calls); + Assert.Equal(0, runtime.PendingTeardownCount); + } + + private static LiveEntityRecord CreateRecord() + { + var runtime = new LiveEntityRuntime( + new GpuWorldState(), + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + return runtime.RegisterLiveEntity(CreateSpawn(0x70000001u)).Record!; + } + + private static WorldSession.EntitySpawn CreateSpawn(uint guid) => + new( + Guid: guid, + Position: null, + SetupTableId: null, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "test", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: 1); +} diff --git a/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs b/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs new file mode 100644 index 00000000..3303e781 --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs @@ -0,0 +1,62 @@ +using AcDream.App.World; + +namespace AcDream.App.Tests.World; + +public sealed class LiveWorldOriginStateTests +{ + [Fact] + public void Placeholder_IsNotAuthoritativeUntilFirstInitialization() + { + var state = new LiveWorldOriginState(); + + state.SetPlaceholder(42, 43); + + Assert.Equal(42, state.CenterX); + Assert.Equal(43, state.CenterY); + Assert.False(state.IsKnown); + Assert.True(state.TryInitialize(10, 11)); + Assert.Equal(10, state.CenterX); + Assert.Equal(11, state.CenterY); + Assert.True(state.IsKnown); + Assert.False(state.TryInitialize(20, 21)); + Assert.Equal(10, state.CenterX); + Assert.Equal(11, state.CenterY); + } + + [Fact] + public void Recenter_ReplacesKnownOriginAndResetRetainsItAsPlaceholder() + { + var state = new LiveWorldOriginState(); + Assert.True(state.TryInitialize(1, 2)); + + state.Recenter(3, 4); + state.Reset(); + + Assert.Equal(3, state.CenterX); + Assert.Equal(4, state.CenterY); + Assert.False(state.IsKnown); + Assert.True(state.TryInitialize(5, 6)); + } + + [Fact] + public void RecenterBeforeInitialization_PreservesUnknownAndResetIsIdempotent() + { + var state = new LiveWorldOriginState(); + state.SetPlaceholder(20, 21); + + state.Recenter(30, 31); + state.Reset(); + state.Reset(); + + Assert.Equal(30, state.CenterX); + Assert.Equal(31, state.CenterY); + Assert.False(state.IsKnown); + + Assert.True(state.TryInitialize(40, 41)); + state.Recenter(50, 51); + + Assert.True(state.IsKnown); + Assert.Equal(50, state.CenterX); + Assert.Equal(51, state.CenterY); + } +} From 5882b308c146051c46465bc6190cee8c4c55852e Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 13:17:58 +0200 Subject: [PATCH 40/98] refactor(physics): extract remote motion runtime --- .../Physics/ProjectileController.cs | 2 +- src/AcDream.App/Physics/RemoteMotion.cs | 307 ++++++++++++++++++ .../Physics/RemotePhysicsUpdater.cs | 2 +- src/AcDream.App/Rendering/GameWindow.cs | 302 +---------------- .../Rendering/LiveEntityAnimationScheduler.cs | 2 +- .../LiveEntityOrdinaryPhysicsUpdaterTests.cs | 4 +- .../Physics/ProjectileControllerTests.cs | 20 +- .../Physics/RemotePhysicsUpdaterTests.cs | 36 +- .../Physics/RemoteTeleportControllerTests.cs | 22 +- .../Physics/RemoteTeleportPlacementTests.cs | 10 +- .../LiveEntityAnimationSchedulerTests.cs | 10 +- .../Motion/RemoteChaseEndToEndHarnessTests.cs | 12 +- 12 files changed, 368 insertions(+), 361 deletions(-) create mode 100644 src/AcDream.App/Physics/RemoteMotion.cs diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs index 7e68faef..7e4ad350 100644 --- a/src/AcDream.App/Physics/ProjectileController.cs +++ b/src/AcDream.App/Physics/ProjectileController.cs @@ -5,7 +5,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using DatReaderWriter.DBObjs; -using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion; +using RemoteMotion = AcDream.App.Physics.RemoteMotion; namespace AcDream.App.Physics; diff --git a/src/AcDream.App/Physics/RemoteMotion.cs b/src/AcDream.App/Physics/RemoteMotion.cs new file mode 100644 index 00000000..7b35d0bd --- /dev/null +++ b/src/AcDream.App/Physics/RemoteMotion.cs @@ -0,0 +1,307 @@ +using AcDream.App.Rendering; +using AcDream.App.World; + +namespace AcDream.App.Physics; + +/// +/// Per-remote-entity physics + motion stack — verbatim application of +/// retail's client-side motion pipeline to every remote. Mirrors retail +/// CPhysicsObj::update_object (0x00515D10) → +/// CPhysicsObj::UpdateObjectInternal (0x005156B0) → +/// CPhysicsObj::UpdatePositionInternal (0x00512C30) → +/// CPhysicsObj::UpdatePhysicsInternal (0x00510700), and ACE's +/// PhysicsObj.cs port. +/// +/// +/// Retail has NO special "interpolator" for remote entities — it runs +/// the full motion state machine on every entity, local or remote, +/// and reconciles via hard-snap on UpdatePosition. This class simply +/// pairs a with its +/// so each +/// remote gets the same treatment as the local player. +/// +/// +internal sealed class RemoteMotion : + AcDream.App.World.ILiveEntityRemotePlacementRuntime +{ + public AcDream.Core.Physics.PhysicsBody Body { get; } + AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body; + + /// R5-V5: retail CPhysicsObj::movement_manager — the + /// ONE per-entity owner of the interp + moveto pair (acclient.h + /// /* 3463 */). Constructed here with the interp; the + /// MoveToManager side arrives via MoveToFactory + + /// MakeMoveToManager() in EnsureRemoteMotionBindings. + /// / below are views of its + /// children (retail get_minterp-style access), kept so the + /// dozens of existing call sites read unchanged. + public AcDream.Core.Physics.Motion.MovementManager Movement; + + public AcDream.Core.Physics.MotionInterpreter Motion => Movement.Minterp; + /// R3-W4: the persistent per-remote dispatch sink (created + /// once by EnsureRemoteMotionBindings; also bound as + /// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch + /// cycles through the motion-table backend). + public AcDream.Core.Physics.Motion.MotionTableDispatchSink? Sink; + /// Last UpdatePosition timestamp — drives body.update_object sub-stepping. + public double LastServerPosTime; + /// Last known server position — kept for diagnostics / HUD. + public System.Numerics.Vector3 LastServerPos; + /// + /// #184: the body position at which this remote's collision SHADOW was + /// last (re-)registered. The per-tick shadow-follows-resolved sync + /// (SyncRemoteShadowToBody) skips re-flooding when the body has + /// not moved > ε since this — a resting/idle-town crowd doesn't churn + /// the registry, while a moving/de-overlapping crowd re-registers every + /// tick. Sentinel Zero forces the first sync. + /// + public System.Numerics.Vector3 LastShadowSyncPos; + /// + /// Complete root orientation paired with . + /// Multipart and off-centre Setup collision geometry must be re-flooded + /// when the object turns in place, not only when its origin translates. + /// The default zero quaternion is the first-sync sentinel. + /// + public System.Numerics.Quaternion LastShadowSyncOrientation; + /// + /// Latest server-authoritative velocity for NPC/monster smoothing. + /// Prefer the HasVelocity vector from UpdatePosition; when ACE omits + /// it for a server-controlled creature, derive it from consecutive + /// authoritative positions instead of guessing from player RUM state. + /// + public System.Numerics.Vector3 ServerVelocity; + public bool HasServerVelocity; + + /// R4-V4: the entity's verbatim retail MoveToManager + /// (server-directed movement), constructed + seam-bound by + /// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. R5-V5: + /// owned by ; this is the child view. + public AcDream.Core.Physics.Motion.MoveToManager? MoveTo => Movement.MoveTo; + + // R5-V2: the entity's CPhysicsObj stand-in — owns the TargetManager + // voyeur system (retail CPhysicsObj::target_manager). Replaces the + // AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/ + // clear_target/quantum seams route here, HandleTargetting ticks per + // frame, and OTHER entities resolve this one via GameWindow._physicsHosts. + public EntityPhysicsHost? Host; + /// + /// True while a server MoveToObject/MoveToPosition packet is the + /// active locomotion source. Retail runs these through MoveToManager + /// and CMotionInterp; the per-tick remote driver consults this to + /// decide whether to feed body steering through + /// instead of + /// the InterpretedMotionState path. + /// + public bool ServerMoveToActive; + + /// + /// True once a MoveTo packet's full path payload (Origin + thresholds) + /// has been parsed and the world-converted destination is stored on + /// . Cleared on arrival or when + /// the next non-MoveTo UpdateMotion replaces the locomotion source. + /// Phase L.1c (2026-04-28). + /// + public bool HasMoveToDestination; + + /// + /// World-space destination from the most recent MoveTo packet's + /// Origin field, converted via the same landblock-grid + /// arithmetic OnLivePositionUpdated uses. + /// + public System.Numerics.Vector3 MoveToDestinationWorld; + + /// + /// min_distance from the MoveTo packet's MovementParameters. + /// Used by as + /// the chase-arrival threshold per retail + /// MoveToManager::HandleMoveToPosition. + /// + public float MoveToMinDistance; + + /// + /// distance_to_object from the MoveTo packet. Reserved for + /// the flee branch (move_away); chase uses + /// . + /// + public float MoveToDistanceToObject; + + /// + /// True if MovementParameters bit 9 (move_towards, mask + /// 0x200) is set on the active packet — i.e. this is a + /// chase. False = flee (move_away) or static target. + /// + public bool MoveToMoveTowards; + + /// + /// Seconds-since-epoch timestamp of the most recent MoveTo packet + /// for this entity. Used by the per-tick driver to give up + /// steering when no refresh has arrived for + /// + /// — typically because the entity left our streaming view and + /// the server stopped broadcasting its MoveTo updates. + /// + public double LastMoveToPacketTime; + /// + /// Full 32-bit cell ID from the latest UpdatePosition. High 16 bits + /// = landblock (LBx,LBy), low 16 bits = cell index (outdoor 0x0001- + /// 0x0040, indoor 0x0100+). Fed into + /// + /// so the retail sphere-sweep can look up the right terrain/EnvCell + /// polygons for each remote's per-frame motion. Zero until the first + /// UP lands, which disables the transition step for that frame + /// (Euler alone, matching pre-wire behavior). + /// + private uint _cellId; + private Func? _canonicalCellReader; + private Action? _canonicalCellWriter; + + /// + /// Canonical full cell for this one live CPhysicsObj. Ordinary remotes + /// retain the local backing value. A MovementManager attached to an + /// existing projectile delegates reads and writes to + /// , so projectile prediction, + /// Movement packets, and spatial rebucketing cannot develop competing + /// cell identities. + /// + public uint CellId + { + get => _canonicalCellReader?.Invoke() ?? _cellId; + set + { + _cellId = value; + _canonicalCellWriter?.Invoke(value); + } + } + + System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPosition + { + get => LastServerPos; + set => LastServerPos = value; + } + + double AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPositionTime + { + get => LastServerPosTime; + set => LastServerPosTime = value; + } + + System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncPosition + { + get => LastShadowSyncPos; + set => LastShadowSyncPos = value; + } + + System.Numerics.Quaternion AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncOrientation + { + get => LastShadowSyncOrientation; + set => LastShadowSyncOrientation = value; + } + + bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne + { + get => Airborne; + set => Airborne = value; + } + + void AcDream.App.World.ILiveEntityRemotePlacementRuntime.HitGround() => + Movement.HitGround(); + + void AcDream.App.World.ILiveEntityRemotePlacementRuntime.LeaveGround() => + Motion.LeaveGround(); + + /// + /// K-fix9 (2026-04-26): true while the remote is airborne (jump + /// arc in flight). Set when a 0xF74E VectorUpdate arrives with + /// non-trivial +Z velocity; cleared when the next UpdatePosition + /// snaps to a new ground location. While true, the per-tick + /// remote update SKIPS the "force OnWalkable + apply_current_movement" + /// step that would otherwise stomp the body's Z velocity each + /// frame, AND enables gravity so the parabolic arc actually plays + /// out between server snaps. + /// + public bool Airborne; + + /// + /// Per-remote position-waypoint queue + catch-up math (retail's + /// CPhysicsObj::InterpolateTo + InterpolationManager::adjust_offset). + /// Drives per-tick body translation for grounded player remotes + /// via . + /// + public AcDream.Core.Physics.InterpolationManager Interp { get; } = + new AcDream.Core.Physics.InterpolationManager(); + + /// + /// Per-frame combiner for animation root motion + InterpolationManager + /// correction. Mirrors retail UpdatePositionInternal @ 0x00512c30: + /// queue catch-up REPLACES anim when active; anim stands when queue + /// is idle. + /// + public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } = + new AcDream.Core.Physics.RemoteMotionCombiner(); + + /// Reusable complete delta frame shared by interpolation, + /// Sticky/Constraint, and the final body composition. + public AcDream.Core.Physics.Motion.MotionDeltaFrame PositionManagerDeltaScratch { get; } = + new AcDream.Core.Physics.Motion.MotionDeltaFrame(); + + /// + /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the + /// previous UpdatePosition's world position + timestamp. The per-tick + /// path compares the server's broadcast pace with the literal root + /// displacement emitted by CSequence::update. The old inferred + /// walk/run velocity was the source of the periodic overshoot this + /// diagnostic was introduced to find. + /// + public System.Numerics.Vector3 PrevServerPos; + public double PrevServerPosTime; + public double LastOmegaDiagLogTime; + + /// + /// Diagnostic-only: maximum scaled CSequence root-motion speed + /// observed since the last UpdatePosition arrival. The next UP + /// compares it with the server's observed pace. Reset on each UP. + /// + public float MaxRootMotionSpeedSinceLastUP; + + public RemoteMotion(AcDream.Core.Physics.PhysicsBody? sharedBody = null) + { + Body = sharedBody ?? new AcDream.Core.Physics.PhysicsBody + { + // Remotes don't simulate gravity — server owns Z. Force + // Contact + OnWalkable + Active so apply_current_movement + // writes velocity through every tick (the gate in + // MotionInterpreter.apply_current_movement is + // PhysicsObj.OnWalkable). + State = AcDream.Core.Physics.PhysicsStateFlags.ReportCollisions, + TransientState = AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable + | AcDream.Core.Physics.TransientStateFlags.Active, + // R4-V5 door-swing fix: a RemoteMotion exists only for a + // WORLD entity — retail's physics_obj->cell is non-null the + // moment the object is placed. Without this, the interp's + // detached-object guard stripped every dispatched + // transition link (see PhysicsBody.InWorld). + InWorld = true, + }; + // R5-V5: the interp is owned by the MovementManager facade + // (retail CPhysicsObj::movement_manager -> motion_interpreter). + // acdream constructs it eagerly here — retail's lazy-create + // window is never observable (see MovementManager's class doc). + Movement = new AcDream.Core.Physics.Motion.MovementManager( + new AcDream.Core.Physics.MotionInterpreter(Body) + { + // R4-V5 #160 fix: retail remotes carry a weenie whose + // InqRunRate FAILS, landing apply_run_to_command on + // my_run_rate (the M13 wire feed). A null weenie took the + // degenerate 1.0 branch — observer-side run movetos played + // and moved in slow motion. See RemoteWeenie. + WeenieObj = new AcDream.Core.Physics.RemoteWeenie(), + }); + } + + public void BindCanonicalCell(Func read, Action write) + { + _canonicalCellReader = read ?? throw new ArgumentNullException(nameof(read)); + _canonicalCellWriter = write ?? throw new ArgumentNullException(nameof(write)); + } +} diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index 4c6b039b..412ef6e2 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -1,4 +1,4 @@ -using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion; +using RemoteMotion = AcDream.App.Physics.RemoteMotion; using LiveEntityAnimationState = AcDream.App.Rendering.LiveEntityAnimationState; namespace AcDream.App.Physics; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index fb194182..dcd6ba55 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,4 +1,5 @@ using AcDream.Core.Plugins; +using AcDream.App.Physics; using AcDream.App.World; using AcDream.Content; using DatReaderWriter; @@ -421,307 +422,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.World.LiveEntityRuntime? _liveEntities; private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness; - /// - /// Per-remote-entity physics + motion stack — verbatim application of - /// retail's client-side motion pipeline to every remote. Mirrors - /// retail FUN_00515020 update_objectFUN_00513730 - /// UpdatePositionInternalFUN_005111D0 - /// UpdatePhysicsInternal, and ACE's PhysicsObj.cs port. - /// - /// - /// Retail has NO special "interpolator" for remote entities — it runs - /// the full motion state machine on every entity, local or remote, - /// and reconciles via hard-snap on UpdatePosition. This class simply - /// pairs a with its - /// so each - /// remote gets the same treatment as the local player. - /// - /// - internal sealed class RemoteMotion : - AcDream.App.World.ILiveEntityRemotePlacementRuntime - { - public AcDream.Core.Physics.PhysicsBody Body { get; } - AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body; - - /// R5-V5: retail CPhysicsObj::movement_manager — the - /// ONE per-entity owner of the interp + moveto pair (acclient.h - /// /* 3463 */). Constructed here with the interp; the - /// MoveToManager side arrives via MoveToFactory + - /// MakeMoveToManager() in EnsureRemoteMotionBindings. - /// / below are views of its - /// children (retail get_minterp-style access), kept so the - /// dozens of existing call sites read unchanged. - public AcDream.Core.Physics.Motion.MovementManager Movement; - - public AcDream.Core.Physics.MotionInterpreter Motion => Movement.Minterp; - /// R3-W4: the persistent per-remote dispatch sink (created - /// once by EnsureRemoteMotionBindings; also bound as - /// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch - /// cycles through the motion-table backend). - public AcDream.Core.Physics.Motion.MotionTableDispatchSink? Sink; - /// Last UpdatePosition timestamp — drives body.update_object sub-stepping. - public double LastServerPosTime; - /// Last known server position — kept for diagnostics / HUD. - public System.Numerics.Vector3 LastServerPos; - /// - /// #184: the body position at which this remote's collision SHADOW was - /// last (re-)registered. The per-tick shadow-follows-resolved sync - /// (SyncRemoteShadowToBody) skips re-flooding when the body has - /// not moved > ε since this — a resting/idle-town crowd doesn't churn - /// the registry, while a moving/de-overlapping crowd re-registers every - /// tick. Sentinel Zero forces the first sync. - /// - public System.Numerics.Vector3 LastShadowSyncPos; - /// - /// Complete root orientation paired with . - /// Multipart and off-centre Setup collision geometry must be re-flooded - /// when the object turns in place, not only when its origin translates. - /// The default zero quaternion is the first-sync sentinel. - /// - public System.Numerics.Quaternion LastShadowSyncOrientation; - /// - /// Latest server-authoritative velocity for NPC/monster smoothing. - /// Prefer the HasVelocity vector from UpdatePosition; when ACE omits - /// it for a server-controlled creature, derive it from consecutive - /// authoritative positions instead of guessing from player RUM state. - /// - public System.Numerics.Vector3 ServerVelocity; - public bool HasServerVelocity; - - /// R4-V4: the entity's verbatim retail MoveToManager - /// (server-directed movement), constructed + seam-bound by - /// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. R5-V5: - /// owned by ; this is the child view. - public AcDream.Core.Physics.Motion.MoveToManager? MoveTo => Movement.MoveTo; - - // R5-V2: the entity's CPhysicsObj stand-in — owns the TargetManager - // voyeur system (retail CPhysicsObj::target_manager). Replaces the - // AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/ - // clear_target/quantum seams route here, HandleTargetting ticks per - // frame, and OTHER entities resolve this one via GameWindow._physicsHosts. - public EntityPhysicsHost? Host; - /// - /// True while a server MoveToObject/MoveToPosition packet is the - /// active locomotion source. Retail runs these through MoveToManager - /// and CMotionInterp; the per-tick remote driver consults this to - /// decide whether to feed body steering through - /// instead of - /// the InterpretedMotionState path. - /// - public bool ServerMoveToActive; - - /// - /// True once a MoveTo packet's full path payload (Origin + thresholds) - /// has been parsed and the world-converted destination is stored on - /// . Cleared on arrival or when - /// the next non-MoveTo UpdateMotion replaces the locomotion source. - /// Phase L.1c (2026-04-28). - /// - public bool HasMoveToDestination; - - /// - /// World-space destination from the most recent MoveTo packet's - /// Origin field, converted via the same landblock-grid - /// arithmetic OnLivePositionUpdated uses. - /// - public System.Numerics.Vector3 MoveToDestinationWorld; - - /// - /// min_distance from the MoveTo packet's MovementParameters. - /// Used by as - /// the chase-arrival threshold per retail - /// MoveToManager::HandleMoveToPosition. - /// - public float MoveToMinDistance; - - /// - /// distance_to_object from the MoveTo packet. Reserved for - /// the flee branch (move_away); chase uses - /// . - /// - public float MoveToDistanceToObject; - - /// - /// True if MovementParameters bit 9 (move_towards, mask - /// 0x200) is set on the active packet — i.e. this is a - /// chase. False = flee (move_away) or static target. - /// - public bool MoveToMoveTowards; - - /// - /// Seconds-since-epoch timestamp of the most recent MoveTo packet - /// for this entity. Used by the per-tick driver to give up - /// steering when no refresh has arrived for - /// - /// — typically because the entity left our streaming view and - /// the server stopped broadcasting its MoveTo updates. - /// - public double LastMoveToPacketTime; - /// - /// Full 32-bit cell ID from the latest UpdatePosition. High 16 bits - /// = landblock (LBx,LBy), low 16 bits = cell index (outdoor 0x0001- - /// 0x0040, indoor 0x0100+). Fed into - /// - /// so the retail sphere-sweep can look up the right terrain/EnvCell - /// polygons for each remote's per-frame motion. Zero until the first - /// UP lands, which disables the transition step for that frame - /// (Euler alone, matching pre-wire behavior). - /// - private uint _cellId; - private Func? _canonicalCellReader; - private Action? _canonicalCellWriter; - - /// - /// Canonical full cell for this one live CPhysicsObj. Ordinary remotes - /// retain the local backing value. A MovementManager attached to an - /// existing projectile delegates reads and writes to - /// , so projectile prediction, - /// Movement packets, and spatial rebucketing cannot develop competing - /// cell identities. - /// - public uint CellId - { - get => _canonicalCellReader?.Invoke() ?? _cellId; - set - { - _cellId = value; - _canonicalCellWriter?.Invoke(value); - } - } - - System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPosition - { - get => LastServerPos; - set => LastServerPos = value; - } - - double AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPositionTime - { - get => LastServerPosTime; - set => LastServerPosTime = value; - } - - System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncPosition - { - get => LastShadowSyncPos; - set => LastShadowSyncPos = value; - } - - System.Numerics.Quaternion AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncOrientation - { - get => LastShadowSyncOrientation; - set => LastShadowSyncOrientation = value; - } - - bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne - { - get => Airborne; - set => Airborne = value; - } - - void AcDream.App.World.ILiveEntityRemotePlacementRuntime.HitGround() => - Movement.HitGround(); - - void AcDream.App.World.ILiveEntityRemotePlacementRuntime.LeaveGround() => - Motion.LeaveGround(); - - /// - /// K-fix9 (2026-04-26): true while the remote is airborne (jump - /// arc in flight). Set when a 0xF74E VectorUpdate arrives with - /// non-trivial +Z velocity; cleared when the next UpdatePosition - /// snaps to a new ground location. While true, the per-tick - /// remote update SKIPS the "force OnWalkable + apply_current_movement" - /// step that would otherwise stomp the body's Z velocity each - /// frame, AND enables gravity so the parabolic arc actually plays - /// out between server snaps. - /// - public bool Airborne; - - /// - /// Per-remote position-waypoint queue + catch-up math (retail's - /// CPhysicsObj::InterpolateTo + InterpolationManager::adjust_offset). - /// Drives per-tick body translation for grounded player remotes - /// via . - /// - public AcDream.Core.Physics.InterpolationManager Interp { get; } = - new AcDream.Core.Physics.InterpolationManager(); - - /// - /// Per-frame combiner for animation root motion + InterpolationManager - /// correction. Mirrors retail UpdatePositionInternal @ 0x00512c30: - /// queue catch-up REPLACES anim when active; anim stands when queue - /// is idle. - /// - public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } = - new AcDream.Core.Physics.RemoteMotionCombiner(); - - /// Reusable complete delta frame shared by interpolation, - /// Sticky/Constraint, and the final body composition. - public AcDream.Core.Physics.Motion.MotionDeltaFrame PositionManagerDeltaScratch { get; } = - new AcDream.Core.Physics.Motion.MotionDeltaFrame(); - - /// - /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the - /// previous UpdatePosition's world position + timestamp. The per-tick - /// path compares the server's broadcast pace with the literal root - /// displacement emitted by CSequence::update. The old inferred - /// walk/run velocity was the source of the periodic overshoot this - /// diagnostic was introduced to find. - /// - public System.Numerics.Vector3 PrevServerPos; - public double PrevServerPosTime; - public double LastOmegaDiagLogTime; - - /// - /// Diagnostic-only: maximum scaled CSequence root-motion speed - /// observed since the last UpdatePosition arrival. The next UP - /// compares it with the server's observed pace. Reset on each UP. - /// - public float MaxRootMotionSpeedSinceLastUP; - - public RemoteMotion(AcDream.Core.Physics.PhysicsBody? sharedBody = null) - { - Body = sharedBody ?? new AcDream.Core.Physics.PhysicsBody - { - // Remotes don't simulate gravity — server owns Z. Force - // Contact + OnWalkable + Active so apply_current_movement - // writes velocity through every tick (the gate in - // MotionInterpreter.apply_current_movement is - // PhysicsObj.OnWalkable). - State = AcDream.Core.Physics.PhysicsStateFlags.ReportCollisions, - TransientState = AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable - | AcDream.Core.Physics.TransientStateFlags.Active, - // R4-V5 door-swing fix: a RemoteMotion exists only for a - // WORLD entity — retail's physics_obj->cell is non-null the - // moment the object is placed. Without this, the interp's - // detached-object guard stripped every dispatched - // transition link (see PhysicsBody.InWorld). - InWorld = true, - }; - // R5-V5: the interp is owned by the MovementManager facade - // (retail CPhysicsObj::movement_manager -> motion_interpreter). - // acdream constructs it eagerly here — retail's lazy-create - // window is never observable (see MovementManager's class doc). - Movement = new AcDream.Core.Physics.Motion.MovementManager( - new AcDream.Core.Physics.MotionInterpreter(Body) - { - // R4-V5 #160 fix: retail remotes carry a weenie whose - // InqRunRate FAILS, landing apply_run_to_command on - // my_run_rate (the M13 wire feed). A null weenie took the - // degenerate 1.0 branch — observer-side run movetos played - // and moved in slow motion. See RemoteWeenie. - WeenieObj = new AcDream.Core.Physics.RemoteWeenie(), - }); - } - - public void BindCanonicalCell(Func read, Action write) - { - _canonicalCellReader = read ?? throw new ArgumentNullException(nameof(read)); - _canonicalCellWriter = write ?? throw new ArgumentNullException(nameof(write)); - } - } - /// Soft-snap decay rate (1/sec). At this rate the residual /// halves every 1/rate seconds. 8.0 → ~100ms half-life, so even a /// 2m residual fades within ~300ms without visible snap. diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs index 5e156244..b7b275cf 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs @@ -5,7 +5,7 @@ using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.Core.World; using DatReaderWriter.Types; -using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion; +using RemoteMotion = AcDream.App.Physics.RemoteMotion; namespace AcDream.App.Rendering; diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs index 5d50faa0..040c23c9 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs @@ -41,7 +41,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests ParentCellId = SourceCell, })!; - var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion + var remote = new AcDream.App.Physics.RemoteMotion { CellId = SourceCell, }; @@ -108,7 +108,7 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests MeshRefs = Array.Empty(), ParentCellId = SourceCell, })!; - var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion + var remote = new AcDream.App.Physics.RemoteMotion { CellId = SourceCell, }; diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index aefa2284..2bc55d79 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -104,7 +104,7 @@ public sealed class ProjectileControllerTests LiveEntityRecord record = fixture.Spawn(instance: 1); WorldEntity entity = record.WorldEntity!; Assert.Null(record.AnimationRuntime); - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); RemotePhysicsBodyInitializer.Initialize(remote.Body, record); remote.Body.Position = entity.Position; remote.Body.Orientation = entity.Rotation; @@ -683,7 +683,7 @@ public sealed class ProjectileControllerTests LiveEntityRecord record = fixture.Spawn(instance: 1); Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); PhysicsBody body = record.PhysicsBody!; - fixture.Live.SetRemoteMotionRuntime(Guid, new GameWindow.RemoteMotion(body)); + fixture.Live.SetRemoteMotionRuntime(Guid, new AcDream.App.Physics.RemoteMotion(body)); record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; Assert.True(fixture.Controller.ApplyAuthoritativeState( record, record.FinalPhysicsState, 2.0, 1, 1)); @@ -707,7 +707,7 @@ public sealed class ProjectileControllerTests Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); PhysicsBody body = record.PhysicsBody!; Vector3 position = body.Position; - fixture.Live.SetRemoteMotionRuntime(Guid, new GameWindow.RemoteMotion(body)); + fixture.Live.SetRemoteMotionRuntime(Guid, new AcDream.App.Physics.RemoteMotion(body)); record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; Assert.True(fixture.Controller.ApplyAuthoritativeState( @@ -946,7 +946,7 @@ public sealed class ProjectileControllerTests var fixture = new Fixture(); LiveEntityRecord record = fixture.Spawn(instance: 1); record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); RemotePhysicsBodyInitializer.Initialize(remote.Body, record); fixture.Live.SetRemoteMotionRuntime(Guid, remote); @@ -998,7 +998,7 @@ public sealed class ProjectileControllerTests var fixture = new Fixture(); LiveEntityRecord record = fixture.Spawn(instance: 1); record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); RemotePhysicsBodyInitializer.Initialize(remote.Body, record); fixture.Live.SetRemoteMotionRuntime(Guid, remote); @@ -1070,7 +1070,7 @@ public sealed class ProjectileControllerTests seedCellId: startCell, isStatic: false); record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); RemotePhysicsBodyInitializer.Initialize(remote.Body, record); Assert.Equal(new Vector3(40f, 0f, 0f), remote.Body.Velocity); Assert.Equal(new Vector3(0f, 0f, 2f), remote.Body.Omega); @@ -1136,7 +1136,7 @@ public sealed class ProjectileControllerTests velocity: new Vector3(float.NaN, 0f, 0f)); WorldEntity entity = record.WorldEntity!; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); RemotePhysicsBodyInitializer.Initialize(remote.Body, record); remote.Body.Position = entity.Position; remote.Body.Orientation = entity.Rotation; @@ -1162,7 +1162,7 @@ public sealed class ProjectileControllerTests LiveEntityRecord record = fixture.Spawn(instance: 1); WorldEntity entity = record.WorldEntity!; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); RemotePhysicsBodyInitializer.Initialize(remote.Body, record); remote.Body.Position = entity.Position; remote.Body.Orientation = entity.Rotation; @@ -1186,7 +1186,7 @@ public sealed class ProjectileControllerTests LiveEntityRecord record = fixture.Spawn(instance: 1); Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); PhysicsBody body = record.PhysicsBody!; - var remote = new GameWindow.RemoteMotion(body); + var remote = new AcDream.App.Physics.RemoteMotion(body); fixture.Live.SetRemoteMotionRuntime(Guid, remote); fixture.Live.RebucketLiveEntity(Guid, CellB); @@ -1198,7 +1198,7 @@ public sealed class ProjectileControllerTests Assert.Same(body, record.RemoteMotionRuntime!.Body); Assert.Same(body, record.ProjectileRuntime!.Body); - var replacement = new GameWindow.RemoteMotion(body); + var replacement = new AcDream.App.Physics.RemoteMotion(body); fixture.Live.SetRemoteMotionRuntime(Guid, replacement); remote.CellId = CellB; Assert.Equal(CellA, record.FullCellId); diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs index afc96a3d..02b2e6a1 100644 --- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -68,7 +68,7 @@ public sealed class RemotePhysicsUpdaterTests MeshRefs = Array.Empty(), ParentCellId = cellId, }; - var motion = new GameWindow.RemoteMotion + var motion = new AcDream.App.Physics.RemoteMotion { LastShadowSyncPos = Vector3.Zero, LastShadowSyncOrientation = Quaternion.Identity, @@ -151,7 +151,7 @@ public sealed class RemotePhysicsUpdaterTests PartTemplate = Array.Empty(), PartAvailability = Array.Empty(), }; - var motion = new GameWindow.RemoteMotion(); + var motion = new AcDream.App.Physics.RemoteMotion(); motion.Body.Position = entity.Position; motion.Body.Orientation = entity.Rotation; motion.Body.TransientState = TransientStateFlags.Contact @@ -217,7 +217,7 @@ public sealed class RemotePhysicsUpdaterTests PartTemplate = Array.Empty(), PartAvailability = Array.Empty(), }; - var motion = new GameWindow.RemoteMotion + var motion = new AcDream.App.Physics.RemoteMotion { Airborne = true, }; @@ -272,7 +272,7 @@ public sealed class RemotePhysicsUpdaterTests PartTemplate = Array.Empty(), PartAvailability = Array.Empty(), }; - var motion = new GameWindow.RemoteMotion(); + var motion = new AcDream.App.Physics.RemoteMotion(); motion.Body.Position = entity.Position; motion.Body.Orientation = initial; motion.Interp.Enqueue( @@ -308,7 +308,7 @@ public sealed class RemotePhysicsUpdaterTests [Fact] public void TickHidden_AppliesPositionManagerOffsetWithoutAdvancingPhysics() { - var motion = new GameWindow.RemoteMotion(); + var motion = new AcDream.App.Physics.RemoteMotion(); motion.Body.Position = Vector3.Zero; motion.Body.Orientation = Quaternion.Identity; motion.Body.Velocity = new Vector3(4f, 0f, 5f); @@ -354,7 +354,7 @@ public sealed class RemotePhysicsUpdaterTests new PhysicsEngine(), (_, _) => (0.48f, 1.835f), (_, _, _, _) => { }); - var motion = new GameWindow.RemoteMotion(); + var motion = new AcDream.App.Physics.RemoteMotion(); motion.Body.Orientation = Quaternion.Identity; var entity = new WorldEntity { @@ -384,7 +384,7 @@ public sealed class RemotePhysicsUpdaterTests new PhysicsEngine(), (_, _) => (0.48f, 1.835f), (_, _, _, _) => { }); - var motion = new GameWindow.RemoteMotion(); + var motion = new AcDream.App.Physics.RemoteMotion(); motion.Body.Orientation = Quaternion.Identity; var entity = new WorldEntity { @@ -424,7 +424,7 @@ public sealed class RemotePhysicsUpdaterTests engine, (_, _) => (0.48f, 1.835f), (_, _, _, _) => { }); - var motion = new GameWindow.RemoteMotion(); + var motion = new AcDream.App.Physics.RemoteMotion(); motion.Body.Position = new Vector3(191f, 10f, 50f); motion.Body.Orientation = Quaternion.Identity; motion.Body.TransientState = TransientStateFlags.Contact @@ -471,7 +471,7 @@ public sealed class RemotePhysicsUpdaterTests engine, (_, _) => (0.48f, 1.835f), (_, _, _, _) => { }); - var motion = new GameWindow.RemoteMotion + var motion = new AcDream.App.Physics.RemoteMotion { Airborne = true, }; @@ -591,7 +591,7 @@ public sealed class RemotePhysicsUpdaterTests using var fixture = new BoundaryRemoteFixture(); LiveEntityRecord oldRecord = fixture.Record; WorldEntity oldEntity = fixture.Entity; - GameWindow.RemoteMotion oldRemote = fixture.Remote; + AcDream.App.Physics.RemoteMotion oldRemote = fixture.Remote; ulong oldAuthority = oldRecord.PositionAuthorityVersion; int publications = 0; @@ -628,9 +628,9 @@ public sealed class RemotePhysicsUpdaterTests using var fixture = new BoundaryRemoteFixture(); LiveEntityRecord oldRecord = fixture.Record; WorldEntity oldEntity = fixture.Entity; - GameWindow.RemoteMotion oldRemote = fixture.Remote; + AcDream.App.Physics.RemoteMotion oldRemote = fixture.Remote; WorldEntity? replacementEntity = null; - GameWindow.RemoteMotion? replacementRemote = null; + AcDream.App.Physics.RemoteMotion? replacementRemote = null; ulong clockEpoch = oldRecord.ObjectClockEpoch; fixture.Live.ProjectionVisibilityChanged += (record, visible) => @@ -813,7 +813,7 @@ public sealed class RemotePhysicsUpdaterTests MeshRefs = Array.Empty(), ParentCellId = cellId, })!; - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); remote.Body.Position = entity.Position; remote.Body.Orientation = entity.Rotation; remote.CellId = cellId; @@ -872,7 +872,7 @@ public sealed class RemotePhysicsUpdaterTests public RemotePhysicsUpdater Updater { get; } public LiveEntityRecord Record { get; } public WorldEntity Entity { get; } - public GameWindow.RemoteMotion Remote { get; } + public AcDream.App.Physics.RemoteMotion Remote { get; } public void HydrateDestination() => Spatial.AddLandblock(new LoadedLandblock( @@ -881,7 +881,7 @@ public sealed class RemotePhysicsUpdaterTests Array.Empty())); public (LiveEntityRecord Record, WorldEntity Entity, - GameWindow.RemoteMotion Remote) ReplaceAtSource() + AcDream.App.Physics.RemoteMotion Remote) ReplaceAtSource() { Assert.True(Live.UnregisterLiveEntity( new DeleteObject.Parsed(Guid, InstanceSequence: 1), @@ -902,7 +902,7 @@ public sealed class RemotePhysicsUpdaterTests } private (LiveEntityRecord Record, WorldEntity Entity, - GameWindow.RemoteMotion Remote) SpawnAndBind( + AcDream.App.Physics.RemoteMotion Remote) SpawnAndBind( ushort instanceSequence, Vector3 position, bool enqueueDestination) @@ -925,7 +925,7 @@ public sealed class RemotePhysicsUpdaterTests MeshRefs = Array.Empty(), ParentCellId = SourceCell, })); - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); remote.Body.Position = position; remote.Body.Orientation = Quaternion.Identity; remote.CellId = SourceCell; @@ -945,7 +945,7 @@ public sealed class RemotePhysicsUpdaterTests private void RegisterShadow( WorldEntity entity, - GameWindow.RemoteMotion remote) + AcDream.App.Physics.RemoteMotion remote) { Engine.ShadowObjects.Register( entity.Id, diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs index 7a261071..441b7780 100644 --- a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs @@ -101,7 +101,7 @@ public sealed class RemoteTeleportControllerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), })!; - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); remote.Body.SnapToCell( sourceCell, entity.Position, @@ -242,7 +242,7 @@ public sealed class RemoteTeleportControllerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), })!; - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); live.SetRemoteMotionRuntime(guid, remote); var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; @@ -333,7 +333,7 @@ public sealed class RemoteTeleportControllerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), })!; - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); remote.Body.SnapToCell(sourceCell, sourcePosition, sourcePosition); remote.Body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable; @@ -774,13 +774,13 @@ public sealed class RemoteTeleportControllerTests Assert.Throws(() => fixture.Live.SetRemoteMotionRuntime( RollbackFixture.Guid, - new GameWindow.RemoteMotion())); + new AcDream.App.Physics.RemoteMotion())); Assert.Throws(() => fixture.Live.SetRemoteMotionRuntime( RollbackFixture.Guid, new BodyOnlyRemote(fixture.Remote.Body))); - var replacement = new GameWindow.RemoteMotion(fixture.Remote.Body); + var replacement = new AcDream.App.Physics.RemoteMotion(fixture.Remote.Body); fixture.Live.SetRemoteMotionRuntime(RollbackFixture.Guid, replacement); fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); @@ -811,7 +811,7 @@ public sealed class RemoteTeleportControllerTests Assert.Throws(() => fixture.Live.SetRemoteMotionRuntime( RollbackFixture.Guid, - new GameWindow.RemoteMotion())); + new AcDream.App.Physics.RemoteMotion())); Assert.Throws(() => fixture.Live.SetRemoteMotionRuntime( RollbackFixture.Guid, @@ -1114,7 +1114,7 @@ public sealed class RemoteTeleportControllerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), })!; - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); live.SetRemoteMotionRuntime(guid, remote); controller = new RemoteTeleportController( @@ -1191,7 +1191,7 @@ public sealed class RemoteTeleportControllerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), })!; - var remote = new GameWindow.RemoteMotion(); + var remote = new AcDream.App.Physics.RemoteMotion(); remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); live.SetRemoteMotionRuntime(guid, remote); return new LoadedRemoteFixture( @@ -1236,7 +1236,7 @@ public sealed class RemoteTeleportControllerTests private readonly record struct LoadedRemoteFixture( LiveEntityRuntime Live, WorldEntity Entity, - GameWindow.RemoteMotion Remote, + AcDream.App.Physics.RemoteMotion Remote, PhysicsEngine Engine); private static void AddDestination(PhysicsEngine engine) => @@ -1395,7 +1395,7 @@ public sealed class RemoteTeleportControllerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), })!; - Remote = new GameWindow.RemoteMotion(); + Remote = new AcDream.App.Physics.RemoteMotion(); Remote.Body.SnapToCell( celllessSource ? 0u : SourceCell, SourcePosition, @@ -1476,7 +1476,7 @@ public sealed class RemoteTeleportControllerTests internal GpuWorldState Spatial { get; } = new(); internal LiveEntityRuntime Live { get; } internal WorldEntity Entity { get; } - internal GameWindow.RemoteMotion Remote { get; } + internal AcDream.App.Physics.RemoteMotion Remote { get; } internal PhysicsEngine Engine { get; } = new() { DataCache = new PhysicsDataCache() }; internal RemoteTeleportController Controller { get; } internal LiveEntityPresentationController Presentation { get; } diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs index a754d92d..6caac319 100644 --- a/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs @@ -19,7 +19,7 @@ public sealed class RemoteTeleportPlacementTests 0x01010001u, new Vector3(3f, 4f, 5f), new Vector3(3f, 4f, 5f)); - var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body); + var remote = new AcDream.App.Physics.RemoteMotion(body); Quaternion orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f); RemoteTeleportPlacement.Apply( @@ -54,7 +54,7 @@ public sealed class RemoteTeleportPlacementTests State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, }; var contactPlane = new Plane(Vector3.UnitZ, 0f); - var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body) + var remote = new AcDream.App.Physics.RemoteMotion(body) { Airborne = true, }; @@ -97,7 +97,7 @@ public sealed class RemoteTeleportPlacementTests // landblock is absent. SetPositionInternal must still receive the // grounded source edge captured before parking. body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); - var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body) + var remote = new AcDream.App.Physics.RemoteMotion(body) { Airborne = true, }; @@ -141,7 +141,7 @@ public sealed class RemoteTeleportPlacementTests // calc_acceleration, which clears grounded angular drift, before // set_on_walkable installs the steep destination's false value. body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); - var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body); + var remote = new AcDream.App.Physics.RemoteMotion(body); RemoteTeleportPlacement.Apply( remote, @@ -171,7 +171,7 @@ public sealed class RemoteTeleportPlacementTests { var original = new Vector3(7f, 8f, 9f); var body = new PhysicsBody { Position = original }; - var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body); + var remote = new AcDream.App.Physics.RemoteMotion(body); Assert.Throws(() => RemoteTeleportPlacement.Apply( diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs index ca14c642..c81d5a2b 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs @@ -52,7 +52,7 @@ public sealed class LiveEntityAnimationSchedulerTests public void HiddenRemotePose_IsSampledOnceAfterAdmittedHiddenQuantum() { var (live, record, animation) = BuildHiddenAnimated(RemoteGuid); - var remote = new GameWindow.RemoteMotion + var remote = new AcDream.App.Physics.RemoteMotion { CellId = Cell, }; @@ -169,7 +169,7 @@ public sealed class LiveEntityAnimationSchedulerTests var (live, record, entity) = BuildMaterialized( RemoteGuid, PhysicsStateFlags.Gravity); - var remote = new GameWindow.RemoteMotion + var remote = new AcDream.App.Physics.RemoteMotion { CellId = Cell, Airborne = true, @@ -286,7 +286,7 @@ public sealed class LiveEntityAnimationSchedulerTests var physics = new PhysicsEngine(); var projectiles = new ProjectileController(live, physics); record.FinalPhysicsState = ProjectileState; - var remote = new GameWindow.RemoteMotion + var remote = new AcDream.App.Physics.RemoteMotion { CellId = Cell, Airborne = false, @@ -598,9 +598,9 @@ public sealed class LiveEntityAnimationSchedulerTests Assert.Equal(1, rootPublishes); } - private static GameWindow.RemoteMotion BuildRemote(WorldEntity entity) + private static AcDream.App.Physics.RemoteMotion BuildRemote(WorldEntity entity) { - var remote = new GameWindow.RemoteMotion + var remote = new AcDream.App.Physics.RemoteMotion { CellId = Cell, }; diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs index d4b59bff..cfbfb27e 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs @@ -30,7 +30,7 @@ namespace AcDream.Core.Tests.Physics.Motion; // // This harness wires a real MotionInterpreter + AnimationSequencer + // MotionTableDispatchSink + MoveToManager EXACTLY like GameWindow's -// EnsureRemoteMotionBindings (GameWindow.cs:4251) and ticks them in +// EnsureRemoteMotionBindings and ticks them in // the retail per-object order: CSequence update → PositionManager adjust → // complete Frame combine → hook processing → Target/Movement/PartArray/ // Position manager tail. Wire events (aggro stance UM, mt-6 arms, attack @@ -81,8 +81,8 @@ internal sealed class RemoteChaseHarness public const float OwnRadius = 0.3f; public const float StickyTargetRadius = 0.5f; - // Field-for-field mirror of GameWindow's RemoteMotion construction - // (GameWindow.cs:592-618): Contact+OnWalkable+Active, InWorld=true (the + // Field-for-field mirror of AcDream.App.Physics.RemoteMotion construction: + // Contact+OnWalkable+Active, InWorld=true (the // R4-V5 door fix — without it the interp's detached-object guard strips // every dispatched transition link), and the R4-V5 #160 RemoteWeenie // (null weenie degrades run-rate to 1.0). @@ -98,7 +98,7 @@ internal sealed class RemoteChaseHarness public readonly AnimationSequencer Seq; public readonly MotionTableDispatchSink Sink; - /// R5-V5: GameWindow's RemoteMotion.Movement twin — the ONE + /// R5-V5: AcDream.App.Physics.RemoteMotion.Movement twin — the ONE /// per-entity MovementManager facade owning Interp + the MoveToManager /// (retail CPhysicsObj::movement_manager). public readonly MovementManager Movement; @@ -156,7 +156,7 @@ internal sealed class RemoteChaseHarness WeenieObj = new RemoteWeenie(), }; - // ── EnsureRemoteMotionBindings (GameWindow.cs:4251) verbatim ── + // ── EnsureRemoteMotionBindings verbatim ── Sink = new MotionTableDispatchSink(Seq); Interp.DefaultSink = Sink; // #174: production binds the seam to Manager.HandleEnterWorld @@ -167,7 +167,7 @@ internal sealed class RemoteChaseHarness Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions; // ── R5-V5: the MovementManager facade owns Interp + the moveto — - // GameWindow's RemoteMotion ctor + EnsureRemoteMotionBindings + // AcDream.App.Physics.RemoteMotion ctor + EnsureRemoteMotionBindings // factory shape verbatim (sticky binds inside the factory; // MakeMoveToManager after the host/Pm exist). ── Movement = new MovementManager(Interp) From fcb66198fc40bd4d25e691fdabc3343f1432ef91 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 14:05:34 +0200 Subject: [PATCH 41/98] refactor(world): canonicalize live physics host ownership --- .../retail-divergence-register.md | 5 +- .../Net/LiveSessionResetManifest.cs | 2 - src/AcDream.App/Physics/EntityPhysicsHost.cs | 314 ++++++++ src/AcDream.App/Physics/RemoteMotion.cs | 50 +- .../Rendering/EntityPhysicsHost.cs | 162 ---- src/AcDream.App/Rendering/GameWindow.cs | 126 ++- src/AcDream.App/World/LiveEntityRuntime.cs | 202 ++++- .../Physics/Motion/IPhysicsObjHost.cs | 34 +- .../Physics/Motion/StickyManager.cs | 2 +- .../Physics/Motion/TargetManager.cs | 78 +- .../Physics/Motion/TargettedVoyeurInfo.cs | 23 +- .../Net/LiveSessionResetPlanTests.cs | 3 - .../Physics/PlayerMovementHiddenTests.cs | 1 + .../Physics/ProjectileControllerTests.cs | 5 +- .../Rendering/EntityPhysicsHostTests.cs | 2 +- .../LiveEntityPhysicsHostOwnershipTests.cs | 745 ++++++++++++++++++ .../Input/PlayerMoveToCutoverTests.cs | 1 + .../Physics/Motion/R5ManagerHarness.cs | 13 +- .../Motion/RemoteChaseEndToEndHarnessTests.cs | 15 +- .../Physics/Motion/StickyManagerTests.cs | 4 +- .../Physics/Motion/TargetManagerTests.cs | 82 +- 21 files changed, 1546 insertions(+), 323 deletions(-) create mode 100644 src/AcDream.App/Physics/EntityPhysicsHost.cs delete mode 100644 src/AcDream.App/Rendering/EntityPhysicsHost.cs create mode 100644 tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 24a5d55c..781d88d8 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -61,7 +61,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 37 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) +## 2. Adaptation (AD) — 38 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -103,6 +103,7 @@ accepted-divergence entries (#96, #49, #50). | 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-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` | --- @@ -255,7 +256,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) | | TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` | | TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` | -| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Rendering/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` | +| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Rendering/GameWindow.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` | | TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order | | TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each object's ParticleManager and ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` also advances that static owner's managers using its whole admitted elapsed interval. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`) | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and manager order faithful without pretending the shared tails have per-owner timing. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static default scripts/particles use render elapsed rather than `animate_static_object` elapsed/discard timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum | | TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 | diff --git a/src/AcDream.App/Net/LiveSessionResetManifest.cs b/src/AcDream.App/Net/LiveSessionResetManifest.cs index 9c6c3191..310a3e2d 100644 --- a/src/AcDream.App/Net/LiveSessionResetManifest.cs +++ b/src/AcDream.App/Net/LiveSessionResetManifest.cs @@ -39,7 +39,6 @@ internal sealed class LiveSessionResetBindings public required Action AnimationHookFrames { get; init; } public required Action LivePresentation { get; init; } public required Action RemoteMovementDiagnostics { get; init; } - public required Action PhysicsHostIndex { get; init; } } /// @@ -92,7 +91,6 @@ internal static class LiveSessionResetManifest new("animation hook frames", bindings.AnimationHookFrames), new("live presentation", bindings.LivePresentation), new("remote movement diagnostics", bindings.RemoteMovementDiagnostics), - new("physics host index", bindings.PhysicsHostIndex), ]); } } diff --git a/src/AcDream.App/Physics/EntityPhysicsHost.cs b/src/AcDream.App/Physics/EntityPhysicsHost.cs new file mode 100644 index 00000000..3b8a89e9 --- /dev/null +++ b/src/AcDream.App/Physics/EntityPhysicsHost.cs @@ -0,0 +1,314 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.App.World; + +namespace AcDream.App.Physics; + +/// +/// R5-V2 — the App-side per entity: acdream's +/// stand-in for retail's CPhysicsObj as the movement managers see it. +/// One is built per entity (a remote RemoteMotion or the local player) +/// in GameWindow.EnsureRemoteMotionBindings / EnterPlayerModeNow +/// and stored on the exact LiveEntityRecord, so +/// can resolve OTHER entities' hosts — the +/// cross-entity delivery path the voyeur system +/// needs. +/// +/// Owns a (retail +/// CPhysicsObj::target_manager). Its set_target/clear_target/ +/// add_voyeur/remove_voyeur/receive_target_update seams +/// forward to it exactly as retail's CPhysicsObj does; the movement managers' +/// target seams are repointed here, replacing the AP-79 poll adapter. The +/// per-entity accessors (position/velocity/radius/contact/clocks) and the +/// fan-out are injected by GameWindow so this +/// class stays free of GameWindow's internals (code-structure rule #1). +/// +/// R5-V3: owns a too (retail +/// CPhysicsObj::position_manager — retail creates it lazily via +/// get_position_manager; acdream constructs it eagerly, which is +/// behaviorally identical because the empty facade no-ops until its first +/// StickTo/ConstrainTo). fans +/// deliveries to the injected MoveToManager fan FIRST, then the +/// PositionManager — retail CPhysicsObj::HandleUpdateTarget order +/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager +/// @0x00512c1a). +/// +public sealed class EntityPhysicsHost : IPhysicsObjHost +{ + private Func _getPosition; + private Func _getVelocity; + private Func _getRadius; + private Func _inContact; + private Func _minterpMaxSpeed; + private Func _curTime; + private Func _physicsTimerTime; + private Func _getObjectA; + private Action _handleUpdateTarget; + private Action _interruptCurrentMovement; + private readonly TargetManager _targetManager; + + public EntityPhysicsHost( + uint id, + Func getPosition, + Func getVelocity, + Func getRadius, + Func inContact, + Func minterpMaxSpeed, + Func curTime, + Func physicsTimerTime, + Func getObjectA, + Action handleUpdateTarget, + Action interruptCurrentMovement) + { + Id = id; + _getPosition = null!; + _getVelocity = null!; + _getRadius = null!; + _inContact = null!; + _minterpMaxSpeed = null!; + _curTime = null!; + _physicsTimerTime = null!; + _getObjectA = null!; + _handleUpdateTarget = null!; + _interruptCurrentMovement = null!; + Rebind( + getPosition, + getVelocity, + getRadius, + inContact, + minterpMaxSpeed, + curTime, + physicsTimerTime, + getObjectA, + handleUpdateTarget, + interruptCurrentMovement); + _targetManager = new TargetManager(this); + PositionManager = new PositionManager(this); + } + + /// + /// Rebinds the changing CPhysicsObj access seams without replacing this + /// object's retail-lifetime managers. A static/minimal live object can gain + /// a MovementManager later, and the local player controller can be rebuilt, + /// but retail keeps the same CPhysicsObj, TargetManager, and PositionManager + /// for the accepted INSTANCE_TS incarnation. Existing voyeur, sticky, and + /// constraint state therefore survives the upgrade. + /// + private void Rebind( + Func getPosition, + Func getVelocity, + Func getRadius, + Func inContact, + Func minterpMaxSpeed, + Func curTime, + Func physicsTimerTime, + Func getObjectA, + Action handleUpdateTarget, + Action interruptCurrentMovement) + { + ArgumentNullException.ThrowIfNull(getPosition); + ArgumentNullException.ThrowIfNull(getVelocity); + ArgumentNullException.ThrowIfNull(getRadius); + ArgumentNullException.ThrowIfNull(inContact); + ArgumentNullException.ThrowIfNull(minterpMaxSpeed); + ArgumentNullException.ThrowIfNull(curTime); + ArgumentNullException.ThrowIfNull(physicsTimerTime); + ArgumentNullException.ThrowIfNull(getObjectA); + ArgumentNullException.ThrowIfNull(handleUpdateTarget); + ArgumentNullException.ThrowIfNull(interruptCurrentMovement); + + _getPosition = getPosition; + _getVelocity = getVelocity; + _getRadius = getRadius; + _inContact = inContact; + _minterpMaxSpeed = minterpMaxSpeed; + _curTime = curTime; + _physicsTimerTime = physicsTimerTime; + _getObjectA = getObjectA; + _handleUpdateTarget = handleUpdateTarget; + _interruptCurrentMovement = interruptCurrentMovement; + } + + /// + /// Applies a freshly composed delegate configuration while retaining this + /// host's exact identity and manager instances. + /// + private void RebindFrom(EntityPhysicsHost configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + if (configuration.Id != Id) + throw new ArgumentException( + "A physics-host configuration must match the existing host GUID.", + nameof(configuration)); + + Rebind( + configuration._getPosition, + configuration._getVelocity, + configuration._getRadius, + configuration._inContact, + configuration._minterpMaxSpeed, + configuration._curTime, + configuration._physicsTimerTime, + configuration._getObjectA, + configuration._handleUpdateTarget, + configuration._interruptCurrentMovement); + } + + /// + /// Publishes the first host for an exact live-object incarnation, or + /// enriches that incarnation's existing minimal host in place. This is the + /// shared remote/player composition seam; it deliberately never replaces + /// TargetManager or PositionManager ownership. + /// + internal static EntityPhysicsHost InstallOrRebind( + LiveEntityRuntime runtime, + LiveEntityRecord expectedRecord, + EntityPhysicsHost configuration) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(expectedRecord); + ArgumentNullException.ThrowIfNull(configuration); + if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current) + || !ReferenceEquals(current, expectedRecord)) + { + throw new InvalidOperationException( + $"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host composition."); + } + + if (current.PhysicsHost is null) + { + runtime.InstallPhysicsHost(current, configuration); + return configuration; + } + + if (current.PhysicsHost is not EntityPhysicsHost existing) + { + throw new InvalidOperationException( + $"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation."); + } + + existing.RebindFrom(configuration); + return existing; + } + + /// + /// Builds the position-only CPhysicsObj facade used before an animated or + /// player movement owner exists. The delegates capture the exact live + /// record rather than the visible-world projection, so exit_world + /// notifications retain the object's last cell and frame after the picker + /// view has already withdrawn it. + /// + internal static EntityPhysicsHost CreateMinimal( + LiveEntityRecord record, + Func resolve, + Func now) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(resolve); + ArgumentNullException.ThrowIfNull(now); + return new EntityPhysicsHost( + record.ServerGuid, + getPosition: () => new Position( + record.FullCellId, + record.WorldEntity?.Position ?? Vector3.Zero, + record.WorldEntity?.Rotation ?? Quaternion.Identity), + getVelocity: () => Vector3.Zero, + getRadius: () => 0f, + inContact: () => true, + minterpMaxSpeed: () => null, + curTime: now, + physicsTimerTime: now, + getObjectA: resolve, + handleUpdateTarget: _ => { }, + interruptCurrentMovement: () => { }); + } + + // ── IPhysicsObjHost accessors ────────────────────────────────────────── + public uint Id { get; } + public Position Position => _getPosition(); + public Vector3 Velocity => _getVelocity(); + public float Radius => _getRadius(); + public bool InContact => _inContact(); + public float? MinterpMaxSpeed => _minterpMaxSpeed(); + public double CurTime => _curTime(); + public double PhysicsTimerTime => _physicsTimerTime(); + + /// The owned voyeur manager (retail + /// CPhysicsObj::target_manager). + public TargetManager TargetManager => _targetManager; + + /// R5-V3 — the owned facade (retail + /// CPhysicsObj::position_manager): sticky follow + (unarmed) + /// constraint leash. Seam targets: MoveToManager.StickTo/Unstick, + /// MotionInterpreter.UnstickFromObject, the per-tick + /// AdjustOffset/UseTime drivers. + public PositionManager PositionManager { get; } + + // ── IPhysicsObjHost fan-out / target-tracking seams ──────────────────── + public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id); + public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => + _targetManager.GetRelationshipTarget(objectId); + + public void HandleUpdateTarget(TargetInfo info) + { + // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order: + // MovementManager (the injected MoveToManager fan) first, then + // PositionManager (@0x00512c1a — the R5-V3 sticky consumer). + _handleUpdateTarget(info); + PositionManager.HandleUpdateTarget(info); + } + public void InterruptCurrentMovement() => _interruptCurrentMovement(); + + public void SetTarget(uint contextId, uint objectId, float radius, double quantum) + => _targetManager.SetTarget(contextId, objectId, radius, quantum); + + public void ClearTarget() => _targetManager.ClearTarget(); + public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) => + _targetManager.ReceiveUpdate(info, sender); + public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) + => _targetManager.AddVoyeur(watcher, radius, quantum); + public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) => + _targetManager.RemoveVoyeur(watcherId, expectedWatcher); + + // ── per-tick driver + lifecycle (called by GameWindow) ───────────────── + + /// Retail TargetManager::HandleTargetting — the per-tick + /// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally + /// for every entity in UpdateObjectInternal, BEFORE the movement + /// managers' UseTime. + public void HandleTargetting() => _targetManager.HandleTargetting(); + + /// Retail CPhysicsObj::exit_world's + /// TargetManager::NotifyVoyeurOfEvent(ExitWorld) — tell every + /// watcher of this entity that it left the world (they drop the + /// stick/moveto). Called on despawn before the host is removed from the + /// registry. + public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld); + + /// + /// A Hidden object remains logically alive but is removed from the cell + /// lookup/interaction surface. Retail's DetectionManager sends + /// LeftDetection from CObjCell::hide_object; that manager is + /// not ported yet, so the App bridges the same unavailability edge through + /// the existing target fan-out. The non-Ok update tears down watcher + /// MoveTo/Sticky consumers, and the watched-role table is cleared so an + /// UnHide re-subscription receives a fresh initial snapshot. The object's + /// own watcher role is deliberately preserved. + /// + public void NotifyHidden() => + _targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld); + + /// R5-V3 (#171): retail CPhysicsObj::teleport_hook's tail + /// (0x00514ed0 @0x00514f1b-0x00514f28) — TargetManager::ClearTarget + /// (drop this entity's OWN subscription) then + /// NotifyVoyeurOfEvent(Teleported) (every entity watching THIS one + /// drops its stick/moveto — StickyManager::HandleUpdateTarget's + /// non-Ok teardown path). Called after a teleport placement. + public void NotifyTeleported() + { + _targetManager.ClearTarget(); + _targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported); + } +} diff --git a/src/AcDream.App/Physics/RemoteMotion.cs b/src/AcDream.App/Physics/RemoteMotion.cs index 7b35d0bd..e29ee21c 100644 --- a/src/AcDream.App/Physics/RemoteMotion.cs +++ b/src/AcDream.App/Physics/RemoteMotion.cs @@ -1,4 +1,3 @@ -using AcDream.App.Rendering; using AcDream.App.World; namespace AcDream.App.Physics; @@ -22,7 +21,8 @@ namespace AcDream.App.Physics; /// /// internal sealed class RemoteMotion : - AcDream.App.World.ILiveEntityRemotePlacementRuntime + AcDream.App.World.ILiveEntityRemotePlacementRuntime, + AcDream.App.World.ILiveEntityCanonicalRuntimeConsumer { public AcDream.Core.Physics.PhysicsBody Body { get; } AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body; @@ -82,8 +82,13 @@ internal sealed class RemoteMotion : // voyeur system (retail CPhysicsObj::target_manager). Replaces the // AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/ // clear_target/quantum seams route here, HandleTargetting ticks per - // frame, and OTHER entities resolve this one via GameWindow._physicsHosts. - public EntityPhysicsHost? Host; + // frame, and OTHER entities resolve this one through LiveEntityRuntime's + // exact-incarnation PhysicsHost slot. + private Func? _physicsHostReader; + private bool _fullPhysicsHostBound; + public EntityPhysicsHost? Host => _fullPhysicsHostBound + ? _physicsHostReader?.Invoke() as EntityPhysicsHost + : null; /// /// True while a server MoveToObject/MoveToPosition packet is the /// active locomotion source. Retail runs these through MoveToManager @@ -299,9 +304,42 @@ internal sealed class RemoteMotion : }); } + public void BindCanonicalRuntime( + Func readPhysicsHost, + Func readCell, + Action writeCell) + { + ArgumentNullException.ThrowIfNull(readPhysicsHost); + ArgumentNullException.ThrowIfNull(readCell); + ArgumentNullException.ThrowIfNull(writeCell); + if (_physicsHostReader is not null + || _canonicalCellReader is not null + || _canonicalCellWriter is not null) + { + throw new InvalidOperationException("The remote canonical runtime context is already bound."); + } + + // All validation precedes publication. Nothing below can throw, so a + // rejected context never leaves a half-bound reusable component. + _physicsHostReader = readPhysicsHost; + _canonicalCellReader = readCell; + _canonicalCellWriter = writeCell; + } + public void BindCanonicalCell(Func read, Action write) { - _canonicalCellReader = read ?? throw new ArgumentNullException(nameof(read)); - _canonicalCellWriter = write ?? throw new ArgumentNullException(nameof(write)); + ArgumentNullException.ThrowIfNull(read); + ArgumentNullException.ThrowIfNull(write); + if (_canonicalCellReader is not null || _canonicalCellWriter is not null) + throw new InvalidOperationException("The remote canonical cell source is already bound."); + _canonicalCellReader = read; + _canonicalCellWriter = write; + } + + public void MarkFullPhysicsHostBound() + { + if (_physicsHostReader is null) + throw new InvalidOperationException("The canonical physics-host source is not bound."); + _fullPhysicsHostBound = true; } } diff --git a/src/AcDream.App/Rendering/EntityPhysicsHost.cs b/src/AcDream.App/Rendering/EntityPhysicsHost.cs deleted file mode 100644 index 3aa1d2e7..00000000 --- a/src/AcDream.App/Rendering/EntityPhysicsHost.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; - -namespace AcDream.App.Rendering; - -/// -/// R5-V2 — the App-side per entity: acdream's -/// stand-in for retail's CPhysicsObj as the movement managers see it. -/// One is built per entity (a remote RemoteMotion or the local player) -/// in GameWindow.EnsureRemoteMotionBindings / EnterPlayerModeNow -/// and registered in GameWindow._physicsHosts (guid → host), so -/// can resolve OTHER entities' hosts — the -/// cross-entity delivery path the voyeur system -/// needs. -/// -/// Owns a (retail -/// CPhysicsObj::target_manager). Its set_target/clear_target/ -/// add_voyeur/remove_voyeur/receive_target_update seams -/// forward to it exactly as retail's CPhysicsObj does; the movement managers' -/// target seams are repointed here, replacing the AP-79 poll adapter. The -/// per-entity accessors (position/velocity/radius/contact/clocks) and the -/// fan-out are injected by GameWindow so this -/// class stays free of GameWindow's internals (code-structure rule #1). -/// -/// R5-V3: owns a too (retail -/// CPhysicsObj::position_manager — retail creates it lazily via -/// get_position_manager; acdream constructs it eagerly, which is -/// behaviorally identical because the empty facade no-ops until its first -/// StickTo/ConstrainTo). fans -/// deliveries to the injected MoveToManager fan FIRST, then the -/// PositionManager — retail CPhysicsObj::HandleUpdateTarget order -/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager -/// @0x00512c1a). -/// -public sealed class EntityPhysicsHost : IPhysicsObjHost -{ - private readonly Func _getPosition; - private readonly Func _getVelocity; - private readonly Func _getRadius; - private readonly Func _inContact; - private readonly Func _minterpMaxSpeed; - private readonly Func _curTime; - private readonly Func _physicsTimerTime; - private readonly Func _getObjectA; - private readonly Action _handleUpdateTarget; - private readonly Action _interruptCurrentMovement; - private readonly TargetManager _targetManager; - - public EntityPhysicsHost( - uint id, - Func getPosition, - Func getVelocity, - Func getRadius, - Func inContact, - Func minterpMaxSpeed, - Func curTime, - Func physicsTimerTime, - Func getObjectA, - Action handleUpdateTarget, - Action interruptCurrentMovement) - { - Id = id; - _getPosition = getPosition ?? throw new ArgumentNullException(nameof(getPosition)); - _getVelocity = getVelocity ?? throw new ArgumentNullException(nameof(getVelocity)); - _getRadius = getRadius ?? throw new ArgumentNullException(nameof(getRadius)); - _inContact = inContact ?? throw new ArgumentNullException(nameof(inContact)); - _minterpMaxSpeed = minterpMaxSpeed ?? throw new ArgumentNullException(nameof(minterpMaxSpeed)); - _curTime = curTime ?? throw new ArgumentNullException(nameof(curTime)); - _physicsTimerTime = physicsTimerTime ?? throw new ArgumentNullException(nameof(physicsTimerTime)); - _getObjectA = getObjectA ?? throw new ArgumentNullException(nameof(getObjectA)); - _handleUpdateTarget = handleUpdateTarget ?? throw new ArgumentNullException(nameof(handleUpdateTarget)); - _interruptCurrentMovement = interruptCurrentMovement - ?? throw new ArgumentNullException(nameof(interruptCurrentMovement)); - _targetManager = new TargetManager(this); - PositionManager = new PositionManager(this); - } - - // ── IPhysicsObjHost accessors ────────────────────────────────────────── - public uint Id { get; } - public Position Position => _getPosition(); - public Vector3 Velocity => _getVelocity(); - public float Radius => _getRadius(); - public bool InContact => _inContact(); - public float? MinterpMaxSpeed => _minterpMaxSpeed(); - public double CurTime => _curTime(); - public double PhysicsTimerTime => _physicsTimerTime(); - - /// The owned voyeur manager (retail - /// CPhysicsObj::target_manager). - public TargetManager TargetManager => _targetManager; - - /// R5-V3 — the owned facade (retail - /// CPhysicsObj::position_manager): sticky follow + (unarmed) - /// constraint leash. Seam targets: MoveToManager.StickTo/Unstick, - /// MotionInterpreter.UnstickFromObject, the per-tick - /// AdjustOffset/UseTime drivers. - public PositionManager PositionManager { get; } - - // ── IPhysicsObjHost fan-out / target-tracking seams ──────────────────── - public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id); - - public void HandleUpdateTarget(TargetInfo info) - { - // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order: - // MovementManager (the injected MoveToManager fan) first, then - // PositionManager (@0x00512c1a — the R5-V3 sticky consumer). - _handleUpdateTarget(info); - PositionManager.HandleUpdateTarget(info); - } - public void InterruptCurrentMovement() => _interruptCurrentMovement(); - - public void SetTarget(uint contextId, uint objectId, float radius, double quantum) - => _targetManager.SetTarget(contextId, objectId, radius, quantum); - - public void ClearTarget() => _targetManager.ClearTarget(); - public void ReceiveTargetUpdate(TargetInfo info) => _targetManager.ReceiveUpdate(info); - public void AddVoyeur(uint watcherId, float radius, double quantum) - => _targetManager.AddVoyeur(watcherId, radius, quantum); - public void RemoveVoyeur(uint watcherId) => _targetManager.RemoveVoyeur(watcherId); - - // ── per-tick driver + lifecycle (called by GameWindow) ───────────────── - - /// Retail TargetManager::HandleTargetting — the per-tick - /// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally - /// for every entity in UpdateObjectInternal, BEFORE the movement - /// managers' UseTime. - public void HandleTargetting() => _targetManager.HandleTargetting(); - - /// Retail CPhysicsObj::exit_world's - /// TargetManager::NotifyVoyeurOfEvent(ExitWorld) — tell every - /// watcher of this entity that it left the world (they drop the - /// stick/moveto). Called on despawn before the host is removed from the - /// registry. - public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld); - - /// - /// A Hidden object remains logically alive but is removed from the cell - /// lookup/interaction surface. Retail's DetectionManager sends - /// LeftDetection from CObjCell::hide_object; that manager is - /// not ported yet, so the App bridges the same unavailability edge through - /// the existing target fan-out. The non-Ok update tears down watcher - /// MoveTo/Sticky consumers, and the watched-role table is cleared so an - /// UnHide re-subscription receives a fresh initial snapshot. The object's - /// own watcher role is deliberately preserved. - /// - public void NotifyHidden() => - _targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld); - - /// R5-V3 (#171): retail CPhysicsObj::teleport_hook's tail - /// (0x00514ed0 @0x00514f1b-0x00514f28) — TargetManager::ClearTarget - /// (drop this entity's OWN subscription) then - /// NotifyVoyeurOfEvent(Teleported) (every entity watching THIS one - /// drops its stick/moveto — StickyManager::HandleUpdateTarget's - /// non-Ok teardown path). Called after a teleport placement. - public void NotifyTeleported() - { - _targetManager.ClearTarget(); - _targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported); - } -} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index dcd6ba55..dada8342 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -777,7 +777,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private IReadOnlyDictionary LastSpawns => _liveEntities?.Snapshots ?? EmptyLiveSpawnMap; // R5-V2: the local player's CPhysicsObj stand-in — owns the player's - // TargetManager voyeur system, registered in _physicsHosts so remote + // TargetManager voyeur system, stored on the exact live record so remote // entities chasing the player resolve it. Replaces the AP-79 // _playerMoveToTarget* poll fields. private EntityPhysicsHost? _playerHost; @@ -787,7 +787,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // giving the TargetManager voyeur round-trip its cross-entity delivery // path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow // (player); pruned only by logical LiveEntityRuntime teardown. - private readonly Dictionary _physicsHosts = new(); private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; // ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184 @@ -2644,7 +2643,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext AnimationHookFrames = () => _animationHookFrames?.Clear(), LivePresentation = () => _liveEntityPresentation?.Clear(), RemoteMovementDiagnostics = _remoteLastMove.Clear, - PhysicsHostIndex = _physicsHosts.Clear, }); private void ResetSessionMouseCapture() @@ -4714,7 +4712,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // live object has no PartArray sink, so build the manager once anyway. if (rm.Host is not null) return rm.Sink; - + if (_liveEntities is not { } liveEntities + || !liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord hostRecord) + || !ReferenceEquals(hostRecord.RemoteMotionRuntime, rm)) + { + return rm.Sink; + } // R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness // wiring (the conformance-tested reference). Positions are WORLD // space on both sides (getPosition + the MovementStruct positions @@ -4799,12 +4802,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // HandleUpdateTarget fans deliveries to this entity's MoveToManager // and (R5-V3, inside the host) its PositionManager — retail's // CPhysicsObj::HandleUpdateTarget order. - host = new EntityPhysicsHost( + var configuredHost = new EntityPhysicsHost( serverGuid, getPosition: () => new AcDream.Core.Physics.Position( - rmT.CellId, - _entitiesByServerGuid.TryGetValue(serverGuid, out var selfEnt) - ? selfEnt.Position : mtBody.Position, + hostRecord.FullCellId, + hostRecord.WorldEntity?.Position ?? mtBody.Position, mtBody.Orientation), getVelocity: () => mtBody.Velocity, getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, @@ -4820,8 +4822,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info), interruptCurrentMovement: () => rmT.Movement.CancelMoveTo( AcDream.Core.Physics.WeenieError.ActionCancelled)); - rm.Host = host; - _physicsHosts[serverGuid] = host; + host = EntityPhysicsHost.InstallOrRebind( + liveEntities, + hostRecord, + configuredHost); + rm.MarkFullPhysicsHostBound(); // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the // MoveToManager via the factory above (host now exists for the @@ -4851,42 +4856,38 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id) { - // CObjCell::hide_object removes Hidden objects from the lookup surface - // used to establish new target/voyeur relationships. Existing - // subscriptions are cleared by LiveEntityPresentationController. - if (!_visibleEntitiesByServerGuid.ContainsKey(id)) + if (_liveEntities is not { } liveEntities) + return null; + + bool isActive = liveEntities.TryGetRecord(id, out LiveEntityRecord activeRecord); + if (!isActive) + return null; + + // TS-49: CObjCell::hide_object removes Hidden objects from the + // relationship surface until DetectionManager is ported. Pending, + // attached, and otherwise non-render-visible active objects remain in + // CObjectMaint's object table and must still resolve here. + if (liveEntities.IsHidden(id)) { if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) { Console.WriteLine( $"[autowalk-host-miss] object=0x{id:X8} " + $"materialized={_entitiesByServerGuid.ContainsKey(id)} " - + $"registered={_physicsHosts.ContainsKey(id)} " - + $"hidden={_liveEntities?.IsHidden(id) == true}"); + + $"registered={liveEntities.TryGetPhysicsHost(id, out _)} " + + $"hidden={liveEntities.IsHidden(id)}"); } return null; } - if (_physicsHosts.TryGetValue(id, out var existing)) + if (liveEntities.TryGetPhysicsHost(id, out var existing)) return existing; double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - var minimal = new EntityPhysicsHost( - id, - getPosition: () => new AcDream.Core.Physics.Position( - 0u, - _visibleEntitiesByServerGuid.TryGetValue(id, out var e) - ? e.Position : System.Numerics.Vector3.Zero, - System.Numerics.Quaternion.Identity), - getVelocity: () => System.Numerics.Vector3.Zero, // static target - getRadius: () => 0f, - inContact: () => true, - minterpMaxSpeed: () => null, - curTime: NowSeconds, - physicsTimerTime: NowSeconds, - getObjectA: ResolvePhysicsHost, - handleUpdateTarget: _ => { }, // not a watcher - interruptCurrentMovement: () => { }); - _physicsHosts[id] = minimal; + var minimal = EntityPhysicsHost.CreateMinimal( + activeRecord, + ResolvePhysicsHost, + NowSeconds); + liveEntities.InstallPhysicsHost(activeRecord, minimal); return minimal; } @@ -4973,7 +4974,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext AcDream.Core.Selection.SelectionChangeSource.System, AcDream.Core.Selection.SelectionChangeReason.Cleared); - if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost) + if (_liveEntities?.TryGetPhysicsHost(serverGuid, out var hiddenHost) == true && hiddenHost is EntityPhysicsHost hiddenEntityHost) hiddenEntityHost.NotifyHidden(); } @@ -5038,15 +5039,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // moveto/stick. This is the ONLY clean-up for a watcher whose target // already sent an Ok (once status != Undefined the 10 s staleness // timeout never fires), so it must run before the host is pruned. - EntityPhysicsHost? ephGone = rmGone?.Host; - if (ephGone is null) - { - incarnation.RunIfNoReplacement(() => - { - if (_physicsHosts.TryGetValue(serverGuid, out var hostGone)) - ephGone = hostGone as EntityPhysicsHost; - }); - } + EntityPhysicsHost? ephGone = record.PhysicsHost as EntityPhysicsHost; if (ephGone is not null) { // R5-V3 (#171): retail exit_world (0x00514e60) order — the @@ -5061,8 +5054,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext cleanups.Add(ephGone.PositionManager.UnStick); cleanups.Add(ephGone.ClearTarget); cleanups.Add(ephGone.NotifyExitWorld); - EntityPhysicsHost capturedHost = ephGone; - cleanups.Add(() => incarnation.RemoveCaptured(_physicsHosts, capturedHost)); } cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id)); cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id)); @@ -5259,7 +5250,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext bool targetVisible = turnPath.TargetGuid is { } visibleGuid && _visibleEntitiesByServerGuid.ContainsKey(visibleGuid); bool targetHost = turnPath.TargetGuid is { } hostGuid - && _physicsHosts.ContainsKey(hostGuid); + && _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true; var moveTo = movement.MoveTo; Console.WriteLine( $"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} " @@ -10922,9 +10913,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Func isCurrent) { _remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote); - EntityPhysicsHost? host = _physicsHosts.TryGetValue(serverGuid, out var registered) - ? registered as EntityPhysicsHost - : remote?.Host; + EntityPhysicsHost? host = + _liveEntities?.TryGetPhysicsHost(serverGuid, out var registered) == true + ? registered as EntityPhysicsHost + : null; return AcDream.App.Physics.RemoteTeleportHook.Execute( new AcDream.App.Physics.RemoteTeleportHookActions( CancelMoveTo: error => remote?.Movement.CancelMoveTo(error), @@ -13139,18 +13131,19 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext return false; } - LiveEntityRecord? playerRecord = null; - if (_liveEntities?.TryGetRecord( + if (_liveEntities is not { } playerLiveEntities + || !playerLiveEntities.TryGetRecord( _playerServerGuid, - out LiveEntityRecord foundPlayerRecord) == true) + out LiveEntityRecord playerRecord)) { - playerRecord = foundPlayerRecord; + Console.WriteLine( + $"live: {loggingTag} — player record 0x{_playerServerGuid:X8} not found yet"); + return false; } _playerController = new AcDream.App.Input.PlayerMovementController( _physicsEngine, - playerRecord?.ObjectClock); - if (playerRecord is not null) - _playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState); + playerRecord.ObjectClock); + _playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState); // R4-V5: the local player's verbatim MoveToManager — same seam // wiring shape as EnsureRemoteMotionBindings, with three @@ -13232,15 +13225,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // R5-V2: the player's CPhysicsObj stand-in + TargetManager. Position is // the player's WORLD position (WorldEntity.Position — what the AP-79 // poll used), so an NPC watching the player receives the identical - // position. Registered in _physicsHosts so those NPCs' GetObjectA + // position. Stored on the exact live record so those NPCs' GetObjectA // resolves the player; HandleTargetting is ticked in the player // pre-Update block. - playerHost = new EntityPhysicsHost( + var exactPlayerMovement = pcMoveTo.Movement; + var configuredPlayerHost = new EntityPhysicsHost( _playerServerGuid, getPosition: () => new AcDream.Core.Physics.Position( - pcMoveTo.CellId, - _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pSelf) - ? pSelf.Position : pcMoveTo.Position, + playerRecord.FullCellId, + playerRecord.WorldEntity?.Position ?? pcMoveTo.Position, pcMoveTo.BodyOrientation), getVelocity: () => pcMoveTo.BodyVelocity, getRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius, @@ -13263,12 +13256,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext + $"{info.TargetPosition.Frame.Origin.Y:F2}," + $"{info.TargetPosition.Frame.Origin.Z:F2})"); } - _playerController?.Movement.HandleUpdateTarget(info); + exactPlayerMovement.HandleUpdateTarget(info); }, - interruptCurrentMovement: () => _playerController?.Movement.CancelMoveTo( + interruptCurrentMovement: () => exactPlayerMovement.CancelMoveTo( AcDream.Core.Physics.WeenieError.ActionCancelled)); + playerHost = EntityPhysicsHost.InstallOrRebind( + playerLiveEntities, + playerRecord, + configuredPlayerHost); _playerHost = playerHost; - _physicsHosts[_playerServerGuid] = playerHost; // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the // player MoveToManager via the factory above (playerHost now exists diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 7b064a2a..c82d8655 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -24,6 +24,29 @@ public interface ILiveEntityRemoteMotionRuntime PhysicsBody Body { get; } } +/// +/// Optional seam for a remote-motion component that reads the exact +/// incarnation's canonical movement-manager host from +/// . +/// +public interface ILiveEntityPhysicsHostConsumer +{ + void BindPhysicsHost(Func read); +} + +/// +/// Atomic composition seam for a component that consumes both canonical host +/// and cell identity. Implementations validate every delegate before +/// publishing any of them, so a failed bind leaves the component reusable. +/// +public interface ILiveEntityCanonicalRuntimeConsumer +{ + void BindCanonicalRuntime( + Func readPhysicsHost, + Func readCell, + Action writeCell); +} + /// /// Optional seam for a remote-motion component that consumes the canonical /// full-cell identity owned by . The runtime @@ -216,6 +239,8 @@ public sealed class LiveEntityRecord internal bool PhysicsBodyAcquisitionInProgress { get; set; } public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; } public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; } + internal bool RemoteMotionBindingInProgress { get; set; } + public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; } internal bool RequiresRemotePlacementRuntime { get; set; } public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; } public ILiveEntityEffectProfile? EffectProfile { get; internal set; } @@ -1201,10 +1226,30 @@ public sealed class LiveEntityRuntime ArgumentNullException.ThrowIfNull(runtime); if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists."); + if (record.RemoteMotionBindingInProgress) + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} remote-motion binding is already in progress."); + PhysicsBody candidateBody = runtime.Body + ?? throw new InvalidOperationException("A remote-motion runtime returned no physics body."); + if (ReferenceEquals(record.RemoteMotionRuntime, runtime)) + { + if (!ReferenceEquals(record.PhysicsBody, candidateBody)) + { + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} remote motion changed its canonical physics body."); + } + // Idempotent publication is important when motion and vector + // packets converge on the same runtime in one update turn. The + // component seams are incarnation-bound and must never be rebound. + candidateBody.State = record.FinalPhysicsState; + SynchronizePhysicsBodyActiveState(record); + _remoteMotionByGuid[serverGuid] = runtime; + RefreshSpatialRuntimeIndexes(record); + return; + } if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null) throw new InvalidOperationException( $"Live entity 0x{serverGuid:X8} cannot bind remote motion during physics-body acquisition."); - PhysicsBody candidateBody = runtime.Body; if (record.PhysicsBody is { } canonicalBody && !ReferenceEquals(canonicalBody, candidateBody)) { @@ -1217,38 +1262,134 @@ public sealed class LiveEntityRuntime throw new InvalidOperationException( $"Live entity 0x{serverGuid:X8} cannot discard its remote placement contract within one incarnation."); } - record.RequiresRemotePlacementRuntime |= - runtime is ILiveEntityRemotePlacementRuntime; - record.RemoteMotionRuntime = runtime; - record.PhysicsBody = candidateBody; - candidateBody.State = record.FinalPhysicsState; - SynchronizePhysicsBodyActiveState(record); - if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer) + // Bind every possibly-throwing exact-incarnation seam before + // publishing any canonical record/index state. An already-bound + // component from an older GUID generation must fail without poisoning + // the replacement record. The callback is arbitrary App code, so the + // exact record, authority epoch, and expected owners are revalidated + // before commit just like resource/physics-body acquisition. + LiveEntityRecord incarnation = record; + ulong sessionVersion = _sessionLifetimeVersion; + ulong lifetimeVersion = CurrentLifetimeMutation(serverGuid); + PhysicsBody? expectedBody = record.PhysicsBody; + ILiveEntityRemoteMotionRuntime? expectedRuntime = record.RemoteMotionRuntime; + bool expectedPlacementContract = record.RequiresRemotePlacementRuntime; + bool expectedIndexPresent = _remoteMotionByGuid.TryGetValue( + serverGuid, + out ILiveEntityRemoteMotionRuntime? expectedIndexedRuntime); + Func readPhysicsHost = () => + _recordsByGuid.TryGetValue(serverGuid, out var current) + && ReferenceEquals(current, incarnation) + && ReferenceEquals(current.RemoteMotionRuntime, runtime) + ? current.PhysicsHost + : null; + // Reads remain bound to the exact CPhysicsObj incarnation through its + // exit_world notifications. Only writes require active ownership. + Func readCell = () => incarnation.FullCellId; + Action writeCell = cellId => { - // Capture the incarnation, not only its GUID. A callback retained - // by an old MovementManager must never mutate a replacement that - // later reuses the same server GUID. - LiveEntityRecord incarnation = record; - cellConsumer.BindCanonicalCell( - read: () => - _recordsByGuid.TryGetValue(serverGuid, out var current) - && ReferenceEquals(current, incarnation) - && ReferenceEquals(current.RemoteMotionRuntime, runtime) - ? current.FullCellId - : 0u, - write: cellId => + if (cellId != 0 + && _recordsByGuid.TryGetValue(serverGuid, out var current) + && ReferenceEquals(current, incarnation) + && ReferenceEquals(current.RemoteMotionRuntime, runtime)) + { + RebucketLiveEntity(serverGuid, cellId); + } + }; + + record.RemoteMotionBindingInProgress = true; + try + { + if (runtime is ILiveEntityCanonicalRuntimeConsumer canonicalConsumer) + { + canonicalConsumer.BindCanonicalRuntime( + readPhysicsHost, + readCell, + writeCell); + } + else + { + bool consumesHost = runtime is ILiveEntityPhysicsHostConsumer; + bool consumesCell = runtime is ILiveEntityCanonicalCellConsumer; + if (consumesHost && consumesCell) { - if (cellId != 0 - && _recordsByGuid.TryGetValue(serverGuid, out var current) - && ReferenceEquals(current, incarnation) - && ReferenceEquals(current.RemoteMotionRuntime, runtime)) - { - RebucketLiveEntity(serverGuid, cellId); - } - }); + throw new InvalidOperationException( + "A remote runtime that consumes both canonical host and cell identity must bind them atomically."); + } + if (runtime is ILiveEntityPhysicsHostConsumer hostConsumer) + hostConsumer.BindPhysicsHost(readPhysicsHost); + if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer) + cellConsumer.BindCanonicalCell(readCell, writeCell); + } + + bool indexUnchanged = _remoteMotionByGuid.TryGetValue( + serverGuid, + out ILiveEntityRemoteMotionRuntime? currentIndexedRuntime) + == expectedIndexPresent + && (!expectedIndexPresent + || ReferenceEquals(currentIndexedRuntime, expectedIndexedRuntime)); + if (_sessionLifetimeVersion != sessionVersion + || CurrentLifetimeMutation(serverGuid) != lifetimeVersion + || !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current) + || !ReferenceEquals(current, incarnation) + || !ReferenceEquals(current.PhysicsBody, expectedBody) + || !ReferenceEquals(current.RemoteMotionRuntime, expectedRuntime) + || current.RequiresRemotePlacementRuntime != expectedPlacementContract + || !indexUnchanged) + { + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} changed incarnation or component ownership during remote-motion binding."); + } + + record.RequiresRemotePlacementRuntime |= + runtime is ILiveEntityRemotePlacementRuntime; + record.RemoteMotionRuntime = runtime; + record.PhysicsBody = candidateBody; + candidateBody.State = record.FinalPhysicsState; + SynchronizePhysicsBodyActiveState(record); + _remoteMotionByGuid[serverGuid] = runtime; + RefreshSpatialRuntimeIndexes(record); } - _remoteMotionByGuid[serverGuid] = runtime; - RefreshSpatialRuntimeIndexes(record); + finally + { + record.RemoteMotionBindingInProgress = false; + } + } + + public void InstallPhysicsHost( + LiveEntityRecord expectedRecord, + AcDream.Core.Physics.Motion.IPhysicsObjHost host) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + ArgumentNullException.ThrowIfNull(host); + uint serverGuid = expectedRecord.ServerGuid; + if (host.Id != serverGuid) + throw new ArgumentException("A physics host must match its live entity GUID.", nameof(host)); + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || !ReferenceEquals(record, expectedRecord)) + { + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} changed incarnation during physics-host installation."); + } + if (record.PhysicsHost is not null) + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} already owns its incarnation-stable physics host."); + record.PhysicsHost = host; + } + + public bool TryGetPhysicsHost( + uint serverGuid, + out AcDream.Core.Physics.Motion.IPhysicsObjHost host) + { + if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + && record.PhysicsHost is { } existing) + { + host = existing; + return true; + } + + host = null!; + return false; } public bool TryGetRemoteMotionRuntime(uint serverGuid, out ILiveEntityRemoteMotionRuntime runtime) => @@ -2374,6 +2515,7 @@ public sealed class LiveEntityRuntime } record.AnimationRuntime = null; record.RemoteMotionRuntime = null; + record.PhysicsHost = null; record.RequiresRemotePlacementRuntime = false; record.PhysicsBody = null; record.ProjectileRuntime = null; diff --git a/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs b/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs index 85b4eb88..d5081b34 100644 --- a/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs +++ b/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs @@ -16,9 +16,9 @@ namespace AcDream.Core.Physics.Motion; /// RemoteMotion or the local player), wiring the accessors to the live /// and the / /// / it owns. -/// is backed by the App's live entity table -/// (_entitiesByServerGuid), giving the voyeur round-trip its -/// cross-entity delivery path. +/// is backed by the App's canonical +/// LiveEntityRuntime records, giving the voyeur round-trip its +/// cross-entity delivery path without a second GUID index. /// public interface IPhysicsObjHost { @@ -57,11 +57,21 @@ public interface IPhysicsObjHost double PhysicsTimerTime { get; } /// Retail CObjectMaint::GetObjectA(id) — resolve another - /// physics object by guid, or null if not currently known/visible. - /// The cross-entity seam for the voyeur round-trip and sticky live-target + /// physics object by guid from the object table. The App additionally + /// withholds Hidden objects from ordinary relationship creation through + /// its registered TS-49 DetectionManager adaptation. This is the + /// cross-entity seam for the voyeur round-trip and sticky live-target /// resolve. IPhysicsObjHost? GetObjectA(uint id); + /// + /// Returns the exact target incarnation captured by this host's current + /// TargetManager relationship. App teardown can overlap a newer + /// INSTANCE_TS with the same GUID, so StickyManager must not re-resolve the + /// relationship through the active GUID table. + /// + IPhysicsObjHost? GetRelationshipTarget(uint objectId); + /// Retail CPhysicsObj::HandleUpdateTarget — fans a /// to this host's /// (move-to steering) AND (sticky follow). @@ -86,15 +96,19 @@ public interface IPhysicsObjHost /// Retail CPhysicsObj::receive_target_update → /// . The inbound side a SENDER's /// SendVoyeurUpdate tail-calls on the watcher. - void ReceiveTargetUpdate(TargetInfo info); + void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender); /// Retail CPhysicsObj::add_voyeur(id, radius, quantum) → /// (lazily creating the - /// TargetManager). Called on the TARGET when a watcher subscribes. - void AddVoyeur(uint watcherId, float radius, double quantum); + /// TargetManager). Called on the TARGET when a watcher subscribes. The + /// exact watcher host preserves retail's unique object-table identity + /// while App teardown overlaps a newer same-GUID generation. + void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum); /// Retail CPhysicsObj::remove_voyeur(id) → /// . Called on the TARGET when a - /// watcher unsubscribes. - void RemoveVoyeur(uint watcherId); + /// watcher unsubscribes. The expected watcher identity prevents a retiring + /// same-GUID incarnation from removing a newer incarnation's subscription + /// while App teardown callbacks converge. + void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher); } diff --git a/src/AcDream.Core/Physics/Motion/StickyManager.cs b/src/AcDream.Core/Physics/Motion/StickyManager.cs index 1646ce97..1ca0308b 100644 --- a/src/AcDream.Core/Physics/Motion/StickyManager.cs +++ b/src/AcDream.Core/Physics/Motion/StickyManager.cs @@ -210,7 +210,7 @@ public sealed class StickyManager return; var self = _host.Position; - var target = _host.GetObjectA(TargetId); + var target = _host.GetRelationshipTarget(TargetId); var targetPos = target != null ? target.Position : TargetPosition; // offset = local-frame, Z-flattened vector from self to target. diff --git a/src/AcDream.Core/Physics/Motion/TargetManager.cs b/src/AcDream.Core/Physics/Motion/TargetManager.cs index 7ca908a9..a26bb6ce 100644 --- a/src/AcDream.Core/Physics/Motion/TargetManager.cs +++ b/src/AcDream.Core/Physics/Motion/TargetManager.cs @@ -49,6 +49,7 @@ public sealed class TargetManager private readonly IPhysicsObjHost _host; private TargetInfo? _targetInfo; // retail target_info (watcher role) + private IPhysicsObjHost? _targetHost; // exact App incarnation token private Dictionary? _voyeurTable; // retail voyeur_table (watched role) private double _lastUpdateTime; // retail last_update_time (throttle base) @@ -67,6 +68,12 @@ public sealed class TargetManager /// quantum, 0 when tracking nothing. public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0; + /// The pointer-like target identity captured by SetTarget. + public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => + _targetInfo is { } info && info.ObjectId == objectId + ? _targetHost + : null; + // ── watcher role ─────────────────────────────────────────────────────── /// @@ -101,7 +108,8 @@ public sealed class TargetManager LastUpdateTime: _host.CurTime); var target = _host.GetObjectA(objectId); - target?.AddVoyeur(_host.Id, radius, quantum); + _targetHost = target; + target?.AddVoyeur(_host, radius, quantum); } /// @@ -115,8 +123,9 @@ public sealed class TargetManager return; _targetInfo = ti with { Quantum = quantum }; - var target = _host.GetObjectA(ti.ObjectId); - target?.AddVoyeur(_host.Id, ti.Radius, quantum); + var target = _targetHost ?? _host.GetObjectA(ti.ObjectId); + _targetHost = target; + target?.AddVoyeur(_host, ti.Radius, quantum); } /// @@ -128,9 +137,10 @@ public sealed class TargetManager if (_targetInfo is not { } ti) return; - var target = _host.GetObjectA(ti.ObjectId); - target?.RemoveVoyeur(_host.Id); + var target = _targetHost ?? _host.GetObjectA(ti.ObjectId); + target?.RemoveVoyeur(_host.Id, _host); _targetInfo = null; + _targetHost = null; } /// @@ -141,10 +151,17 @@ public sealed class TargetManager /// time, recomputes the self→target interpolated heading (falls back to +Z /// when degenerate), fans the snapshot to the host, and drops the /// subscription on an ExitWorld status. + /// Receives the update only from the exact target incarnation that + /// established this relationship. The sender token stays outside the + /// retail TargetInfo payload. /// - public void ReceiveUpdate(TargetInfo update) + public void ReceiveUpdate(TargetInfo update, IPhysicsObjHost sender) { - if (_targetInfo is not { } ti || ti.ObjectId != update.ObjectId) + ArgumentNullException.ThrowIfNull(sender); + if (_targetInfo is not { } ti + || ti.ObjectId != update.ObjectId + || _targetHost is null + || !ReferenceEquals(_targetHost, sender)) return; // Copy radius/quantum/positions/velocity/status from the wire; keep our @@ -181,25 +198,53 @@ public sealed class TargetManager /// radius/quantum in place (no immediate send); otherwise creates the entry /// and pushes an immediate initial snapshot (Ok). /// - public void AddVoyeur(uint watcherId, float radius, double quantum) + public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { + ArgumentNullException.ThrowIfNull(watcher); + uint watcherId = watcher.Id; _voyeurTable ??= new Dictionary(); if (_voyeurTable.TryGetValue(watcherId, out var existing)) { - existing.Radius = radius; - existing.Quantum = quantum; - return; + if (ReferenceEquals(existing.WatcherHost, watcher)) + { + existing.Radius = radius; + existing.Quantum = quantum; + return; + } + + // A later INSTANCE_TS reused the GUID. Replace the pointer-like + // subscription; the retiring watcher can no longer remove it via + // its exact identity token. + _voyeurTable.Remove(watcherId); } - var voyeur = new TargettedVoyeurInfo(watcherId, radius, quantum); + var voyeur = new TargettedVoyeurInfo( + watcherId, + radius, + quantum, + watcher); _voyeurTable[watcherId] = voyeur; SendVoyeurUpdate(voyeur, _host.Position, TargetStatus.Ok); } - /// Retail TargetManager::RemoveVoyeur (0x0051ad90). - public bool RemoveVoyeur(uint watcherId) - => _voyeurTable?.Remove(watcherId) ?? false; + /// + /// Retail TargetManager::RemoveVoyeur (0x0051ad90). Removes a + /// relationship only when it still belongs to the exact watcher + /// incarnation that established it in the App lifetime model. + /// + public bool RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) + { + ArgumentNullException.ThrowIfNull(expectedWatcher); + if (_voyeurTable is null + || !_voyeurTable.TryGetValue(watcherId, out var existing) + || !ReferenceEquals(existing.WatcherHost, expectedWatcher)) + { + return false; + } + + return _voyeurTable.Remove(watcherId); + } /// /// Retail TargetManager::HandleTargetting (0x0051aa90). THE per-tick @@ -280,8 +325,7 @@ public sealed class TargetManager Quantum: voyeur.Quantum, Velocity: _host.Velocity); - var voyeurObj = _host.GetObjectA(voyeur.ObjectId); - voyeurObj?.ReceiveTargetUpdate(info); + voyeur.WatcherHost.ReceiveTargetUpdate(info, _host); } /// diff --git a/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs b/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs index 10631681..c4502114 100644 --- a/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs +++ b/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs @@ -11,6 +11,14 @@ namespace AcDream.Core.Physics.Motion; /// public sealed class TargettedVoyeurInfo { + /// + /// Exact watcher incarnation captured when this subscription is created. + /// Retail object IDs are process-lifetime object-table identities; the App + /// preserves that pointer-like identity explicitly while an old + /// INSTANCE_TS teardown may overlap a newer record with the same GUID. + /// + internal IPhysicsObjHost WatcherHost { get; } + /// +0x00 retail object_id — the subscriber's guid. public uint ObjectId { get; } @@ -27,10 +35,23 @@ public sealed class TargettedVoyeurInfo /// delivered to this subscriber (updated by SendVoyeurUpdate). public Position LastSentPosition { get; set; } - public TargettedVoyeurInfo(uint objectId, float radius, double quantum) + public TargettedVoyeurInfo( + uint objectId, + float radius, + double quantum, + IPhysicsObjHost watcherHost) { + ArgumentNullException.ThrowIfNull(watcherHost); + if (watcherHost.Id != objectId) + { + throw new ArgumentException( + "Watcher identity must match the voyeur object ID.", + nameof(watcherHost)); + } + ObjectId = objectId; Radius = radius; Quantum = quantum; + WatcherHost = watcherHost; } } diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs index e55baf92..cce541e2 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs @@ -242,7 +242,6 @@ public sealed class LiveSessionResetPlanTests AnimationHookFrames = Stage("animation hook frames"), LivePresentation = Stage("live presentation"), RemoteMovementDiagnostics = Stage("remote movement diagnostics"), - PhysicsHostIndex = Stage("physics host index"), }); Assert.Equal(ExpectedManifestNames(), plan.StageNames); @@ -255,7 +254,6 @@ public sealed class LiveSessionResetPlanTests .Select(static error => error.StageName)); Assert.Equal(playerA, sessionIdentity); Assert.Equal(1, live.PendingTeardownCount); - Assert.False(dirty["physics host index"]); Assert.Contains(staticEntity, spatial.Entities); Assert.Equal(1, spatial.PersistentGuidCount); @@ -319,7 +317,6 @@ public sealed class LiveSessionResetPlanTests "animation hook frames", "live presentation", "remote movement diagnostics", - "physics host index", ]; private static WorldEntity Entity(uint id, uint guid) => new() diff --git a/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs b/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs index 354830da..4db98835 100644 --- a/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs +++ b/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.Input; +using AcDream.App.Physics; using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; using AcDream.Core.Physics; diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index 2bc55d79..f0fd81a4 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -1202,7 +1202,10 @@ public sealed class ProjectileControllerTests fixture.Live.SetRemoteMotionRuntime(Guid, replacement); remote.CellId = CellB; Assert.Equal(CellA, record.FullCellId); - Assert.Equal(0u, remote.CellId); + // A displaced component retains read-only access to its exact + // incarnation through exit_world; its guarded write cannot mutate the + // current canonical owner. + Assert.Equal(CellA, remote.CellId); replacement.CellId = CellB; Assert.Equal(CellB, record.FullCellId); } diff --git a/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs b/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs index cee150f4..0d78b7a5 100644 --- a/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs @@ -1,5 +1,5 @@ using System.Numerics; -using AcDream.App.Rendering; +using AcDream.App.Physics; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; diff --git a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs new file mode 100644 index 00000000..c2a35f44 --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs @@ -0,0 +1,745 @@ +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; + +namespace AcDream.App.Tests.World; + +public sealed class LiveEntityPhysicsHostOwnershipTests +{ + [Fact] + public void Host_IsOwnedByExactRecordAndStaleRemoteReaderCannotSeeReplacement() + { + const uint guid = 0x70000010u; + var runtime = Runtime(); + LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var firstMotion = new HostConsumerMotion(); + runtime.SetRemoteMotionRuntime(guid, firstMotion); + EntityPhysicsHost firstHost = Host(guid); + + runtime.InstallPhysicsHost(first, firstHost); + Assert.Same(firstHost, firstMotion.Host); + Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved)); + Assert.Same(firstHost, resolved); + + LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + var secondMotion = new HostConsumerMotion(); + runtime.SetRemoteMotionRuntime(guid, secondMotion); + EntityPhysicsHost replacement = Host(guid); + runtime.InstallPhysicsHost(second, replacement); + + Assert.Null(firstMotion.Host); + Assert.Same(replacement, secondMotion.Host); + Assert.Same(replacement, second.PhysicsHost); + } + + [Fact] + public void Install_RejectsWrongGuidDuplicateAndStaleRecord() + { + const uint guid = 0x70000011u; + var runtime = Runtime(); + LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + EntityPhysicsHost original = Host(guid); + runtime.InstallPhysicsHost(first, original); + + Assert.Throws(() => + runtime.InstallPhysicsHost(first, Host(guid + 1))); + Assert.Throws(() => + runtime.InstallPhysicsHost(first, Host(guid))); + + LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + Assert.Throws(() => + runtime.InstallPhysicsHost(first, Host(guid))); + Assert.Null(second.PhysicsHost); + } + + [Fact] + public void InstallOrRebind_PreservesHostManagersAndLiveTrackingState() + { + const uint watcherGuid = 0x70000020u; + const uint targetGuid = 0x70000021u; + var runtime = Runtime(); + LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!; + LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!; + IPhysicsObjHost? Resolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + + EntityPhysicsHost watcher = Host( + watcherGuid, + resolve: Resolve, + position: new Vector3(1f, 0f, 0f)); + EntityPhysicsHost target = Host( + targetGuid, + resolve: Resolve, + position: new Vector3(4f, 0f, 0f)); + runtime.InstallPhysicsHost(watcherRecord, watcher); + runtime.InstallPhysicsHost(targetRecord, target); + + watcher.PositionManager.StickTo(targetGuid, 0.5f, 1f); + watcher.PositionManager.ConstrainTo( + new Position(0x01010001u, Vector3.Zero, Quaternion.Identity), + 1f, + 5f); + TargetManager targetManager = watcher.TargetManager; + PositionManager positionManager = watcher.PositionManager; + StickyManager sticky = Assert.IsType(positionManager.Sticky); + ConstraintManager constraint = Assert.IsType(positionManager.Constraint); + Assert.Equal(targetGuid, sticky.TargetId); + Assert.NotNull(watcher.TargetManager.TargetInfo); + Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(watcherGuid)); + + EntityPhysicsHost configured = Host( + watcherGuid, + resolve: Resolve, + position: new Vector3(9f, 0f, 0f)); + EntityPhysicsHost rebound = EntityPhysicsHost.InstallOrRebind( + runtime, + watcherRecord, + configured); + + Assert.Same(watcher, rebound); + Assert.Same(targetManager, rebound.TargetManager); + Assert.Same(positionManager, rebound.PositionManager); + Assert.Same(sticky, rebound.PositionManager.Sticky); + Assert.Same(constraint, rebound.PositionManager.Constraint); + Assert.Equal(targetGuid, rebound.PositionManager.GetStickyObjectId()); + Assert.Equal(9f, rebound.Position.Frame.Origin.X); + Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(watcherGuid)); + + EntityPhysicsHost secondConfiguration = Host( + watcherGuid, + resolve: Resolve, + position: new Vector3(12f, 0f, 0f)); + Assert.Same( + watcher, + EntityPhysicsHost.InstallOrRebind(runtime, watcherRecord, secondConfiguration)); + Assert.Same(targetManager, watcher.TargetManager); + Assert.Same(positionManager, watcher.PositionManager); + Assert.Equal(12f, watcher.Position.Frame.Origin.X); + } + + [Fact] + public void SameGuidReplacement_HostCallbacksRemainIncarnationLocal() + { + const uint guid = 0x70000022u; + var runtime = Runtime(); + LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + int firstUpdates = 0; + int firstInterrupts = 0; + var firstHost = CallbackHost( + guid, + _ => firstUpdates++, + () => firstInterrupts++); + runtime.InstallPhysicsHost(first, firstHost); + + LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + int replacementUpdates = 0; + int replacementInterrupts = 0; + var replacementHost = CallbackHost( + guid, + _ => replacementUpdates++, + () => replacementInterrupts++); + runtime.InstallPhysicsHost(replacement, replacementHost); + + firstHost.HandleUpdateTarget(default); + firstHost.InterruptCurrentMovement(); + + Assert.Equal(1, firstUpdates); + Assert.Equal(1, firstInterrupts); + Assert.Equal(0, replacementUpdates); + Assert.Equal(0, replacementInterrupts); + } + + [Fact] + public void RemoteMotion_FullHostGateUsesCanonicalStableHost() + { + const uint guid = 0x70000030u; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var motion = new RemoteMotion(new PhysicsBody()); + runtime.SetRemoteMotionRuntime(guid, motion); + EntityPhysicsHost minimal = Host(guid); + runtime.InstallPhysicsHost(record, minimal); + + Assert.Null(motion.Host); + motion.MarkFullPhysicsHostBound(); + Assert.Same(minimal, motion.Host); + + EntityPhysicsHost configured = Host(guid, position: new Vector3(7f, 0f, 0f)); + Assert.Same( + minimal, + EntityPhysicsHost.InstallOrRebind(runtime, record, configured)); + Assert.Same(minimal, motion.Host); + Assert.Equal(7f, motion.Host!.Position.Frame.Origin.X); + + runtime.SetRemoteMotionRuntime(guid, motion); + Assert.Same(minimal, motion.Host); + } + + [Fact] + public void RemoteMotionBindingFailure_DoesNotPublishPartialCanonicalState() + { + const uint guid = 0x70000031u; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var failing = new ThrowingCellConsumerMotion(); + + Assert.Throws(() => + runtime.SetRemoteMotionRuntime(guid, failing)); + Assert.Null(record.RemoteMotionRuntime); + Assert.Null(record.PhysicsBody); + Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _)); + + runtime.SetRemoteMotionRuntime(guid, failing); + Assert.Same(failing, record.RemoteMotionRuntime); + Assert.Same(failing.Body, record.PhysicsBody); + } + + [Fact] + public void AlreadyBoundRemoteFromOldGeneration_CannotPoisonReplacement() + { + const uint guid = 0x70000032u; + var runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(guid, 1)); + var stale = new RemoteMotion(new PhysicsBody()); + runtime.SetRemoteMotionRuntime(guid, stale); + + LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + Assert.Throws(() => + runtime.SetRemoteMotionRuntime(guid, stale)); + Assert.Null(replacement.RemoteMotionRuntime); + Assert.Null(replacement.PhysicsBody); + Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _)); + + var fresh = new RemoteMotion(new PhysicsBody()); + runtime.SetRemoteMotionRuntime(guid, fresh); + Assert.Same(fresh, replacement.RemoteMotionRuntime); + } + + [Fact] + public void SameRemoteRuntime_CannotChangeCanonicalBodyOnIdempotentBind() + { + const uint guid = 0x70000033u; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var firstBody = new PhysicsBody(); + var secondBody = new PhysicsBody(); + var alternating = new AlternatingBodyMotion(firstBody, secondBody); + + runtime.SetRemoteMotionRuntime(guid, alternating); + Assert.Same(firstBody, record.PhysicsBody); + + Assert.Throws(() => + runtime.SetRemoteMotionRuntime(guid, alternating)); + Assert.Same(firstBody, record.PhysicsBody); + Assert.Same(alternating, record.RemoteMotionRuntime); + Assert.True(runtime.TryGetRemoteMotionRuntime(guid, out var indexed)); + Assert.Same(alternating, indexed); + } + + [Fact] + public void IdempotentRemoteBind_ReadsBodyOnceAndMutatesOnlyCanonicalBody() + { + const uint guid = 0x70000036u; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var canonical = new PhysicsBody(); + var unrelated = new PhysicsBody(); + PhysicsStateFlags unrelatedInitialState = unrelated.State; + var scripted = new SequenceBodyMotion(canonical, canonical, unrelated); + + runtime.SetRemoteMotionRuntime(guid, scripted); + runtime.SetRemoteMotionRuntime(guid, scripted); + + Assert.Same(canonical, record.PhysicsBody); + Assert.Equal(record.FinalPhysicsState, canonical.State); + Assert.Equal(unrelatedInitialState, unrelated.State); + Assert.Equal(2, scripted.ReadCount); + } + + [Fact] + public void ReentrantBind_SameGuidReplacementPreventsOuterPublication() + { + const uint guid = 0x70000034u; + var runtime = Runtime(); + LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var motion = new CallbackCanonicalMotion(() => + runtime.RegisterLiveEntity(Spawn(guid, 2))); + + Assert.Throws(() => + runtime.SetRemoteMotionRuntime(guid, motion)); + + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement)); + Assert.Equal((ushort)2, replacement.Generation); + Assert.Null(replacement.RemoteMotionRuntime); + Assert.Null(replacement.PhysicsBody); + Assert.Null(retired.RemoteMotionRuntime); + Assert.Null(retired.PhysicsBody); + Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _)); + } + + [Fact] + public void ReentrantBind_SessionClearPreventsOuterPublication() + { + const uint guid = 0x70000035u; + var runtime = Runtime(); + LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var motion = new CallbackCanonicalMotion(runtime.Clear); + + Assert.Throws(() => + runtime.SetRemoteMotionRuntime(guid, motion)); + + Assert.Equal(0, runtime.Count); + Assert.Equal(0, runtime.PendingTeardownCount); + Assert.Null(retired.RemoteMotionRuntime); + Assert.Null(retired.PhysicsBody); + Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _)); + } + + [Fact] + public void Delete_TeardownHostsRemainPeerResolvableUntilExitWorldConverges() + { + const uint leftGuid = 0x70000040u; + const uint rightGuid = 0x70000041u; + LiveEntityRuntime runtime = null!; + runtime = Runtime(record => CleanPhysicsHost(record)); + LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!; + LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!; + IPhysicsObjHost? Resolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + EntityPhysicsHost left = Host(leftGuid, Resolve, new Vector3(0f, 0f, 0f)); + EntityPhysicsHost right = Host(rightGuid, Resolve, new Vector3(2f, 0f, 0f)); + runtime.InstallPhysicsHost(leftRecord, left); + runtime.InstallPhysicsHost(rightRecord, right); + left.PositionManager.StickTo(rightGuid, 0.5f, 1f); + right.PositionManager.StickTo(leftGuid, 0.5f, 1f); + + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(leftGuid, InstanceSequence: 1), + isLocalPlayer: false)); + + Assert.Null(leftRecord.PhysicsHost); + Assert.Equal(0u, right.PositionManager.GetStickyObjectId()); + Assert.Null(right.TargetManager.TargetInfo); + Assert.False(right.TargetManager.VoyeurTable?.ContainsKey(leftGuid) ?? false); + Assert.False(runtime.TryGetPhysicsHost(leftGuid, out _)); + } + + [Fact] + public void SessionClear_TeardownHostsResolveAcrossTombstones() + { + const uint leftGuid = 0x70000042u; + const uint rightGuid = 0x70000043u; + LiveEntityRuntime runtime = null!; + runtime = Runtime(record => CleanPhysicsHost(record)); + LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!; + LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!; + IPhysicsObjHost? Resolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + EntityPhysicsHost left = Host(leftGuid, Resolve, Vector3.Zero); + EntityPhysicsHost right = Host(rightGuid, Resolve, new Vector3(2f, 0f, 0f)); + runtime.InstallPhysicsHost(leftRecord, left); + runtime.InstallPhysicsHost(rightRecord, right); + left.PositionManager.StickTo(rightGuid, 0.5f, 1f); + right.PositionManager.StickTo(leftGuid, 0.5f, 1f); + + runtime.Clear(); + + Assert.Equal(0, runtime.PendingTeardownCount); + Assert.Null(leftRecord.PhysicsHost); + Assert.Null(rightRecord.PhysicsHost); + Assert.Equal(0u, left.PositionManager.GetStickyObjectId()); + Assert.Equal(0u, right.PositionManager.GetStickyObjectId()); + } + + [Fact] + public void FailedTeardown_RetryCannotMutateReplacementRelationship() + { + const uint guid = 0x70000044u; + const uint targetGuid = 0x70000045u; + bool failOnce = true; + EntityPhysicsHost? replacementHost = null; + LiveEntityRecord? replacementRecord = null; + LiveEntityRuntime runtime = null!; + runtime = Runtime(record => + { + if (failOnce) + { + failOnce = false; + replacementRecord = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + IPhysicsObjHost? Resolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + replacementHost = Host(guid, Resolve); + runtime.InstallPhysicsHost(replacementRecord, replacementHost); + replacementHost.PositionManager.StickTo(targetGuid, 0.5f, 1f); + throw new InvalidOperationException("fixture failure before old host cleanup"); + } + + CleanPhysicsHost(record); + }); + LiveEntityRecord oldRecord = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!; + IPhysicsObjHost? InitialResolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + EntityPhysicsHost oldHost = Host(guid, InitialResolve); + EntityPhysicsHost targetHost = Host(targetGuid, InitialResolve, new Vector3(2f, 0f, 0f)); + runtime.InstallPhysicsHost(oldRecord, oldHost); + runtime.InstallPhysicsHost(targetRecord, targetHost); + oldHost.PositionManager.StickTo(targetGuid, 0.5f, 1f); + + Assert.Throws(() => runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + Assert.Same(oldHost, oldRecord.PhysicsHost); + Assert.Same(replacementHost, replacementRecord!.PhysicsHost); + Assert.Equal(targetGuid, replacementHost!.PositionManager.GetStickyObjectId()); + Assert.True(targetHost.TargetManager.VoyeurTable!.ContainsKey(guid)); + + Assert.Equal(1, runtime.RetryPendingTeardowns()); + Assert.Null(oldRecord.PhysicsHost); + Assert.Same(replacementHost, replacementRecord.PhysicsHost); + Assert.Equal(targetGuid, replacementHost.PositionManager.GetStickyObjectId()); + Assert.True(targetHost.TargetManager.VoyeurTable!.ContainsKey(guid)); + } + + [Fact] + public void FailedTeardown_TombstoneCannotAcceptNewRelationshipsOutsideTeardown() + { + const uint retiredGuid = 0x70000046u; + const uint watcherGuid = 0x70000047u; + bool fail = true; + LiveEntityRuntime runtime = null!; + runtime = Runtime(record => + { + CleanPhysicsHost(record); + if (fail) + { + fail = false; + throw new InvalidOperationException("fixture post-exit failure"); + } + }); + LiveEntityRecord retiredRecord = runtime.RegisterLiveEntity(Spawn(retiredGuid, 1)).Record!; + LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!; + IPhysicsObjHost? Resolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + EntityPhysicsHost retired = Host(retiredGuid, Resolve); + EntityPhysicsHost watcher = Host(watcherGuid, Resolve); + runtime.InstallPhysicsHost(retiredRecord, retired); + runtime.InstallPhysicsHost(watcherRecord, watcher); + + Assert.Throws(() => runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(retiredGuid, InstanceSequence: 1), + isLocalPlayer: false)); + watcher.SetTarget(0, retiredGuid, 0.5f, 0.5); + + Assert.Null(retired.TargetManager.VoyeurTable); + Assert.Equal(TargetStatus.Undefined, watcher.TargetManager.TargetInfo!.Value.Status); + Assert.False(runtime.TryGetPhysicsHost(retiredGuid, out _)); + Assert.Equal(1, runtime.RetryPendingTeardowns()); + } + + [Fact] + public void MinimalHost_ExitPositionRetainsExactRecordFrame() + { + const uint guid = 0x70000048u; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + record.FullCellId = 0x01010123u; + record.WorldEntity = new AcDream.Core.World.WorldEntity + { + Id = 1_000_123u, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(12f, 34f, 56f), + Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f), + MeshRefs = Array.Empty(), + }; + EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0); + + Assert.Equal(record.FullCellId, host.Position.ObjCellId); + Assert.Equal(record.WorldEntity.Position, host.Position.Frame.Origin); + Assert.Equal(record.WorldEntity.Rotation, host.Position.Frame.Orientation); + } + + [Fact] + public void FullHost_ExitSnapshotRetainsOldFrameAcrossSameGuidReplacement() + { + const uint departingGuid = 0x70000049u; + const uint watcherGuid = 0x7000004Au; + var delivered = new List(); + LiveEntityRuntime runtime = null!; + runtime = Runtime(record => + { + if (record.ServerGuid == departingGuid) + { + LiveEntityRecord replacement = runtime.RegisterLiveEntity( + Spawn(departingGuid, 2)).Record!; + replacement.FullCellId = 0x02020202u; + replacement.WorldEntity = Entity( + departingGuid, + new Vector3(999f, 999f, 999f), + Quaternion.Identity); + runtime.InstallPhysicsHost(replacement, Host(departingGuid)); + } + CleanPhysicsHost(record); + }); + LiveEntityRecord departing = runtime.RegisterLiveEntity(Spawn(departingGuid, 1)).Record!; + LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!; + departing.FullCellId = 0x01010123u; + Quaternion departingRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f); + departing.WorldEntity = Entity( + departingGuid, + new Vector3(12f, 34f, 56f), + departingRotation); + watcherRecord.WorldEntity = Entity(watcherGuid, Vector3.Zero, Quaternion.Identity); + IPhysicsObjHost? Resolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host : null; + var body = new PhysicsBody + { + Position = departing.WorldEntity.Position, + Orientation = departingRotation, + }; + var remote = new RemoteMotion(body); + runtime.SetRemoteMotionRuntime(departingGuid, remote); + var departingHost = new EntityPhysicsHost( + departingGuid, + getPosition: () => new Position( + departing.FullCellId, + departing.WorldEntity?.Position ?? body.Position, + body.Orientation), + getVelocity: () => body.Velocity, + getRadius: () => 0.5f, + inContact: () => true, + minterpMaxSpeed: () => 1f, + curTime: () => 0, + physicsTimerTime: () => 0, + getObjectA: Resolve, + handleUpdateTarget: _ => { }, + interruptCurrentMovement: () => { }); + var watcher = new EntityPhysicsHost( + watcherGuid, + getPosition: () => new Position(0x01010001u, Vector3.Zero, Quaternion.Identity), + getVelocity: () => Vector3.Zero, + getRadius: () => 0.5f, + inContact: () => true, + minterpMaxSpeed: () => 1f, + curTime: () => 0, + physicsTimerTime: () => 0, + getObjectA: Resolve, + handleUpdateTarget: delivered.Add, + interruptCurrentMovement: () => { }); + runtime.InstallPhysicsHost(departing, departingHost); + runtime.InstallPhysicsHost(watcherRecord, watcher); + watcher.SetTarget(0, departingGuid, 0.5f, 0.5); + delivered.Clear(); + + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(departingGuid, InstanceSequence: 1), + isLocalPlayer: false)); + + TargetInfo exit = Assert.Single(delivered); + Assert.Equal(TargetStatus.ExitWorld, exit.Status); + Assert.Equal(0x01010123u, exit.TargetPosition.ObjCellId); + Assert.Equal(new Vector3(12f, 34f, 56f), exit.TargetPosition.Frame.Origin); + Assert.Equal(departingRotation, exit.TargetPosition.Frame.Orientation); + Assert.Equal(0x01010123u, remote.CellId); + } + + [Fact] + public void ActiveNonVisibleRecord_CanOwnResolvablePhysicsHost() + { + const uint guid = 0x7000004Bu; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0); + runtime.InstallPhysicsHost(record, host); + + Assert.Empty(runtime.WorldEntities); + Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved)); + Assert.Same(host, resolved); + } + + private static void CleanPhysicsHost(LiveEntityRecord record) + { + if (record.PhysicsHost is not EntityPhysicsHost host) + return; + host.PositionManager.UnStick(); + host.ClearTarget(); + host.NotifyExitWorld(); + } + + private static LiveEntityRuntime Runtime(Action? teardown = null) => + teardown is null + ? new LiveEntityRuntime( + new GpuWorldState(), + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })) + : new LiveEntityRuntime( + new GpuWorldState(), + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), + teardown); + + private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) => + new( + Guid: guid, + Position: null, + SetupTableId: null, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: instance); + + private static AcDream.Core.World.WorldEntity Entity( + uint guid, + Vector3 position, + Quaternion rotation) => + new() + { + Id = 1_000_000u + (guid & 0xFFFFu), + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = position, + Rotation = rotation, + MeshRefs = Array.Empty(), + }; + + private static EntityPhysicsHost Host( + uint guid, + Func? resolve = null, + Vector3? position = null) => + new( + guid, + getPosition: () => new Position( + 0x01010001u, + position ?? Vector3.Zero, + Quaternion.Identity), + getVelocity: () => Vector3.Zero, + getRadius: () => 0.5f, + inContact: () => true, + minterpMaxSpeed: () => 1f, + curTime: () => 0, + physicsTimerTime: () => 0, + getObjectA: resolve ?? (_ => null), + handleUpdateTarget: _ => { }, + interruptCurrentMovement: () => { }); + + private static EntityPhysicsHost CallbackHost( + uint guid, + Action handleUpdateTarget, + Action interruptCurrentMovement) => + new( + guid, + getPosition: () => new Position( + 0x01010001u, + Vector3.Zero, + Quaternion.Identity), + getVelocity: () => Vector3.Zero, + getRadius: () => 0.5f, + inContact: () => true, + minterpMaxSpeed: () => 1f, + curTime: () => 0, + physicsTimerTime: () => 0, + getObjectA: _ => null, + handleUpdateTarget, + interruptCurrentMovement); + + private sealed class HostConsumerMotion : + ILiveEntityRemoteMotionRuntime, + ILiveEntityPhysicsHostConsumer + { + private Func? _read; + public PhysicsBody Body { get; } = new(); + public IPhysicsObjHost? Host => _read?.Invoke(); + public void BindPhysicsHost(Func read) + { + if (_read is not null) + throw new InvalidOperationException("already bound"); + _read = read; + } + } + + private sealed class ThrowingCellConsumerMotion : + ILiveEntityRemoteMotionRuntime, + ILiveEntityCanonicalRuntimeConsumer + { + private bool _failOnce = true; + public PhysicsBody Body { get; } = new(); + public void BindCanonicalRuntime( + Func readPhysicsHost, + Func readCell, + Action writeCell) + { + ArgumentNullException.ThrowIfNull(readPhysicsHost); + ArgumentNullException.ThrowIfNull(readCell); + ArgumentNullException.ThrowIfNull(writeCell); + if (_failOnce) + { + _failOnce = false; + throw new InvalidOperationException("fixture context bind failure"); + } + } + } + + private sealed class AlternatingBodyMotion( + PhysicsBody first, + PhysicsBody second) : ILiveEntityRemoteMotionRuntime + { + private int _reads; + public PhysicsBody Body => _reads++ == 0 ? first : second; + } + + private sealed class SequenceBodyMotion(params PhysicsBody[] bodies) : + ILiveEntityRemoteMotionRuntime + { + public int ReadCount { get; private set; } + + public PhysicsBody Body + { + get + { + int index = Math.Min(ReadCount, bodies.Length - 1); + ReadCount++; + return bodies[index]; + } + } + } + + private sealed class CallbackCanonicalMotion(Action callback) : + ILiveEntityRemoteMotionRuntime, + ILiveEntityCanonicalRuntimeConsumer + { + public PhysicsBody Body { get; } = new(); + + public void BindCanonicalRuntime( + Func readPhysicsHost, + Func readCell, + Action writeCell) + { + ArgumentNullException.ThrowIfNull(readPhysicsHost); + ArgumentNullException.ThrowIfNull(readCell); + ArgumentNullException.ThrowIfNull(writeCell); + callback(); + } + } +} diff --git a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs index 0202c92b..04436ad3 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs @@ -1,6 +1,7 @@ using System; using System.Numerics; using AcDream.App.Input; +using AcDream.App.Physics; using AcDream.App.Rendering; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; diff --git a/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs b/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs index 55701c73..6dd2acbe 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs @@ -62,6 +62,9 @@ internal sealed class R5Host : IPhysicsObjHost public IPhysicsObjHost? GetObjectA(uint id) => World.TryGetValue(id, out var h) && h.Resolvable ? h : null; + public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => + _targetManager?.GetRelationshipTarget(objectId); + public void HandleUpdateTarget(TargetInfo info) { HandleUpdateTargetCalls.Add(info); @@ -79,12 +82,14 @@ internal sealed class R5Host : IPhysicsObjHost public void ClearTarget() => _targetManager?.ClearTarget(); - public void ReceiveTargetUpdate(TargetInfo info) => _targetManager?.ReceiveUpdate(info); + public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) => + _targetManager?.ReceiveUpdate(info, sender); - public void AddVoyeur(uint watcherId, float radius, double quantum) - => TargetManager.AddVoyeur(watcherId, radius, quantum); + public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) + => TargetManager.AddVoyeur(watcher, radius, quantum); - public void RemoveVoyeur(uint watcherId) => _targetManager?.RemoveVoyeur(watcherId); + public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) => + _targetManager?.RemoveVoyeur(watcherId, expectedWatcher); // ── test helpers ─────────────────────────────────────────────────────── public void SetOrigin(Vector3 origin) diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs index cfbfb27e..f75e054d 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs @@ -352,6 +352,8 @@ internal sealed class RemoteChaseHarness public double PhysicsTimerTime => _h.Now; public IPhysicsObjHost? GetObjectA(uint id) => id == PlayerGuid ? _h._playerHost : null; + public IPhysicsObjHost? GetRelationshipTarget(uint objectId) + => GetObjectA(objectId); public void HandleUpdateTarget(TargetInfo info) { @@ -373,9 +375,9 @@ internal sealed class RemoteChaseHarness } public void ClearTarget() => _h._targetArmed = false; - public void ReceiveTargetUpdate(TargetInfo info) { } - public void AddVoyeur(uint watcherId, float radius, double quantum) { } - public void RemoveVoyeur(uint watcherId) { } + public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) { } + public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { } + public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) { } } private sealed class TargetHost : IPhysicsObjHost @@ -391,13 +393,14 @@ internal sealed class RemoteChaseHarness public double CurTime => _h.Now; public double PhysicsTimerTime => _h.Now; public IPhysicsObjHost? GetObjectA(uint id) => null; + public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => null; public void HandleUpdateTarget(TargetInfo info) { } public void InterruptCurrentMovement() { } public void SetTarget(uint contextId, uint objectId, float radius, double quantum) { } public void ClearTarget() { } - public void ReceiveTargetUpdate(TargetInfo info) { } - public void AddVoyeur(uint watcherId, float radius, double quantum) { } - public void RemoveVoyeur(uint watcherId) { } + public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) { } + public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { } + public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) { } } // ── The per-tick pipeline (GameWindow.TickAnimations order) ──────────── diff --git a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs index 744785df..4c8bd59f 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs @@ -218,11 +218,11 @@ public sealed class StickyManagerTests var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f }; var target = new R5Host(20u, world); var sticky = new StickyManager(self); + target.Resolvable = false; sticky.StickTo(target.Id, 0.5f, 1.0f); - // Cache a position via HandleUpdateTarget, then make the target vanish. + // Cache a position without ever establishing an exact target token. var tp = new Position(1u, new Vector3(4f, 0f, 0f), Quaternion.Identity); sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp)); - target.Resolvable = false; // GetObjectA(target) → null → fall back to cached var frame = new MotionDeltaFrame(); sticky.AdjustOffset(frame, quantum: 0.1); diff --git a/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs index 0fd2130b..549e9b04 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs @@ -76,7 +76,9 @@ public sealed class TargetManagerTests int before = self.HandleUpdateTargetCalls.Count; var p = new Position(1u, Vector3.Zero, Quaternion.Identity); - self.TargetManager.ReceiveUpdate(new TargetInfo(999u, TargetStatus.Ok, p, p)); + self.TargetManager.ReceiveUpdate( + new TargetInfo(999u, TargetStatus.Ok, p, p), + target); Assert.Equal(before, self.HandleUpdateTargetCalls.Count); } @@ -88,7 +90,9 @@ public sealed class TargetManagerTests self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); var p = new Position(1u, Vector3.Zero, Quaternion.Identity); - self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p)); + self.TargetManager.ReceiveUpdate( + new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p), + target); Assert.Null(self.TargetManager.TargetInfo); // cleared Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // unsubscribed @@ -102,7 +106,9 @@ public sealed class TargetManagerTests self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); var tp = new Position(1u, new Vector3(0f, 5f, 0f), Quaternion.Identity); - self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp)); + self.TargetManager.ReceiveUpdate( + new TargetInfo(target.Id, TargetStatus.Ok, tp, tp), + target); // self→target interp position (0,5,0) normalized = +Y. var heading = self.TargetManager.TargetInfo!.Value.InterpolatedHeading; @@ -110,6 +116,46 @@ public sealed class TargetManagerTests Assert.Equal(1f, heading.Y, 3); } + [Fact] + public void ReceiveUpdate_SameGuidReplacementSender_IsIgnored() + { + var (self, originalTarget, world) = TwoHosts(); + self.TargetManager.SetTarget(0, originalTarget.Id, 1.0f, 0.0); + self.HandleUpdateTargetCalls.Clear(); + var replacement = new R5Host(originalTarget.Id, world); + var p = new Position(1u, new Vector3(99f, 0f, 0f), Quaternion.Identity); + + self.ReceiveTargetUpdate( + new TargetInfo(originalTarget.Id, TargetStatus.ExitWorld, p, p), + replacement); + + Assert.Empty(self.HandleUpdateTargetCalls); + Assert.NotNull(self.TargetManager.TargetInfo); + Assert.Same( + originalTarget, + self.TargetManager.GetRelationshipTarget(originalTarget.Id)); + } + + [Fact] + public void StickyAdjustOffset_UsesExactTargetInsteadOfSameGuidReplacement() + { + var (self, originalTarget, world) = TwoHosts(); + self.SetOrigin(Vector3.Zero); + originalTarget.SetOrigin(new Vector3(5f, 0f, 0f)); + self.PositionManager.StickTo(originalTarget.Id, 0.5f, 1f); + var replacement = new R5Host(originalTarget.Id, world); + replacement.SetOrigin(new Vector3(10f, 0f, 0f)); + originalTarget.SetOrigin(new Vector3(-5f, 0f, 0f)); + var delta = new MotionDeltaFrame(); + + self.PositionManager.AdjustOffset(delta, 0.1); + + Assert.True(delta.Origin.X < 0f); + Assert.Same( + originalTarget, + self.TargetManager.GetRelationshipTarget(originalTarget.Id)); + } + [Fact] public void HandleTargetting_UndefinedTarget_TimesOutAfter10Seconds() { @@ -133,9 +179,8 @@ public sealed class TargetManagerTests public void HandleTargetting_Throttles_Within500ms() { var (self, target, _) = TwoHosts(); - var w = new TargettedVoyeurInfo(self.Id, radius: 0.1f, quantum: 0.0); // seed a voyeur directly + advance so a sweep WOULD send if not throttled. - target.TargetManager.AddVoyeur(self.Id, 0.1f, 0.0); + target.TargetManager.AddVoyeur(self, 0.1f, 0.0); target.SetOrigin(new Vector3(10f, 0f, 0f)); // far past radius target.PhysicsTimerTime = 0.3; // < 0.5 throttle @@ -186,12 +231,12 @@ public sealed class TargetManagerTests { var (self, target, _) = TwoHosts(); target.SetOrigin(Vector3.Zero); - target.TargetManager.AddVoyeur(self.Id, radius: 1.0f, quantum: 0.0); + target.TargetManager.AddVoyeur(self, radius: 1.0f, quantum: 0.0); var voyeur = target.TargetManager.VoyeurTable![self.Id]; Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin); target.SetOrigin(new Vector3(5f, 0f, 0f)); - target.TargetManager.AddVoyeur(self.Id, radius: 2.5f, quantum: 0.3); // existing → update only + target.TargetManager.AddVoyeur(self, radius: 2.5f, quantum: 0.3); // existing → update only Assert.Equal(2.5f, voyeur.Radius); Assert.Equal(0.3, voyeur.Quantum); @@ -202,10 +247,27 @@ public sealed class TargetManagerTests public void RemoveVoyeur_RemovesEntry() { var (self, target, _) = TwoHosts(); - target.TargetManager.AddVoyeur(self.Id, 1.0f, 0.0); - Assert.True(target.TargetManager.RemoveVoyeur(self.Id)); + target.TargetManager.AddVoyeur(self, 1.0f, 0.0); + Assert.True(target.TargetManager.RemoveVoyeur(self.Id, self)); Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); - Assert.False(target.TargetManager.RemoveVoyeur(self.Id)); // already gone + Assert.False(target.TargetManager.RemoveVoyeur(self.Id, self)); // already gone + } + + [Fact] + public void HiddenWatcher_GuidReuseCannotRedirectOrRemoveExactSubscription() + { + var (watcher, target, world) = TwoHosts(); + watcher.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); + watcher.HandleUpdateTargetCalls.Clear(); + watcher.Resolvable = false; + var replacement = new R5Host(watcher.Id, world); + + target.TargetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported); + + Assert.Single(watcher.HandleUpdateTargetCalls); + Assert.Empty(replacement.HandleUpdateTargetCalls); + Assert.False(target.TargetManager.RemoveVoyeur(watcher.Id, replacement)); + Assert.True(target.TargetManager.RemoveVoyeur(watcher.Id, watcher)); } [Fact] From c87bac31a0fc38ee2f063e920f72cc1dda242a38 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 14:06:23 +0200 Subject: [PATCH 42/98] docs(plan): record canonical live entity seams --- ...2026-07-21-gamewindow-slice-4-live-entity-integration.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md index 687bedc0..3457e740 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md @@ -12,9 +12,11 @@ them inside a mechanical extraction. **Progress ledger:** -- [ ] A — canonical seams: `LiveWorldOriginState`, single-bind runtime-component +- [x] A — canonical seams: `LiveWorldOriginState`, single-bind runtime-component lifecycle bridge, extracted `RemoteMotion`, and canonical physics-host - ownership. No wire or presentation behavior changes. + ownership. Landed in `d68c83d1`, `5882b308`, and `fcb66198`. The final + exact-record host checkpoint passed three clean reviews plus 6,743 Release + tests / 5 skipped on 2026-07-21. - [ ] B — pure appearance/projection helpers and controller-level characterization tests. - [ ] C — hydration create/materialize/landblock-recovery ownership and direct From 69a2ca0c6db9b86392fd0f1bae7d83de6e715cf5 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 16:17:03 +0200 Subject: [PATCH 43/98] refactor(world): extract live projection mechanics Move appearance rebinding, collision construction, default-pose resolution, local shadow ownership, and exact leave-world presentation into focused owners. Preserve retail parent ordering with staged validation, committed recovery, recursive attached-subtree withdrawal, and retryable exact teardown across parent, pickup, position, unwield, and pose-loss edges. Co-Authored-By: OpenAI Codex --- .../retail-divergence-register.md | 6 +- .../Physics/LiveEntityCollisionBuilder.cs | 231 ++++ .../Physics/LiveEntityDefaultPoseResolver.cs | 59 + .../Physics/LocalPlayerShadowState.cs | 19 + .../EquippedChildRenderController.cs | 905 ++++++++++++- src/AcDream.App/Rendering/GameWindow.cs | 452 ++----- .../Rendering/LiveEntityAppearanceBinding.cs | 127 ++ .../World/InboundPhysicsStateController.cs | 57 +- ...iveEntityProjectionWithdrawalController.cs | 175 +++ src/AcDream.App/World/LiveEntityRuntime.cs | 69 +- .../World/ParentAttachmentState.cs | 148 ++- .../Physics/ShadowObjectRegistry.cs | 86 ++ .../Physics/ShadowShapeBuilder.cs | 16 +- src/AcDream.Core/Physics/TransitionTypes.cs | 4 +- .../LiveEntityCollisionBuilderTests.cs | 358 +++++ .../EquippedChildProjectionWithdrawalTests.cs | 1151 +++++++++++++++++ .../Rendering/LiveAppearanceAnimationTests.cs | 133 +- .../LiveEntityAnimationPresenterTests.cs | 4 +- .../Wb/EntitySpawnAdapterLifetimeTests.cs | 72 ++ .../GameWindowLiveEntityCompositionTests.cs | 51 + .../InboundPhysicsStateControllerTests.cs | 14 +- ...tityProjectionWithdrawalControllerTests.cs | 289 +++++ .../World/LiveEntityRuntimeTests.cs | 6 +- .../World/ParentAttachmentStateTests.cs | 37 +- .../Physics/ShadowObjectRegistryTests.cs | 113 ++ .../Physics/ShadowShapeBuilderTests.cs | 20 + 26 files changed, 4172 insertions(+), 430 deletions(-) create mode 100644 src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs create mode 100644 src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs create mode 100644 src/AcDream.App/Physics/LocalPlayerShadowState.cs create mode 100644 src/AcDream.App/Rendering/LiveEntityAppearanceBinding.cs create mode 100644 src/AcDream.App/World/LiveEntityProjectionWithdrawalController.cs create mode 100644 tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs create mode 100644 tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs create mode 100644 tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 781d88d8..8258839f 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -132,7 +132,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 | | AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 | | AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive → D3DBLEND_ONE | -| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 | +| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 | | AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b | | ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` | | AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) | @@ -184,7 +184,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks), moving to `LiveEntityNetworkUpdateController` in Slice 4 | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. | | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | | AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) | -| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | +| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | | AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | | AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` | | AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection + authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit | @@ -253,7 +253,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 | | TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame | | TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` | -| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) | +| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-91` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) | | TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` | | TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` | | TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Rendering/GameWindow.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` | diff --git a/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs new file mode 100644 index 00000000..8da45062 --- /dev/null +++ b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs @@ -0,0 +1,231 @@ +using System.Numerics; +using AcDream.App.World; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Physics; + +/// +/// Immutable input to the canonical shadow registry for one live object. +/// Building is DAT/physics-only and therefore testable without a window or GL +/// context; registration remains one explicit commit. +/// +internal sealed record LiveEntityCollisionRegistration( + uint EntityId, + uint SourceId, + Vector3 EntityWorldPosition, + Quaternion EntityWorldRotation, + IReadOnlyList Shapes, + uint State, + EntityCollisionFlags Flags, + float WorldOffsetX, + float WorldOffsetY, + uint LandblockId, + uint SeedCellId); + +/// +/// Ports the live-object collision-shape policy used by +/// CPartArray::FindObjCollisions: CylSpheres before Spheres, every +/// physics-BSP part, and the established Setup-radius fallback for the +/// remaining ACE prop data. +/// +internal sealed class LiveEntityCollisionBuilder +{ + private readonly Func _hasPhysicsBsp; + private readonly Func _physicsBspRadius; + private readonly LiveEntityDefaultPoseResolver _defaultPose; + + public LiveEntityCollisionBuilder( + PhysicsDataCache physicsData, + LiveEntityDefaultPoseResolver defaultPose) + : this( + id => physicsData.GetGfxObj(id)?.BSP?.Root is not null, + id => physicsData.GetGfxObj(id)?.BoundingSphere?.Radius, + defaultPose) + { + ArgumentNullException.ThrowIfNull(physicsData); + } + + internal LiveEntityCollisionBuilder( + Func hasPhysicsBsp, + Func physicsBspRadius, + LiveEntityDefaultPoseResolver defaultPose) + { + _hasPhysicsBsp = hasPhysicsBsp + ?? throw new ArgumentNullException(nameof(hasPhysicsBsp)); + _physicsBspRadius = physicsBspRadius + ?? throw new ArgumentNullException(nameof(physicsBspRadius)); + _defaultPose = defaultPose + ?? throw new ArgumentNullException(nameof(defaultPose)); + } + + /// + /// Resolves collision slot zero for every post-AnimPartChanged identity. + /// This intentionally has no visual-LOD option or Setup-type input. + /// + public static uint[] ResolveEffectivePartIdentities( + IReadOnlyList postAnimPartGfxObjIds, + Func resolveSlotZero) + { + ArgumentNullException.ThrowIfNull(postAnimPartGfxObjIds); + ArgumentNullException.ThrowIfNull(resolveSlotZero); + var result = new uint[postAnimPartGfxObjIds.Count]; + for (int i = 0; i < result.Length; i++) + result[i] = resolveSlotZero(postAnimPartGfxObjIds[i]); + return result; + } + + public LiveEntityCollisionRegistration? Build( + WorldEntity entity, + Setup setup, + IReadOnlyList effectivePartGfxObjIds, + WorldSession.EntitySpawn spawn, + LiveEntityRecord exactRecord, + Vector3 worldOrigin, + bool retainEmptyPayload = false) + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(setup); + ArgumentNullException.ThrowIfNull(effectivePartGfxObjIds); + ArgumentNullException.ThrowIfNull(exactRecord); + if (spawn.Position is not { } position) + return null; + if (spawn.Guid != exactRecord.ServerGuid + || spawn.InstanceSequence != exactRecord.Generation + || !ReferenceEquals(exactRecord.WorldEntity, entity)) + { + throw new InvalidOperationException( + "Live collision construction requires the exact materialized record."); + } + + float scale = spawn.ObjScale ?? 1f; + IReadOnlyList? defaultPose = _defaultPose.Resolve( + spawn.MotionTableId ?? 0u, + setup.Parts.Count); + IReadOnlyList raw = ShadowShapeBuilder.FromSetup( + setup, + scale, + _hasPhysicsBsp, + partPoseOverride: defaultPose, + effectivePartGfxObjIds: effectivePartGfxObjIds); + var shapes = new List(raw.Count); + foreach (ShadowShape shape in raw) + { + if (shape.CollisionType == ShadowCollisionType.BSP) + { + float radius = (_physicsBspRadius(shape.GfxObjId) ?? 2f) * scale; + shapes.Add(shape with { Radius = radius }); + } + else + { + shapes.Add(shape); + } + } + + if (shapes.Count == 0 && setup.Radius > 0.0001f) + { + shapes.Add(new ShadowShape( + GfxObjId: 0u, + LocalPosition: Vector3.Zero, + LocalRotation: Quaternion.Identity, + Scale: scale, + CollisionType: ShadowCollisionType.Cylinder, + Radius: setup.Radius * scale, + CylHeight: (setup.Height > 0f ? setup.Height : setup.Radius * 2f) * scale)); + } + + if (shapes.Count == 0 && !retainEmptyPayload) + return null; + + EntityCollisionFlags flags = EntityCollisionFlags.HasWeenie; + if (spawn.ObjectDescriptionFlags is { } descriptionFlags) + flags |= EntityCollisionFlagsExt.FromPwdBitfield(descriptionFlags); + if (spawn.ItemType == (uint)ItemType.Creature) + flags |= EntityCollisionFlags.IsCreature; + + return new LiveEntityCollisionRegistration( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation, + shapes, + (uint)exactRecord.FinalPhysicsState, + flags, + worldOrigin.X, + worldOrigin.Y, + position.LandblockId, + position.LandblockId); + } + + public static void Register( + ShadowObjectRegistry registry, + LiveEntityCollisionRegistration registration) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(registration); + registry.RegisterMultiPart( + registration.EntityId, + registration.EntityWorldPosition, + registration.EntityWorldRotation, + registration.Shapes, + registration.State, + registration.Flags, + registration.WorldOffsetX, + registration.WorldOffsetY, + registration.LandblockId, + registration.SeedCellId, + isStatic: false); + + if (!PhysicsDiagnostics.ProbeBuildingEnabled) + return; + + int cylinders = 0; + int bsps = 0; + foreach (ShadowShape shape in registration.Shapes) + { + if (shape.CollisionType == ShadowCollisionType.Cylinder) + cylinders++; + else + bsps++; + } + Console.WriteLine(FormattableString.Invariant( + $"[entity-source] id=0x{registration.EntityId:X8} entityId=0x{registration.EntityId:X8} src=0x{registration.SourceId:X8} gfxObj=0x{registration.SourceId:X8} lb=0x{registration.LandblockId:X8} shapes=cyl{cylinders}+bsp{bsps} note=server-spawn-root state=0x{registration.State:X8} flags={registration.Flags}")); + } + + /// + /// Commits a retail ObjDesc part-array replacement. A shapeless result + /// removes the prior payload; a shaped result replaces it without making + /// a Hidden, parented, or cell-less object collide in world cells. + /// + public static void ReconcileAppearance( + ShadowObjectRegistry registry, + uint entityId, + LiveEntityCollisionRegistration? registration, + bool suspendIfNew) + { + ArgumentNullException.ThrowIfNull(registry); + if (registration is null) + return; + + if (registration.EntityId != entityId) + throw new InvalidOperationException("Collision replacement belongs to a different live entity."); + + registry.ReplaceMultiPartPayload( + registration.EntityId, + registration.EntityWorldPosition, + registration.EntityWorldRotation, + registration.Shapes, + registration.State, + registration.Flags, + registration.WorldOffsetX, + registration.WorldOffsetY, + registration.LandblockId, + registration.SeedCellId, + isStatic: false, + suspendIfNew); + } +} diff --git a/src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs b/src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs new file mode 100644 index 00000000..40d9a74f --- /dev/null +++ b/src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs @@ -0,0 +1,59 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Physics; + +/// +/// Resolves the motion table's authored default-state part pose used by a +/// live object's collision PartArray. Retail keeps the collision parts in the +/// same current pose as the visual PartArray; an unresolved table falls back +/// to Setup placement frames in . +/// The retained default-pose snapshot approximation is tracked as AP-84 in +/// the retail divergence register until live per-frame collision poses land. +/// +internal sealed class LiveEntityDefaultPoseResolver +{ + private readonly Func _loadMotionTable; + private readonly IAnimationLoader _animationLoader; + private readonly bool _dumpMotion; + + public LiveEntityDefaultPoseResolver( + Func loadMotionTable, + IAnimationLoader animationLoader, + bool dumpMotion) + { + _loadMotionTable = loadMotionTable + ?? throw new ArgumentNullException(nameof(loadMotionTable)); + _animationLoader = animationLoader + ?? throw new ArgumentNullException(nameof(animationLoader)); + _dumpMotion = dumpMotion; + } + + public IReadOnlyList? Resolve(uint motionTableId, int partCount) + { + if (motionTableId == 0u || partCount == 0) + return null; + + MotionTable? motionTable = _loadMotionTable(motionTableId); + if (motionTable is null) + return null; + + IReadOnlyList? pose = MotionTablePose.DefaultStatePartFrames( + motionTable, + _animationLoader.LoadAnimation); + + if (_dumpMotion) + { + string description = pose is null + ? "null->placement-fallback" + : FormattableString.Invariant( + $"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})"); + Console.WriteLine(FormattableString.Invariant( + $"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {description}")); + } + + return pose; + } +} diff --git a/src/AcDream.App/Physics/LocalPlayerShadowState.cs b/src/AcDream.App/Physics/LocalPlayerShadowState.cs new file mode 100644 index 00000000..777384da --- /dev/null +++ b/src/AcDream.App/Physics/LocalPlayerShadowState.cs @@ -0,0 +1,19 @@ +using System.Numerics; + +namespace AcDream.App.Physics; + +/// Session-scoped cache of the local player's last published shadow pose. +internal sealed class LocalPlayerShadowState +{ + public readonly record struct Snapshot( + Vector3 Position, + Quaternion Orientation, + uint CellId); + + public Snapshot? Current { get; private set; } + + public void Set(Vector3 position, Quaternion orientation, uint cellId) => + Current = new Snapshot(position, orientation, cellId); + + public void Clear() => Current = null; +} diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index 1e63cae8..87adf4fb 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -29,6 +29,8 @@ public sealed class EquippedChildRenderController : IDisposable private readonly ClientObjectTable _objects; private readonly LiveEntityRuntime _liveEntities; private readonly Func _acceptParent; + private readonly Func + _withdrawProjection; private readonly EntityEffectPoseRegistry _poses; private ParentAttachmentState Relations => _liveEntities.ParentAttachments; @@ -40,6 +42,13 @@ public sealed class EquippedChildRenderController : IDisposable /// Raised when an attached projection leaves its cell presentation. public event Action? ProjectionRemoved; private readonly Dictionary _attachedByChild = new(); + private readonly Dictionary _pendingUnparentByChild = new(); + private readonly Dictionary _pendingOrdinaryRemovalByRoot = new(); + private readonly Dictionary _pendingDetachedRemovalByChild = new(); + private readonly Dictionary _pendingReparentRemovalByChild = new(); + private readonly Dictionary _pendingPoseLossRemovalByChild = new(); + private readonly Dictionary _pendingOrphanRemovalByChild = new(); + private readonly List _pendingProjectionChildrenScratch = new(); private readonly AttachmentUpdateOrder _updateOrder = new(); private readonly Func _parentOfAttached; private readonly Func _tickAttached; @@ -59,7 +68,9 @@ public sealed class EquippedChildRenderController : IDisposable ClientObjectTable objects, LiveEntityRuntime liveEntities, EntityEffectPoseRegistry poses, - Func acceptParent) + Func acceptParent, + Func + withdrawProjection) { _dats = dats ?? throw new ArgumentNullException(nameof(dats)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); @@ -67,6 +78,8 @@ public sealed class EquippedChildRenderController : IDisposable _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _poses = poses ?? throw new ArgumentNullException(nameof(poses)); _acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent)); + _withdrawProjection = withdrawProjection + ?? throw new ArgumentNullException(nameof(withdrawProjection)); _parentOfAttached = static child => child.ParentGuid; _tickAttached = TickChild; @@ -144,14 +157,18 @@ public sealed class EquippedChildRenderController : IDisposable } } - public void OnGenerationReplaced(uint guid, ushort replacementGeneration) + /// + /// Removes projections owned by one retired incarnation. The runtime has + /// already removed that record from its active GUID table, so the root's + /// attached-map entry is committed by exact record identity without a + /// GUID lookup. Still-live descendants use the normal exact withdrawal + /// path and remain eligible to replay their retained parent relation. + /// + public void OnLogicalTeardown(LiveEntityRecord record) { - TearDownObjectProjections(guid); - } - - public void OnGenerationDeleted(uint guid, ushort deletedGeneration) - { - TearDownObjectProjections(guid); + ArgumentNullException.ThrowIfNull(record); + lock (_datLock) + TearDownRecordProjections(record); } private void OnObjectRemovalClassified(ClientObjectRemoval removal) @@ -160,13 +177,12 @@ public sealed class EquippedChildRenderController : IDisposable return; uint guid = removal.Object.ObjectId; - TearDownObjectProjections(guid); + TearDownCurrentObjectProjections(guid); // InventoryRemoveObject removes the item from the UI view, not the // live physics generation. Preserve fresher pending ParentEvents so a // same-generation world/parent update can still replay them. Logical // delete/replacement are driven directly by the accepted inbound // lifecycle and never depend on this table. - Relations.EndChildProjection(guid); } /// @@ -175,15 +191,32 @@ public sealed class EquippedChildRenderController : IDisposable /// ParentEvents; unlike logical deletion, do not disturb child-addressed /// pending state or children that may themselves reference it. /// - public void OnChildBecameUnparented(uint childGuid) + public ChildUnparentDisposition OnChildBecameUnparented( + uint childGuid, + Action? continuation = null) { - Remove(childGuid); - Relations.EndChildProjection(childGuid); + if (!_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord record)) + { + _pendingUnparentByChild.Remove(childGuid); + Relations.EndChildProjection(childGuid); + return ChildUnparentDisposition.NotAttached; + } + + var pending = new PendingUnparentTransition( + record, + record.PositionAuthorityVersion, + CaptureProjectionSubtreeParentFirst( + record, + restoreRootRelation: false, + restoreDescendantRelations: true), + continuation); + return AdvanceUnparentTransition(childGuid, pending); } /// Recompose every child after the parent's animation tick. public void Tick() { + RetryPendingProjectionTransitions(); IReadOnlyList failed = _updateOrder.ForEachParentFirst( _attachedByChild, _parentOfAttached, @@ -192,12 +225,48 @@ public sealed class EquippedChildRenderController : IDisposable WithdrawForPoseLoss(failed[i]); } + private void RetryPendingProjectionTransitions() + { + foreach ((uint rootGuid, PendingOrdinaryRemoval pending) in + _pendingOrdinaryRemovalByRoot.ToArray()) + { + AdvanceOrdinaryRemoval(rootGuid, pending); + } + + foreach ((uint childGuid, PendingProjectionSubtree pending) in + _pendingDetachedRemovalByChild.ToArray()) + { + AdvanceDetachedRemoval(childGuid, pending); + } + + RetryProjectionSubtrees(_pendingReparentRemovalByChild); + RetryProjectionSubtrees(_pendingPoseLossRemovalByChild); + RetryProjectionSubtrees(_pendingOrphanRemovalByChild); + + foreach ((uint childGuid, PendingUnparentTransition pending) in + _pendingUnparentByChild.ToArray()) + { + if (!_liveEntities.IsCurrentRecord(pending.Record) + || pending.Record.PositionAuthorityVersion + != pending.PositionAuthorityVersion) + { + _pendingUnparentByChild.Remove(childGuid); + continue; + } + AdvanceUnparentTransition(childGuid, pending); + } + + Relations.CopyPendingProjectionChildrenTo(_pendingProjectionChildrenScratch); + for (int i = 0; i < _pendingProjectionChildrenScratch.Count; i++) + ResolveAndTryRealize(_pendingProjectionChildrenScratch[i]); + } + private bool TickChild(uint childGuid) { if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child)) return false; - if (_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent) + if (TryResolveExactAttachment(child, out WorldEntity parent) && _poses.TryGetRootPose(parent.Id, out Matrix4x4 parentWorld) && _poses.TryGetPartPoseSnapshot( parent.Id, @@ -223,7 +292,8 @@ public sealed class EquippedChildRenderController : IDisposable ApplyParentDrawVisibility(child.Entity, parent); child.Entity.ParentCellId = parent.ParentCellId; PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose); - if (parent.ParentCellId is { } parentCellId) + if (TryResolveExactAttachment(child, out parent) + && parent.ParentCellId is { } parentCellId) _liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId); ProjectionPoseReady?.Invoke(child.ChildGuid); return true; @@ -231,12 +301,16 @@ public sealed class EquippedChildRenderController : IDisposable return false; } - private bool TryRealize(uint childGuid) + private bool TryRealize( + ParentAttachmentRelation pending, + ParentProjectionCandidateKind candidateKind) { - if (!Relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending)) - return false; + uint childGuid = pending.ChildGuid; - if (!_liveEntities.TryGetWorldEntity(pending.ParentGuid, out WorldEntity parentEntity) + if (!_liveEntities.TryGetRecord(pending.ParentGuid, out LiveEntityRecord parentRecord) + || parentRecord.WorldEntity is not { } parentEntity + || !parentRecord.IsSpatiallyProjected + || !_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord childRecord) || !_liveEntities.TryGetSnapshot(pending.ParentGuid, out WorldSession.EntitySpawn parentSpawn) || !_liveEntities.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn childSpawn)) return false; @@ -295,7 +369,12 @@ public sealed class EquippedChildRenderController : IDisposable _liveEntities.SetEffectProfile(childGuid, effectProfile); } - Remove(childGuid); + if (!_liveEntities.IsCurrentRecord(parentRecord) + || !_liveEntities.IsCurrentRecord(childRecord) + || !Relations.IsPending(pending, candidateKind)) + { + return false; + } WorldEntity? entity = _liveEntities.MaterializeLiveEntity( childGuid, parentCellId, @@ -318,6 +397,24 @@ public sealed class EquippedChildRenderController : IDisposable LiveEntityProjectionKind.Attached); if (entity is null) return false; + if (!_liveEntities.IsCurrentRecord(parentRecord) + || !_liveEntities.IsCurrentRecord(childRecord) + || !ReferenceEquals(childRecord.WorldEntity, entity) + || !Relations.IsPending(pending, candidateKind)) + { + if (_liveEntities.IsCurrentRecord(childRecord) + && ReferenceEquals(childRecord.WorldEntity, entity) + && childRecord.IsSpatiallyProjected) + { + BeginProjectionSubtreeWithdrawal( + _pendingOrphanRemovalByChild, + childRecord, + restoreRootRelation: false, + restoreDescendantRelations: false); + } + return false; + } + childRecord.HasPartArray = true; ApplyParentWorldPose(entity, parentWorld); ApplyParentDrawVisibility(entity, parentEntity); entity.ParentCellId = parentCellId; @@ -329,6 +426,8 @@ public sealed class EquippedChildRenderController : IDisposable : Array.Empty()); entity.SetIndexedPartPoses(pose.PartLocal, childPartAvailability); _attachedByChild[childGuid] = new AttachedChild( + parentRecord, + childRecord, pending.ParentGuid, childGuid, parentLocation, @@ -341,8 +440,8 @@ public sealed class EquippedChildRenderController : IDisposable pose.AttachedParts, scale, entity); - if (_liveEntities.TryGetRecord(pending.ParentGuid, out LiveEntityRecord parentRecord) - && (parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0) + _pendingUnparentByChild.Remove(childGuid); + if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0) { // A child attached while its parent is already Hidden inherits // the same direct child NoDraw mutation retail applies from @@ -353,7 +452,7 @@ public sealed class EquippedChildRenderController : IDisposable Console.WriteLine( $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " + $"location={parentLocation} placement={placement}"); - Relations.MarkProjected(childGuid); + Relations.MarkProjected(pending, candidateKind); ProjectionPoseReady?.Invoke(childGuid); EntityReady?.Invoke(childGuid); return true; @@ -420,7 +519,7 @@ public sealed class EquippedChildRenderController : IDisposable { foreach (AttachedChild child in _attachedByChild.Values) { - if (!_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent) + if (!TryResolveExactAttachment(child, out WorldEntity parent) || parent.Id != parentLocalId || !child.ParentSetup.HoldingLocations.TryGetValue( child.ParentLocation, @@ -441,7 +540,7 @@ public sealed class EquippedChildRenderController : IDisposable { if (child.Entity.Id != childLocalId) continue; - if (_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent)) + if (TryResolveExactAttachment(child, out WorldEntity parent)) return parent.Id; return null; } @@ -457,11 +556,46 @@ public sealed class EquippedChildRenderController : IDisposable { foreach (AttachedChild child in _attachedByChild.Values) { - if (child.ParentGuid == parentGuid) + if (child.ParentGuid == parentGuid + && TryResolveExactAttachment(child, out _)) _liveEntities.SetAttachedChildNoDraw(child.ChildGuid, noDraw); } } + public void OnCreateParentAccepted(CreateParentUpdate update) + { + lock (_datLock) + { + Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation( + update.ParentGuid, + update.ChildGuid, + update.ParentLocation, + update.PlacementId, + ParentInstanceSequence: 0, + update.ChildPositionSequence)); + ResolveAndTryRealize(update.ChildGuid); + } + } + + private bool TryResolveExactAttachment( + AttachedChild child, + out WorldEntity parent) + { + if (_liveEntities.IsCurrentRecord(child.ParentRecord) + && _liveEntities.IsCurrentRecord(child.ChildRecord) + && child.ParentRecord.IsSpatiallyProjected + && child.ChildRecord.IsSpatiallyProjected + && child.ParentRecord.WorldEntity is { } exactParent + && child.ChildRecord.WorldEntity is { } exactChild + && ReferenceEquals(exactChild, child.Entity)) + { + parent = exactParent; + return true; + } + parent = null!; + return false; + } + private void ResolveRelations(uint childGuid) { Relations.Resolve( @@ -475,10 +609,118 @@ public sealed class EquippedChildRenderController : IDisposable private bool ResolveAndTryRealize(uint childGuid) { - ResolveRelations(childGuid); - return TryRealize(childGuid); + bool projected = false; + while (true) + { + ResolveRelations(childGuid); + if (!Relations.TryGetStagedProjection( + childGuid, + out ParentAttachmentRelation staged)) + { + break; + } + + ParentProjectionValidationDisposition validation = + ValidateParentProjection(staged); + if (validation is ParentProjectionValidationDisposition.Waiting) + break; + if (validation is ParentProjectionValidationDisposition.Rejected) + { + Relations.RejectProjection(staged); + continue; + } + + ProjectionPreparationResult result = PrepareAndTryRealize( + staged, + ParentProjectionCandidateKind.Staged); + projected |= result.Projected; + if (!result.CanAdvanceWireQueue) + return projected; + } + + if (Relations.TryGetRecoveryProjection( + childGuid, + out ParentAttachmentRelation recovery) + && ValidateParentProjection(recovery) + is ParentProjectionValidationDisposition.Ready) + { + projected |= PrepareAndTryRealize( + recovery, + ParentProjectionCandidateKind.Recovery).Projected; + } + return projected; } + private ProjectionPreparationResult PrepareAndTryRealize( + ParentAttachmentRelation relation, + ParentProjectionCandidateKind candidateKind) + { + if (!_liveEntities.TryGetRecord( + relation.ChildGuid, + out LiveEntityRecord childRecord)) + { + return default; + } + + ulong positionAuthorityVersion = childRecord.PositionAuthorityVersion; + if (candidateKind is ParentProjectionCandidateKind.Staged) + { + if (!_liveEntities.CommitStagedParent(relation, out _) + || !Relations.CommitProjection(relation)) + { + return default; + } + candidateKind = ParentProjectionCandidateKind.Recovery; + } + else if (!Relations.IsCommitted(relation)) + { + return default; + } + + if (!Relations.IsPending(relation, candidateKind) + || !_liveEntities.CommitAcceptedParentCellless( + childRecord, + positionAuthorityVersion) + || !WithdrawPriorProjection(childRecord)) + { + return default; + } + return new( + CanAdvanceWireQueue: true, + Projected: TryRealize(relation, candidateKind)); + } + + internal ParentProjectionValidationDisposition ValidateParentProjection( + ParentAttachmentRelation relation) + { + if (relation.ParentGuid == relation.ChildGuid) + return ParentProjectionValidationDisposition.Rejected; + if (!_liveEntities.TryGetRecord(relation.ParentGuid, out LiveEntityRecord parent) + || !_liveEntities.TryGetRecord(relation.ChildGuid, out _) + || parent.WorldEntity is null + || !parent.HasPartArray) + { + return ParentProjectionValidationDisposition.Waiting; + } + if (!_liveEntities.TryGetSnapshot( + relation.ParentGuid, + out WorldSession.EntitySpawn parentSpawn) + || parentSpawn.SetupTableId is not { } parentSetupId) + { + return ParentProjectionValidationDisposition.Rejected; + } + Setup? parentSetup = _dats.Get(parentSetupId); + return parentSetup is not null + && parentSetup.HoldingLocations.ContainsKey( + (ParentLocation)relation.ParentLocation) + ? ParentProjectionValidationDisposition.Ready + : ParentProjectionValidationDisposition.Rejected; + } + + private readonly record struct ProjectionPreparationResult( + bool CanAdvanceWireQueue, + bool Projected); + private void RetryWaitingDescendants(uint parentGuid) { _updateOrder.RealizeDescendants( @@ -574,13 +816,52 @@ public sealed class EquippedChildRenderController : IDisposable private void OnObjectMoved(ClientObjectMove move) { if (move.Current.EquipLocation == EquipMask.None) - Remove(move.ItemId); + BeginDetachedRemoval(move.ItemId); + } + + private void BeginDetachedRemoval(uint childGuid) + { + if (!_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord record)) + return; + BeginProjectionSubtreeWithdrawal( + _pendingDetachedRemovalByChild, + record, + restoreRootRelation: false, + restoreDescendantRelations: true); + } + + private void AdvanceDetachedRemoval( + uint childGuid, + PendingProjectionSubtree pending) + { + AdvanceProjectionSubtree( + _pendingDetachedRemovalByChild, + childGuid, + pending); } private void OnMoveRolledBack(ClientObject item) { if (item.CurrentlyEquippedLocation == EquipMask.None) return; + if (_attachedByChild.TryGetValue(item.ObjectId, out AttachedChild? attached) + && _liveEntities.IsCurrentRecord(attached.ChildRecord) + && attached.ChildRecord.IsSpatiallyProjected) + { + // A component-stage failure left the exact equipped projection + // untouched. The authoritative rollback therefore has nothing to + // reconstruct and merely cancels its retained leave-world retry. + _pendingDetachedRemovalByChild.Remove(item.ObjectId); + return; + } + if (_pendingDetachedRemovalByChild.TryGetValue( + item.ObjectId, + out PendingProjectionSubtree pending)) + { + AdvanceDetachedRemoval(item.ObjectId, pending); + if (_pendingDetachedRemovalByChild.ContainsKey(item.ObjectId)) + return; + } // A rejected unwield restores the equipped location without a fresh // wire ParentEvent; reinstall the last accepted relationship only for @@ -595,52 +876,516 @@ public sealed class EquippedChildRenderController : IDisposable } } - private void Remove(uint childGuid) + private bool Remove(uint childGuid) { - if (!_attachedByChild.Remove(childGuid, out AttachedChild? child)) - return; - _liveEntities.WithdrawLiveEntityProjection(childGuid); - _poses.Remove(child.Entity.Id); + if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child)) + return false; + if (!_liveEntities.IsCurrentRecord(child.ChildRecord)) + return CommitProjectionRemoval(child); + return Remove( + child, + child.ChildRecord.PositionAuthorityVersion, + child.ChildRecord.ProjectionMutationVersion); + } + + private bool Remove( + AttachedChild child, + ulong positionAuthorityVersion, + ulong projectionMutationVersion) + { + if (!_liveEntities.IsCurrentRecord(child.ChildRecord)) + return CommitProjectionRemoval(child); + ExactProjectionWithdrawalOutcome outcome = WithdrawAttachedProjection( + child.ChildRecord, + positionAuthorityVersion, + projectionMutationVersion, + _withdrawProjection, + () => CommitProjectionRemoval(child)); + if (outcome.Failure is not null) + throw outcome.Failure; + return outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed; + } + + private bool WithdrawPriorProjection(LiveEntityRecord childRecord) + { + if (_pendingReparentRemovalByChild.TryGetValue( + childRecord.ServerGuid, + out PendingProjectionSubtree pending)) + { + return AdvanceProjectionSubtree( + _pendingReparentRemovalByChild, + childRecord.ServerGuid, + pending); + } + return BeginProjectionSubtreeWithdrawal( + _pendingReparentRemovalByChild, + childRecord, + restoreRootRelation: false, + restoreDescendantRelations: true); + } + + private ChildUnparentDisposition AdvanceUnparentTransition( + uint childGuid, + PendingUnparentTransition pending) + { + ExactProjectionWithdrawalOutcome outcome = AdvanceProjectionSubtree( + pending.Subtree, + out PendingProjectionSubtree next); + + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending) + { + _pendingUnparentByChild[childGuid] = pending with { Subtree = next }; + if (outcome.Failure is not null) + throw outcome.Failure; + return ChildUnparentDisposition.Pending; + } + + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Superseded) + { + _pendingUnparentByChild.Remove(childGuid); + if (outcome.Failure is not null) + throw outcome.Failure; + return ChildUnparentDisposition.Superseded; + } + + PendingUnparentTransition awaitingContinuation = pending with { Subtree = next }; + _pendingUnparentByChild[childGuid] = awaitingContinuation; + if (outcome.Failure is not null) + throw outcome.Failure; + + Relations.EndChildProjection(childGuid); + try + { + awaitingContinuation.Continuation?.Invoke(); + _pendingUnparentByChild.Remove(childGuid); + return ChildUnparentDisposition.Completed; + } + catch + { + bool continuationCommitted = + _liveEntities.IsCurrentRecord(awaitingContinuation.Record) + && awaitingContinuation.Record.PositionAuthorityVersion + == awaitingContinuation.PositionAuthorityVersion + && awaitingContinuation.Record.IsSpatiallyProjected + && awaitingContinuation.Record.ProjectionKind + is LiveEntityProjectionKind.World; + if (continuationCommitted) + _pendingUnparentByChild.Remove(childGuid); + else + _pendingUnparentByChild[childGuid] = awaitingContinuation; + throw; + } + } + + internal static ExactProjectionWithdrawalOutcome WithdrawAttachedProjection( + LiveEntityRecord childRecord, + ulong positionAuthorityVersion, + ulong projectionMutationVersion, + Func + withdrawProjection, + Func commitRemoval) + { + ArgumentNullException.ThrowIfNull(childRecord); + ArgumentNullException.ThrowIfNull(withdrawProjection); + ArgumentNullException.ThrowIfNull(commitRemoval); + ExactProjectionWithdrawalOutcome outcome = withdrawProjection( + childRecord, + positionAuthorityVersion, + projectionMutationVersion); + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending) + return outcome; + try + { + commitRemoval(); + return outcome; + } + catch (Exception error) + { + return new ExactProjectionWithdrawalOutcome( + outcome.Disposition, + outcome.Failure is null + ? error + : new AggregateException(outcome.Failure, error)); + } + } + + private bool CommitProjectionRemoval(AttachedChild child) + { + if (!_attachedByChild.TryGetValue(child.ChildGuid, out AttachedChild? current) + || !ReferenceEquals(current, child)) + { + return false; + } + + _attachedByChild.Remove(child.ChildGuid); ProjectionRemoved?.Invoke(child.Entity.Id); + return true; } private void WithdrawForPoseLoss(uint childGuid) { - if (!_attachedByChild.ContainsKey(childGuid)) + if (!_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord record)) return; - Remove(childGuid); - // MarkProjected removed the active candidate but retained the last - // accepted relationship. Requeue that exact accepted relation so a - // later parent pose/appearance registration can realize it again. - Relations.RestoreLastAccepted(childGuid); + BeginProjectionSubtreeWithdrawal( + _pendingPoseLossRemovalByChild, + record, + restoreRootRelation: true, + restoreDescendantRelations: true); } - private void TearDownObjectProjections(uint guid) + private void TearDownCurrentObjectProjections(uint guid) { - IReadOnlyList subtree = _updateOrder.CollectSubtreePostOrder( - _attachedByChild, - guid, - _parentOfAttached); - for (int i = 0; i < subtree.Count; i++) + if (_liveEntities.TryGetRecord(guid, out LiveEntityRecord record)) { - uint childGuid = subtree[i]; - Remove(childGuid); - if (childGuid != guid) + List captures = CaptureRecordSubtreeParentFirst(record); + AdvanceOrdinaryRemoval( + guid, + new PendingOrdinaryRemoval(record, captures, NextIndex: 0)); + return; + } + + if (_attachedByChild.TryGetValue(guid, out AttachedChild? stale)) + CommitProjectionRemoval(stale); + Relations.EndChildProjection(guid); + } + + private void AdvanceOrdinaryRemoval( + uint rootGuid, + PendingOrdinaryRemoval pending) + { + if (!_liveEntities.IsCurrentRecord(pending.RootRecord)) + { + _pendingOrdinaryRemovalByRoot.Remove(rootGuid); + return; + } + + for (int i = pending.NextIndex; i < pending.Captures.Count; i++) + { + AttachedRemovalCapture captured = pending.Captures[i]; + ExactProjectionWithdrawalOutcome outcome = WithdrawCaptured(captured); + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending) { - // Retail unparents direct children. Descendant relationships - // can become renderable again if their immediate parent later - // acquires a top-level Position, so retain their accepted - // relationship as a pending projection candidate. - Relations.RestoreLastAccepted(childGuid); + _pendingOrdinaryRemovalByRoot[rootGuid] = pending with { NextIndex = i }; + if (outcome.Failure is not null) + throw outcome.Failure; + return; + } + + if (!ReferenceEquals(captured.Attached.ChildRecord, pending.RootRecord) + && outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed) + { + Relations.RestoreLastAccepted(captured.Attached.ChildGuid); + } + + if (outcome.Failure is not null) + { + _pendingOrdinaryRemovalByRoot[rootGuid] = pending with + { + NextIndex = i + 1, + }; + throw outcome.Failure; } } + + _pendingOrdinaryRemovalByRoot.Remove(rootGuid); + Relations.EndChildProjection(rootGuid); + } + + private void TearDownRecordProjections(LiveEntityRecord record) + { + if (_pendingUnparentByChild.TryGetValue(record.ServerGuid, out var pending) + && ReferenceEquals(pending.Record, record)) + { + _pendingUnparentByChild.Remove(record.ServerGuid); + } + if (_pendingOrdinaryRemovalByRoot.TryGetValue( + record.ServerGuid, + out PendingOrdinaryRemoval ordinary) + && ReferenceEquals(ordinary.RootRecord, record)) + { + _pendingOrdinaryRemovalByRoot.Remove(record.ServerGuid); + } + RemovePendingProjectionSubtree(_pendingDetachedRemovalByChild, record); + RemovePendingProjectionSubtree(_pendingReparentRemovalByChild, record); + RemovePendingProjectionSubtree(_pendingPoseLossRemovalByChild, record); + RemovePendingProjectionSubtree(_pendingOrphanRemovalByChild, record); + List subtree = CaptureRecordSubtreeParentFirst(record); + for (int i = 0; i < subtree.Count; i++) + { + AttachedRemovalCapture captured = subtree[i]; + AttachedChild child = captured.Attached; + if (ReferenceEquals(child.ChildRecord, record)) + { + CommitProjectionRemoval(child); + continue; + } + ExactProjectionWithdrawalOutcome outcome = WithdrawCaptured(captured); + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending) + { + if (outcome.Failure is not null) + throw outcome.Failure; + throw new InvalidOperationException( + $"Attached projection 0x{child.ChildGuid:X8} remains pending exact teardown of 0x{record.ServerGuid:X8}."); + } + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed) + Relations.RestoreLastAccepted(child.ChildGuid); + if (outcome.Failure is not null) + throw outcome.Failure; + } + } + + private List CaptureRecordSubtreeParentFirst( + LiveEntityRecord record) + { + var result = new List(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + if (_attachedByChild.TryGetValue(record.ServerGuid, out AttachedChild? root) + && ReferenceEquals(root.ChildRecord, record)) + { + result.Add(Capture(root)); + } + CollectDescendantsParentFirst(record, result, visited); + return result; + } + + private void CollectDescendantsParentFirst( + LiveEntityRecord parent, + List destination, + HashSet visited) + { + if (!visited.Add(parent)) + return; + AttachedChild[] children = _attachedByChild.Values + .Where(child => ReferenceEquals(child.ParentRecord, parent)) + .ToArray(); + for (int i = 0; i < children.Length; i++) + { + destination.Add(Capture(children[i])); + CollectDescendantsParentFirst(children[i].ChildRecord, destination, visited); + } + } + + private static AttachedRemovalCapture Capture(AttachedChild child) => new( + child, + child.ChildRecord.PositionAuthorityVersion, + child.ChildRecord.ProjectionMutationVersion); + + private ExactProjectionWithdrawalOutcome WithdrawCaptured( + AttachedRemovalCapture captured) + { + if (!_attachedByChild.TryGetValue( + captured.Attached.ChildGuid, + out AttachedChild? current) + || !ReferenceEquals(current, captured.Attached)) + { + return new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + } + return WithdrawAttachedProjection( + captured.Attached.ChildRecord, + captured.PositionAuthorityVersion, + captured.ProjectionMutationVersion, + _withdrawProjection, + () => CommitProjectionRemoval(captured.Attached)); + } + + private PendingProjectionSubtree CaptureProjectionSubtreeParentFirst( + LiveEntityRecord root, + bool restoreRootRelation, + bool restoreDescendantRelations) + { + var captures = new List(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + CaptureProjectionNode(root, isRoot: true, captures, visited); + return new PendingProjectionSubtree( + root, + root.PositionAuthorityVersion, + captures, + NextIndex: 0, + restoreRootRelation, + restoreDescendantRelations); + } + + private void CaptureProjectionNode( + LiveEntityRecord record, + bool isRoot, + List destination, + HashSet visited) + { + if (!visited.Add(record)) + return; + _attachedByChild.TryGetValue(record.ServerGuid, out AttachedChild? attached); + if (attached is not null && !ReferenceEquals(attached.ChildRecord, record)) + attached = null; + if (record.IsSpatiallyProjected || attached is not null) + { + destination.Add(new ProjectionRemovalCapture( + record, + record.PositionAuthorityVersion, + record.ProjectionMutationVersion, + attached, + isRoot)); + } + + AttachedChild[] children = _attachedByChild.Values + .Where(child => ReferenceEquals(child.ParentRecord, record)) + .ToArray(); + for (int i = 0; i < children.Length; i++) + { + CaptureProjectionNode( + children[i].ChildRecord, + isRoot: false, + destination, + visited); + } + } + + private bool BeginProjectionSubtreeWithdrawal( + Dictionary pendingByRoot, + LiveEntityRecord root, + bool restoreRootRelation, + bool restoreDescendantRelations) + { + PendingProjectionSubtree pending = CaptureProjectionSubtreeParentFirst( + root, + restoreRootRelation, + restoreDescendantRelations); + return AdvanceProjectionSubtree(pendingByRoot, root.ServerGuid, pending); + } + + private bool AdvanceProjectionSubtree( + Dictionary pendingByRoot, + uint rootGuid, + PendingProjectionSubtree pending) + { + ExactProjectionWithdrawalOutcome outcome = AdvanceProjectionSubtree( + pending, + out PendingProjectionSubtree next); + bool retry = outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending + || (outcome.Failure is not null && next.NextIndex < next.Captures.Count); + if (retry) + pendingByRoot[rootGuid] = next; + else + pendingByRoot.Remove(rootGuid); + if (outcome.Failure is not null) + throw outcome.Failure; + return outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed + && next.NextIndex >= next.Captures.Count; + } + + private ExactProjectionWithdrawalOutcome AdvanceProjectionSubtree( + PendingProjectionSubtree pending, + out PendingProjectionSubtree next) + { + next = pending; + if (!_liveEntities.IsCurrentRecord(pending.RootRecord) + || pending.RootRecord.PositionAuthorityVersion + != pending.RootPositionAuthorityVersion) + { + return new( + ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + } + + for (int i = pending.NextIndex; i < pending.Captures.Count; i++) + { + ProjectionRemovalCapture captured = pending.Captures[i]; + ExactProjectionWithdrawalOutcome outcome = WithdrawProjectionCapture(captured); + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending) + { + next = pending with { NextIndex = i }; + return outcome; + } + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Superseded + && captured.IsRoot) + { + next = pending with { NextIndex = i + 1 }; + return outcome; + } + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed + && captured.Attached is not null + && (captured.IsRoot + ? pending.RestoreRootRelation + : pending.RestoreDescendantRelations)) + { + Relations.RestoreLastAccepted(captured.Record.ServerGuid); + } + next = pending with { NextIndex = i + 1 }; + if (outcome.Failure is not null) + return outcome; + } + + return new( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null); + } + + private ExactProjectionWithdrawalOutcome WithdrawProjectionCapture( + ProjectionRemovalCapture captured) + { + if (captured.Attached is { } attached) + { + return WithdrawCaptured(new AttachedRemovalCapture( + attached, + captured.PositionAuthorityVersion, + captured.ProjectionMutationVersion)); + } + if (!_liveEntities.IsCurrentRecord(captured.Record) + || captured.Record.PositionAuthorityVersion + != captured.PositionAuthorityVersion + || captured.Record.ProjectionMutationVersion + != captured.ProjectionMutationVersion) + { + return new( + ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + } + if (!captured.Record.IsSpatiallyProjected) + { + return new( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null); + } + return _withdrawProjection( + captured.Record, + captured.PositionAuthorityVersion, + captured.ProjectionMutationVersion); + } + + private void RetryProjectionSubtrees( + Dictionary pendingByRoot) + { + foreach ((uint rootGuid, PendingProjectionSubtree pending) in + pendingByRoot.ToArray()) + { + AdvanceProjectionSubtree(pendingByRoot, rootGuid, pending); + } + } + + private static void RemovePendingProjectionSubtree( + Dictionary pendingByRoot, + LiveEntityRecord record) + { + uint[] keys = pendingByRoot + .Where(pair => ReferenceEquals(pair.Value.RootRecord, record)) + .Select(pair => pair.Key) + .ToArray(); + for (int i = 0; i < keys.Length; i++) + pendingByRoot.Remove(keys[i]); } public void Clear() { - uint[] attached = _attachedByChild.Keys.ToArray(); + AttachedChild[] attached = _attachedByChild.Values.ToArray(); for (int i = 0; i < attached.Length; i++) - Remove(attached[i]); + CommitProjectionRemoval(attached[i]); + _pendingUnparentByChild.Clear(); + _pendingOrdinaryRemovalByRoot.Clear(); + _pendingDetachedRemovalByChild.Clear(); + _pendingReparentRemovalByChild.Clear(); + _pendingPoseLossRemovalByChild.Clear(); + _pendingOrphanRemovalByChild.Clear(); Relations.Clear(); } @@ -653,6 +1398,8 @@ public sealed class EquippedChildRenderController : IDisposable } private sealed record AttachedChild( + LiveEntityRecord ParentRecord, + LiveEntityRecord ChildRecord, uint ParentGuid, uint ChildGuid, ParentLocation ParentLocation, @@ -665,4 +1412,50 @@ public sealed class EquippedChildRenderController : IDisposable MeshRef[] AttachedPartBuffer, float Scale, WorldEntity Entity); + + private readonly record struct PendingUnparentTransition( + LiveEntityRecord Record, + ulong PositionAuthorityVersion, + PendingProjectionSubtree Subtree, + Action? Continuation); + + private readonly record struct PendingProjectionSubtree( + LiveEntityRecord RootRecord, + ulong RootPositionAuthorityVersion, + IReadOnlyList Captures, + int NextIndex, + bool RestoreRootRelation, + bool RestoreDescendantRelations); + + private readonly record struct ProjectionRemovalCapture( + LiveEntityRecord Record, + ulong PositionAuthorityVersion, + ulong ProjectionMutationVersion, + AttachedChild? Attached, + bool IsRoot); + + private readonly record struct AttachedRemovalCapture( + AttachedChild Attached, + ulong PositionAuthorityVersion, + ulong ProjectionMutationVersion); + + private readonly record struct PendingOrdinaryRemoval( + LiveEntityRecord RootRecord, + IReadOnlyList Captures, + int NextIndex); +} + +public enum ChildUnparentDisposition +{ + NotAttached, + Completed, + Pending, + Superseded, +} + +internal enum ParentProjectionValidationDisposition +{ + Ready, + Waiting, + Rejected, } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index dada8342..37de367f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -19,6 +19,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene); private readonly AcDream.App.RuntimeOptions _options; + private readonly AnimationPresentationDiagnostics _animationDiagnostics; private readonly string _datDir; private readonly WorldGameState _worldGameState; private readonly WorldEvents _worldEvents; @@ -192,6 +193,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Step 7 projectile presentation. The controller owns no identity map; // each runtime component is stored on the canonical LiveEntityRecord. private AcDream.App.Physics.ProjectileController? _projectileController; + private AcDream.App.World.LiveEntityProjectionWithdrawalController? + _liveEntityProjectionWithdrawal; // Step 4: portal-based interior cell visibility. private readonly CellVisibility _cellVisibility = new(); @@ -300,11 +303,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new(); - private sealed record AppearanceUpdateState( - AcDream.Core.World.WorldEntity Entity, - LiveEntityAnimationState? Animation); - private AcDream.Core.Physics.IAnimationLoader? _animLoader; + private AcDream.App.Physics.LiveEntityCollisionBuilder? _liveEntityCollisionBuilder; // Phase E.1: central fan-out for animation hooks. Audio (E.2), // particles (E.3), combat (E.4), and renderer state mutators all @@ -591,9 +591,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character private MovementTruthOutbound? _lastMovementTruthOutbound; - private (System.Numerics.Vector3 Position, - System.Numerics.Quaternion Orientation, - uint CellId)? _lastLocalPlayerShadow; + private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new(); private readonly record struct MovementTruthOutbound( string Kind, @@ -815,6 +813,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _worldGameState = worldGameState; _worldEvents = worldEvents; _selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); + _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _uiRegistry = uiRegistry; _animatedEntities = new LiveEntityAnimationRuntimeView(() => _liveEntities); _remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView(() => _liveEntities); @@ -847,7 +846,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext () => _staticAnimationScheduler, _effectPoses, this, - AnimationPresentationDiagnostics.FromEnvironment(), + _animationDiagnostics, options.HidePartIndex); _localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController( resolveEntity: () => @@ -1182,6 +1181,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext SpellBook.InstallMetadata(_magicCatalog.SpellTable); Console.WriteLine($"spells: loaded {SpellTable.Count} entries from portal.dat"); _animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(_dats); + _liveEntityCollisionBuilder = new AcDream.App.Physics.LiveEntityCollisionBuilder( + _physicsDataCache, + new AcDream.App.Physics.LiveEntityDefaultPoseResolver( + id => _dats.Get(id), + _animLoader, + _animationDiagnostics.DumpMotionEnabled)); _emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats); // Phase E.3 particles: always-on, no driver dependency. Registered @@ -2281,6 +2286,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"), }; + _liveEntityProjectionWithdrawal = + new AcDream.App.World.LiveEntityProjectionWithdrawalController( + _liveEntities, + _projectileController, + _worldGameState, + _worldEvents, + _physicsEngine.ShadowObjects, + _effectPoses, + _localPlayerShadow); _liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController( _liveEntities, _effectPoses, @@ -2293,7 +2307,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Objects, _liveEntities, _effectPoses, - TryAcceptParentForRender); + TryAcceptParentForRender, + (childRecord, positionVersion, projectionVersion) => + _liveEntityProjectionWithdrawal!.WithdrawExact( + childRecord, + positionVersion, + projectionVersion, + _playerServerGuid)); var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver( id => _dats!.Get(id)); @@ -2672,7 +2692,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _autoRunActive = false; _chaseModeEverEntered = false; _lastMovementTruthOutbound = null; - _lastLocalPlayerShadow = null; + _localPlayerShadow.Clear(); _spawnClaimRangeMemo = null; } } @@ -2988,13 +3008,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { PublishLocalPhysicsTimestamps(spawn.Guid, result.Timestamps); - if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration) - { - _equippedChildRenderer?.OnGenerationReplaced( - spawn.Guid, - result.Snapshot.InstanceSequence); - } - AcDream.Core.Net.ObjectTableWiring.ApplyEntitySpawn( Objects, spawn, @@ -3156,7 +3169,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private void OnLiveEntitySpawnedLocked( AcDream.Core.Net.WorldSession.EntitySpawn spawn, LiveProjectionPurpose purpose, - AppearanceUpdateState? appearanceUpdate = null) + LiveEntityAppearanceUpdateState? appearanceUpdate = null) { _liveSpawnReceived++; @@ -3461,6 +3474,28 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // changes resolve (which match against the resolved mesh's // surfaces) and BEFORE the GfxObjMesh.Build / texture upload // path consumes the part list. + // Collision reads slot 0 of the installed CPhysicsPart degrade array. + // Keep this independent from the optional visual close-LOD policy: + // draw settings and Setup type must never change physical geometry. + uint[] collisionPartGfxObjIds = + AcDream.App.Physics.LiveEntityCollisionBuilder.ResolveEffectivePartIdentities( + parts.Select(static part => part.GfxObjId).ToArray(), + baseId => + { + if (!AcDream.Core.Meshing.GfxObjDegradeResolver.TryResolveCloseGfxObj( + _dats, + baseId, + out uint slotZeroId, + out DatReaderWriter.DBObjs.GfxObj? slotZeroGfx)) + { + return baseId; + } + + if (slotZeroGfx is not null) + _physicsDataCache.CacheGfxObj(slotZeroId, slotZeroGfx); + return slotZeroId; + }); + if (_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup)) { for (int partIdx = 0; partIdx < parts.Count; partIdx++) @@ -3708,6 +3743,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext if (appearanceUpdate is { } visualUpdate) { AcDream.Core.World.WorldEntity existing = visualUpdate.Entity; + LiveEntityAppearanceCollisionUpdate? appearanceCollision = + LiveEntityAppearanceBinding.PrepareCollision( + _liveEntities!, + _liveEntityCollisionBuilder!, + existing, + setup, + collisionPartGfxObjIds, + spawn, + origin); _wbEntitySpawnAdapter!.OnAppearanceChanged( existing, meshRefs, @@ -3723,11 +3767,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext }, () => { + if (appearanceCollision is not null) + LiveEntityAppearanceBinding.CommitCollision( + _liveEntities, + _physicsEngine.ShadowObjects, + appearanceCollision); existing.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax)) existing.SetLocalBounds(appearanceMin, appearanceMax); if (visualUpdate.Animation is { } animation) - RebindAnimatedEntityForAppearance( + LiveEntityAppearanceBinding.RebindAnimation( animation, existing, setup, @@ -3844,8 +3893,20 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // are deliberately skipped — retail FUN's `FindObjCollisions` // falls through to OK_TS for any object with no collision // geometry (acclient_2013_pseudo_c.txt:276917,276987). - RegisterLiveEntityCollision(entity, setup, spawn, origin); - if (_liveEntities.TryGetRecord(spawn.Guid, out var liveRecord)) + if (_liveEntities.TryGetRecord(spawn.Guid, out var liveRecord) + && _liveEntityCollisionBuilder!.Build( + entity, + setup, + collisionPartGfxObjIds, + spawn, + liveRecord, + origin) is { } collision) + { + AcDream.App.Physics.LiveEntityCollisionBuilder.Register( + _physicsEngine.ShadowObjects, + collision); + } + if (_liveEntities.TryGetRecord(spawn.Guid, out liveRecord)) _projectileController?.TryBind( liveRecord, setup, @@ -4124,9 +4185,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext isLocalPlayer: delete.Guid == _playerServerGuid, beforeTeardown: () => { - _equippedChildRenderer?.OnGenerationDeleted( - delete.Guid, - delete.InstanceSequence); AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete); }); @@ -4174,7 +4232,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext lock (_datLock) { - AppearanceUpdateState? appearanceState = CaptureLiveAppearanceState(update.Guid); + LiveEntityAppearanceUpdateState? appearanceState = + LiveEntityAppearanceBinding.Capture(_liveEntities, update.Guid); OnLiveEntitySpawnedLocked( newSpawn, LiveProjectionPurpose.AppearanceMutation, @@ -4188,36 +4247,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _paperdollDollDirty = true; } - /// - /// Capture the runtime identity that retail preserves across - /// DoObjDescChangesFromDefault. Replacement visual data is hydrated - /// first and then assigned to this same entity instance. - /// - private AppearanceUpdateState? CaptureLiveAppearanceState(uint serverGuid) - { - if (_liveEntities?.TryGetWorldEntity(serverGuid, out var entity) != true) - return null; - - _animatedEntities.TryGetValue(entity.Id, out var animation); - return new AppearanceUpdateState(entity, animation); - } - - internal static void RebindAnimatedEntityForAppearance( - LiveEntityAnimationState animation, - AcDream.Core.World.WorldEntity entity, - DatReaderWriter.DBObjs.Setup setup, - float scale, - IReadOnlyList partTemplate, - IReadOnlyList partAvailability) - { - animation.Entity = entity; - animation.Setup = setup; - animation.Scale = scale; - animation.PartTemplate = partTemplate; - animation.PartAvailability = partAvailability; - animation.InvalidatePresentationPoses(); - } - /// /// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) + /// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette / @@ -4327,175 +4356,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext doll.MeshRefs = reposed; } - /// - /// Commit B 2026-04-29 — register a live (server-spawned) entity into - /// the as a single collision body. - /// One entry per entity (in contrast to static scenery's per-CylSphere - /// registration) so the leave-world path's single - /// Deregister(entity.Id) cleans it up without leaks. - /// - /// - /// Geometry-priority order matches retail - /// (acclient_2013_pseudo_c.txt:276858-276987): CylSpheres > - /// Sphere fallback > Setup.Radius. Phantom Setups (no shape) are - /// rejected — retail's FindObjCollisions falls through to - /// OK_TS in that case. - /// - /// - /// - /// Carries derived from the PWD - /// bitfield (acclient.h:6431-6463) plus IsCreature - /// derived from the inbound ItemType. Commit C consumes these in - /// the PvP exemption block. - /// - /// - private void RegisterLiveEntityCollision( - AcDream.Core.World.WorldEntity entity, - DatReaderWriter.DBObjs.Setup setup, - AcDream.Core.Net.WorldSession.EntitySpawn spawn, - System.Numerics.Vector3 origin) - { - if (spawn.Position is null) return; - - bool hasCyl = setup.CylSpheres.Count > 0; - bool hasSphere = setup.Spheres.Count > 0; - bool hasRadius = setup.Radius > 0.0001f; - - // NOTE: We intentionally do NOT gate here on `!hasCyl && !hasSphere && !hasRadius`. - // That premature check was wrong: ShadowShapeBuilder.FromSetup also emits a BSP shape - // for any Part whose GfxObj has a non-null PhysicsBSP (e.g. a candle holder or floor - // candelabra with only a BSP collision mesh, no CylSphere/Sphere/Radius). Gating here - // would silently drop those BSP-only entities before the builder runs, making them - // invisible walls or fully passable. The correct final gate is at shapes.Count==0 - // below (after the builder + Radius fallback have run), which correctly handles ALL - // cases: BSP-only -> builder emits BSP shape -> registered; truly shapeless -> builder - // empty, no Radius fallback -> shapes.Count==0 -> return. Retail eligibility is - // "has physics_bsp OR cylsphere OR sphere" per CPhysicsObj::FindObjCollisions - // (acclient_2013_pseudo_c.txt:276917 context: the gate fires on the MOVER, not the - // target; no equivalent target-side gate skips BSP-only objects). - - float entScale = spawn.ObjScale ?? 1.0f; - - // A6.P4 door fix (2026-05-24): build the multi-part shape list. - // ShadowShapeBuilder emits one entry per CylSphere, one per Sphere - // (only when no CylSpheres), and one per Part with a non-null - // PhysicsBSP. Retail-faithful per CTransition::find_obj_collisions - // → CPartArray::FindObjCollisions - // (acclient_2013_pseudo_c.txt:286236). Pre-fix doors registered - // ONE small Cylinder via setup.Radius / Sphere — too narrow to - // span the doorway gap, so the player could walk through. With - // this change the door also registers the part-0 BSP slab - // (1.9 × 0.26 × 2.5 m) that retail uses for the real block. - // #175 (2026-07-05): BSP part shapes must pose at the motion table's - // DEFAULT-STATE frame (the closed pose — what the sequencer renders - // for an idle entity and what retail's live CPhysicsPart pose is), - // not the Setup's placement frame. The Facility Hub double door - // (Setup 0x02000C9D) places its panels AJAR in the placement frame - // (yaw −150°/−30°, −0.44 m behind the doorway) while rendering - // closed — the user embedded into the visual door on one side and - // hit a phantom slab on the other. Null (no motion table / no - // cycle / part-count mismatch) falls back to placement frames. - var closedPose = MotionTableDefaultPose( - spawn.MotionTableId ?? 0u, setup.Parts.Count); - - var raw = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup( - setup, entScale, - id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null, - partPoseOverride: closedPose); - - // Substitute the real bounding-sphere radius for BSP shapes — - // the pure builder's 2.0 placeholder works for typical doors - // (BS radius ≈ 1.975 m) but is loose for larger entities and - // tight for smaller ones. Mirrors the landblock-static path's - // pattern of pulling the real radius from PhysicsDataCache. - var shapes = new List(raw.Count); - foreach (var s in raw) - { - if (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.BSP) - { - var phys = _physicsDataCache.GetGfxObj(s.GfxObjId); - float bspR = (phys?.BoundingSphere?.Radius ?? 2f) * entScale; - shapes.Add(s with { Radius = bspR }); - } - else - { - shapes.Add(s); - } - } - - // setup.Radius fallback: the builder doesn't emit a Radius-only - // shape (it only walks CylSpheres / Spheres / Parts). For entities - // with no CylSpheres / Spheres / BSP-bearing Parts but a non-zero - // Radius (rare — simple decorative props), preserve the prior - // behavior of registering a setup.Radius cylinder so we don't - // silently regress those entities' collision. - if (shapes.Count == 0 && hasRadius) - { - shapes.Add(new AcDream.Core.Physics.ShadowShape( - GfxObjId: 0u, - LocalPosition: System.Numerics.Vector3.Zero, - LocalRotation: System.Numerics.Quaternion.Identity, - Scale: entScale, - CollisionType: AcDream.Core.Physics.ShadowCollisionType.Cylinder, - Radius: setup.Radius * entScale, - CylHeight: (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale)); - } - - if (shapes.Count == 0) - return; - - // Decode PvP / Player / Impenetrable from PWD._bitfield. - // IsCreature comes from the spawn's ItemType (server-known type). - // Every server-spawned live entity is backed by a CWeenieObject. - // Static landblock geometry is registered through the separate path - // with HasWeenie clear. Retail OBJECTINFO::missile_ignore 0x0050CEB0 - // needs this distinction for ethereal non-creatures such as doors. - var flags = AcDream.Core.Physics.EntityCollisionFlags.HasWeenie; - if (spawn.ObjectDescriptionFlags is { } odf) - flags |= AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield(odf); - if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature) - flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature; - - uint state = _liveEntities?.TryGetRecord(spawn.Guid, out LiveEntityRecord liveRecord) == true - ? (uint)liveRecord.FinalPhysicsState - : spawn.PhysicsState ?? 0u; - - _physicsEngine.ShadowObjects.RegisterMultiPart( - entityId: entity.Id, - entityWorldPos: entity.Position, - entityWorldRot: entity.Rotation, - shapes: shapes, - state: state, - flags: flags, - worldOffsetX: origin.X, - worldOffsetY: origin.Y, - landblockId: spawn.Position.Value.LandblockId, - // BR-7 / A6.P4 (2026-06-11): the server position's full cell id - // is the registration flood seed (retail m_position.objcell_id - // into CObjCell::find_cell_list). A door whose spheres straddle - // the doorway lands in BOTH the outdoor landcell and the - // vestibule's shadow list at registration — the architectural - // close of #99. Dynamic objects use calc_cross_cells (no - // do_not_load prune), hence isStatic: false. - seedCellId: spawn.Position.Value.LandblockId, - isStatic: false); - - // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. - // Per-shape detail appears in [resolve-bldg] when collisions fire; - // this entity-level line keeps the spawn-time identification. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) - { - int nCyl = 0, nBsp = 0; - foreach (var s in shapes) - { - if (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.Cylinder) nCyl++; - else nBsp++; - } - Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} shapes=cyl{nCyl}+bsp{nBsp} note=server-spawn-root state=0x{state:X8} flags={flags}")); - } - } - private void RouteSameGenerationCreateObject( AcDream.App.World.SameGenerationCreateObjectEvents refresh) { @@ -4511,10 +4371,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext OnLiveAppearanceUpdated(refresh.Appearance); if (refresh.Parent is { } parent - && _liveEntities!.TryApplyCreateParent(parent, out var acceptedParent)) + && _liveEntities!.TryApplyCreateParent(parent, out _)) { - WithdrawLiveEntityWorldProjection(parent.ChildGuid); - _equippedChildRenderer?.OnSpawn(acceptedParent); + _equippedChildRenderer?.OnCreateParentAccepted(parent); } else if (refresh.Position is { } position) { @@ -4541,8 +4400,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext if (!_liveEntities!.TryApplyPickup(pickup, out _)) return; - _equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid); - bool removed = WithdrawLiveEntityWorldProjection(pickup.Guid); + AcDream.App.Rendering.ChildUnparentDisposition childDisposition = + _equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid) + ?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached; + bool removed = childDisposition switch + { + AcDream.App.Rendering.ChildUnparentDisposition.Completed => true, + AcDream.App.Rendering.ChildUnparentDisposition.NotAttached => true, + _ => false, + }; if (removed && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") @@ -4564,9 +4430,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private bool TryAcceptParentForRender(AcDream.Core.Net.Messages.ParentEvent.Parsed update) { - if (!_liveEntities!.TryApplyParent(update, out _)) return false; - WithdrawLiveEntityWorldProjection(update.ChildGuid); - return true; + return _liveEntities!.TryApplyParent(update, out _); } private void PublishLocalPhysicsTimestamps( @@ -4596,7 +4460,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext } if (!force - && _lastLocalPlayerShadow is { } last + && _localPlayerShadow.Current is { } last && last.CellId == cellId && System.Numerics.Vector3.DistanceSquared( last.Position, playerEntity.Position) <= 1e-4f @@ -4614,7 +4478,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext cellId, _liveCenterX, _liveCenterY); - _lastLocalPlayerShadow = ( + _localPlayerShadow.Set( playerEntity.Position, playerEntity.Rotation, cellId); @@ -4631,38 +4495,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext AcDream.Core.World.WorldEntity playerEntity) { _physicsEngine.ShadowObjects.Suspend(playerEntity.Id); - _lastLocalPlayerShadow = null; - } - - /// - /// #175: the motion table's default-state pose (the closed pose for - /// doors) — the derivation lives in - /// (Core, - /// dat-conformance-tested; the first cut here used a bare-style cycle - /// key, always missed, and silently no-oped — the "175 is not fixed" - /// report). Returns null → placement-frame fallback. - /// - private IReadOnlyList? MotionTableDefaultPose( - uint motionTableId, int partCount) - { - if (motionTableId == 0u || partCount == 0 || _dats is null) return null; - - var mt = _dats.Get(motionTableId); - if (mt is null) return null; - - var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames( - mt, id => _animLoader?.LoadAnimation(id)); - - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - { - string desc = pose is null ? "null->placement-fallback" - : System.FormattableString.Invariant( - $"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})"); - Console.WriteLine(System.FormattableString.Invariant( - $"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {desc}")); - } - - return pose; + _localPlayerShadow.Clear(); } /// @@ -5067,7 +4900,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext cleanups.Add(() => incarnation.RunIfNoReplacement( () => _remoteLastMove.Remove(serverGuid))); cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188 - cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record)); + cleanups.Add(() => _liveEntityProjectionWithdrawal!.LeaveWorld( + record, + _playerServerGuid)); + cleanups.Add(() => _equippedChildRenderer?.OnLogicalTeardown(record)); // Logical teardown ends the retained shadow payload too. The weaker // pickup/parent path stops after Suspend so re-entry can restore it. cleanups.Add(() => _physicsEngine.ShadowObjects.Deregister(existingEntity.Id)); @@ -5075,56 +4911,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext return new AcDream.App.World.LiveEntityTeardownPlan(cleanups); } - /// - /// Retail CPhysicsObj::leave_world (0x005155A0): remove cell/shadow - /// presentation while retaining the PartArray, MovementManager, script - /// manager, animation owner, physics host, and effect owner. Pickup and - /// ParentEvent use this weaker transition; logical delete additionally - /// executes . - /// - private void LeaveWorldLiveEntityRuntimeComponents(LiveEntityRecord record) - { - if (record.WorldEntity is not { } entity) - return; - - bool retainedProjectileShadow = - _projectileController?.LeaveWorld(record) == true; - _worldGameState.RemoveById(entity.Id); - _worldEvents.ForgetEntity(entity.Id); - if (!retainedProjectileShadow) - _physicsEngine.ShadowObjects.Deregister(entity.Id); - if (record.ServerGuid == _playerServerGuid) - { - var incarnation = new AcDream.App.World.LiveEntityIncarnationCleanup( - record, - guid => _liveEntities?.TryGetRecord(guid, out LiveEntityRecord current) == true - ? current - : null); - incarnation.RunIfNoReplacement(() => _lastLocalPlayerShadow = null); - } - - // Pose-attached effects keep logical ownership while cell-less. A - // missing pose suppresses stale anchors; re-entry republishes the same - // WorldEntity frames without replaying create-time resources. - _effectPoses.Remove(entity.Id); - } - - /// - /// Removes the world-facing projection of a still-live object. Pickup and - /// parenting advance POSITION_TS but do not end the object incarnation, so - /// the runtime identity and create-time mesh/script resources survive. - /// - private bool WithdrawLiveEntityWorldProjection(uint serverGuid) - { - if (_liveEntities is null - || !_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) - || record.WorldEntity is null) - return false; - - LeaveWorldLiveEntityRuntimeComponents(record); - return _liveEntities.WithdrawLiveEntityProjection(serverGuid); - } - /// /// R4-V5: shared retail unpack_movement type-6..9 routing /// (0x00524440 cases 6/7/8/9, decomp §2f) — one body for remotes @@ -6220,15 +6006,33 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { if (!IsCurrentPositionOwner()) return; - _equippedChildRenderer?.OnChildBecameUnparented(update.Guid); - if (!IsCurrentPositionOwner()) - return; - lock (_datLock) - OnLiveEntitySpawnedLocked( - acceptedSpawn, - LiveProjectionPurpose.SpatialRecovery); + AcDream.App.Rendering.ChildUnparentDisposition unparented = + _equippedChildRenderer?.OnChildBecameUnparented( + update.Guid, + () => + { + if (!IsCurrentPositionOwner()) + return; + lock (_datLock) + OnLiveEntitySpawnedLocked( + acceptedSpawn, + LiveProjectionPurpose.SpatialRecovery); + }) + ?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached; + if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.Superseded + or AcDream.App.Rendering.ChildUnparentDisposition.Pending) + return; if (!IsCurrentPositionOwner()) return; + if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.NotAttached) + { + lock (_datLock) + OnLiveEntitySpawnedLocked( + acceptedSpawn, + LiveProjectionPurpose.SpatialRecovery); + if (!IsCurrentPositionOwner()) + return; + } } if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; diff --git a/src/AcDream.App/Rendering/LiveEntityAppearanceBinding.cs b/src/AcDream.App/Rendering/LiveEntityAppearanceBinding.cs new file mode 100644 index 00000000..96e156bf --- /dev/null +++ b/src/AcDream.App/Rendering/LiveEntityAppearanceBinding.cs @@ -0,0 +1,127 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// Exact runtime identity retained while retail +/// SmartBox::UpdateVisualDesc mutates only a live object's visual +/// description. +/// +internal sealed record LiveEntityAppearanceUpdateState( + WorldEntity Entity, + LiveEntityAnimationState? Animation); + +internal sealed record LiveEntityAppearanceCollisionUpdate( + LiveEntityRecord Record, + WorldEntity Entity, + LiveEntityCollisionRegistration? Registration); + +/// +/// Focused appearance-lifetime operations shared by ObjDesc hydration and the +/// final animation presenter. This type owns no identity index; every capture +/// is resolved from . +/// +internal static class LiveEntityAppearanceBinding +{ + public static LiveEntityAppearanceUpdateState? Capture( + LiveEntityRuntime runtime, + uint serverGuid) + { + ArgumentNullException.ThrowIfNull(runtime); + if (!runtime.TryGetRecord(serverGuid, out LiveEntityRecord record) + || record.WorldEntity is not { } entity) + { + return null; + } + + return new LiveEntityAppearanceUpdateState( + entity, + record.AnimationRuntime as LiveEntityAnimationState); + } + + public static void RebindAnimation( + LiveEntityAnimationState animation, + WorldEntity entity, + Setup setup, + float scale, + IReadOnlyList partTemplate, + IReadOnlyList partAvailability) + { + ArgumentNullException.ThrowIfNull(animation); + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(setup); + ArgumentNullException.ThrowIfNull(partTemplate); + ArgumentNullException.ThrowIfNull(partAvailability); + + animation.Entity = entity; + animation.Setup = setup; + animation.Scale = scale; + animation.PartTemplate = partTemplate; + animation.PartAvailability = partAvailability; + animation.InvalidatePresentationPoses(); + } + + public static LiveEntityAppearanceCollisionUpdate? PrepareCollision( + LiveEntityRuntime runtime, + LiveEntityCollisionBuilder builder, + WorldEntity entity, + Setup setup, + IReadOnlyList effectivePartGfxObjIds, + WorldSession.EntitySpawn spawn, + Vector3 worldOrigin) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(setup); + ArgumentNullException.ThrowIfNull(effectivePartGfxObjIds); + if (!runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record) + || !ReferenceEquals(record.WorldEntity, entity)) + { + return null; + } + + return new LiveEntityAppearanceCollisionUpdate( + record, + entity, + builder.Build( + entity, + setup, + effectivePartGfxObjIds, + spawn, + record, + worldOrigin, + retainEmptyPayload: true)); + } + + public static bool CommitCollision( + LiveEntityRuntime runtime, + ShadowObjectRegistry registry, + LiveEntityAppearanceCollisionUpdate update) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(update); + if (!runtime.TryGetRecord(update.Record.ServerGuid, out LiveEntityRecord current) + || !ReferenceEquals(current, update.Record) + || !ReferenceEquals(current.WorldEntity, update.Entity)) + { + return false; + } + + LiveEntityCollisionBuilder.ReconcileAppearance( + registry, + update.Entity.Id, + update.Registration, + suspendIfNew: !current.IsSpatiallyVisible + || current.ProjectionKind is not LiveEntityProjectionKind.World + || (current.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0); + return true; + } +} diff --git a/src/AcDream.App/World/InboundPhysicsStateController.cs b/src/AcDream.App/World/InboundPhysicsStateController.cs index 58ebde3c..d39ee515 100644 --- a/src/AcDream.App/World/InboundPhysicsStateController.cs +++ b/src/AcDream.App/World/InboundPhysicsStateController.cs @@ -136,12 +136,7 @@ public sealed class InboundPhysicsStateController return false; } - accepted = ApplyParent( - child, - update.ParentGuid, - update.ParentLocation, - update.PlacementId, - update.ChildPositionSequence); + accepted = ApplyPositionTimestampOnly(child, update.ChildPositionSequence); _snapshots[update.ChildGuid] = accepted; return true; } @@ -160,13 +155,34 @@ public sealed class InboundPhysicsStateController return false; } + accepted = ApplyPositionTimestampOnly(child, update.ChildPositionSequence); + _snapshots[update.ChildGuid] = accepted; + return true; + } + + public bool TryCommitParent( + uint childGuid, + uint parentGuid, + uint parentLocation, + uint placementId, + ushort positionSequence, + out WorldSession.EntitySpawn accepted) + { + if (!TryGet(childGuid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn child) + || gate.PositionTimestamp != positionSequence + || child.PositionSequence != positionSequence) + { + accepted = default; + return false; + } + accepted = ApplyParent( child, - update.ParentGuid, - update.ParentLocation, - update.PlacementId, - update.ChildPositionSequence); - _snapshots[update.ChildGuid] = accepted; + parentGuid, + parentLocation, + placementId, + positionSequence); + _snapshots[childGuid] = accepted; return true; } @@ -595,6 +611,25 @@ public sealed class InboundPhysicsStateController }; } + private static WorldSession.EntitySpawn ApplyPositionTimestampOnly( + WorldSession.EntitySpawn old, + ushort positionSequence) + { + PhysicsSpawnData? physics = old.Physics; + if (physics is { } desc) + { + physics = desc with + { + Timestamps = desc.Timestamps with { Position = positionSequence }, + }; + } + return old with + { + PositionSequence = positionSequence, + Physics = physics, + }; + } + private static WorldSession.EntitySpawn ApplyParent( WorldSession.EntitySpawn old, uint parentGuid, diff --git a/src/AcDream.App/World/LiveEntityProjectionWithdrawalController.cs b/src/AcDream.App/World/LiveEntityProjectionWithdrawalController.cs new file mode 100644 index 00000000..14d13aa4 --- /dev/null +++ b/src/AcDream.App/World/LiveEntityProjectionWithdrawalController.cs @@ -0,0 +1,175 @@ +using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; + +namespace AcDream.App.World; + +/// +/// Owns retail CPhysicsObj::leave_world's App projection tail for a +/// still-live exact record. Component projection is removed before canonical +/// runtime withdrawal, matching the shipped failure/retry boundary. +/// +internal sealed class LiveEntityProjectionWithdrawalController +{ + private readonly LiveEntityRuntime _runtime; + private readonly ProjectileController _projectiles; + private readonly WorldGameState _worldState; + private readonly WorldEvents _worldEvents; + private readonly ShadowObjectRegistry _shadows; + private readonly EntityEffectPoseRegistry _effectPoses; + private readonly LocalPlayerShadowState _localPlayerShadow; + + public LiveEntityProjectionWithdrawalController( + LiveEntityRuntime runtime, + ProjectileController projectiles, + WorldGameState worldState, + WorldEvents worldEvents, + ShadowObjectRegistry shadows, + EntityEffectPoseRegistry effectPoses, + LocalPlayerShadowState localPlayerShadow) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _projectiles = projectiles ?? throw new ArgumentNullException(nameof(projectiles)); + _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); + _worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents)); + _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); + _effectPoses = effectPoses ?? throw new ArgumentNullException(nameof(effectPoses)); + _localPlayerShadow = localPlayerShadow + ?? throw new ArgumentNullException(nameof(localPlayerShadow)); + } + + public bool Withdraw(uint serverGuid, uint localPlayerGuid) + { + if (!_runtime.TryGetRecord(serverGuid, out LiveEntityRecord record) + || record.WorldEntity is null) + { + return false; + } + + return Withdraw(record, localPlayerGuid); + } + + /// + /// Withdraws only the captured incarnation and projection operation. A + /// leave-world observer may accept a fresh same-generation Position and + /// reproject this record; that newer projection must survive the displaced + /// outer operation just as a newer INSTANCE_TS must. + /// + public bool Withdraw(LiveEntityRecord record, uint localPlayerGuid) + { + ArgumentNullException.ThrowIfNull(record); + if (!_runtime.IsCurrentRecord(record) || record.WorldEntity is null) + return false; + + return Withdraw( + record, + record.PositionAuthorityVersion, + record.ProjectionMutationVersion, + localPlayerGuid); + } + + public bool Withdraw( + LiveEntityRecord record, + ulong positionAuthorityVersion, + ulong projectionMutationVersion, + uint localPlayerGuid) + { + ExactProjectionWithdrawalOutcome outcome = WithdrawExact( + record, + positionAuthorityVersion, + projectionMutationVersion, + localPlayerGuid); + if (outcome.Failure is not null) + throw outcome.Failure; + return outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed; + } + + public ExactProjectionWithdrawalOutcome WithdrawExact( + LiveEntityRecord record, + ulong positionAuthorityVersion, + ulong projectionMutationVersion, + uint localPlayerGuid) + { + ArgumentNullException.ThrowIfNull(record); + if (!_runtime.IsCurrentRecord(record) + || record.WorldEntity is null + || record.PositionAuthorityVersion != positionAuthorityVersion + || record.ProjectionMutationVersion != projectionMutationVersion) + { + return new( + ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + } + + try + { + LeaveWorld(record, localPlayerGuid); + bool completed = _runtime.WithdrawLiveEntityProjection( + record, + positionAuthorityVersion, + projectionMutationVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + } + catch (Exception error) + { + bool current = _runtime.IsCurrentRecord(record); + ExactProjectionWithdrawalDisposition disposition = + current + && record.PositionAuthorityVersion == positionAuthorityVersion + && record.ProjectionMutationVersion == projectionMutationVersion + && record.IsSpatiallyProjected + ? ExactProjectionWithdrawalDisposition.Pending + : current + && record.PositionAuthorityVersion == positionAuthorityVersion + && !record.IsSpatiallyProjected + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded; + return new(disposition, error); + } + } + + /// + /// Removes only the shorter world-facing component projection. This exact + /// overload is also used by logical teardown after the active GUID table + /// may already expose a replacement incarnation. + /// + public void LeaveWorld(LiveEntityRecord record, uint localPlayerGuid) + { + ArgumentNullException.ThrowIfNull(record); + if (record.WorldEntity is not { } entity) + return; + + bool retainedProjectileShadow = _projectiles.LeaveWorld(record); + if (!retainedProjectileShadow) + _shadows.Suspend(entity.Id); + _worldState.RemoveById(entity.Id); + _worldEvents.ForgetEntity(entity.Id); + if (record.ServerGuid == localPlayerGuid + && (!_runtime.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) + || ReferenceEquals(current, record))) + { + _localPlayerShadow.Clear(); + } + + // Pose-attached effects keep logical ownership while cell-less. A + // missing pose suppresses stale anchors; re-entry republishes the same + // WorldEntity frames without replaying create-time resources. + _effectPoses.Remove(entity.Id); + } +} + +public enum ExactProjectionWithdrawalDisposition +{ + Completed, + Pending, + Superseded, +} + +public readonly record struct ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition Disposition, + Exception? Failure); diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index c82d8655..e544e380 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -931,6 +931,38 @@ public sealed class LiveEntityRuntime return true; } + /// + /// Exact-incarnation withdrawal used after App leave-world callbacks. A + /// callback may accept a newer INSTANCE_TS for the same GUID; that newer + /// projection must never be withdrawn by the displaced operation. + /// + public bool WithdrawLiveEntityProjection(LiveEntityRecord expectedRecord) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + return WithdrawLiveEntityProjection( + expectedRecord, + expectedRecord.PositionAuthorityVersion, + expectedRecord.ProjectionMutationVersion); + } + + /// + /// Exact-operation withdrawal used across arbitrary App callbacks. Both + /// the accepted Position authority and projection mutation token are + /// pinned so a same-incarnation re-entry cannot be removed by an older + /// parent/pickup/leave-world operation. + /// + internal bool WithdrawLiveEntityProjection( + LiveEntityRecord expectedRecord, + ulong expectedPositionAuthorityVersion, + ulong expectedProjectionMutationVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + return IsCurrentRecord(expectedRecord) + && expectedRecord.PositionAuthorityVersion == expectedPositionAuthorityVersion + && expectedRecord.ProjectionMutationVersion == expectedProjectionMutationVersion + && WithdrawLiveEntityProjection(expectedRecord.ServerGuid); + } + /// /// Withdraws a still-live projection whose authoritative rollback frame /// is outside every world cell. Logical owners remain registered. @@ -1515,7 +1547,6 @@ public sealed class LiveEntityRuntime RefreshRecord(update.ChildGuid, accepted); if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record)) record.AdvancePositionAuthority(); - ClearWorldCell(update.ChildGuid); } return applied; } @@ -1528,11 +1559,43 @@ public sealed class LiveEntityRuntime RefreshRecord(update.ChildGuid, accepted); if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record)) record.AdvancePositionAuthority(); - ClearWorldCell(update.ChildGuid); } return applied; } + internal bool CommitStagedParent( + ParentAttachmentRelation relation, + out WorldSession.EntitySpawn accepted) + { + bool committed = _inbound.TryCommitParent( + relation.ChildGuid, + relation.ParentGuid, + relation.ParentLocation, + relation.PlacementId, + relation.ChildPositionSequence, + out accepted); + if (committed) + RefreshRecord(relation.ChildGuid, accepted); + return committed; + } + + /// + /// Commits retail CPhysicsObj::set_parent's cell-less edge only + /// after the parent accepted the child (PartArray + holding-location + /// validation). The POSITION_TS is consumed before this step, matching + /// retail; invalid parent relationships retain the old world projection. + /// + internal bool CommitAcceptedParentCellless( + LiveEntityRecord record, + ulong positionAuthorityVersion) + { + ArgumentNullException.ThrowIfNull(record); + if (!IsCurrentPositionAuthority(record, positionAuthorityVersion)) + return false; + ClearWorldCell(record.ServerGuid); + return IsCurrentPositionAuthority(record, positionAuthorityVersion); + } + public bool TryApplyMotion( WorldSession.EntityMotionUpdate update, bool retainPayload, @@ -2154,7 +2217,7 @@ public sealed class LiveEntityRuntime RefreshSpatialRuntimeIndexes(record); } - private bool IsCurrentRecord(LiveEntityRecord record) => + internal bool IsCurrentRecord(LiveEntityRecord record) => _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) && ReferenceEquals(current, record); diff --git a/src/AcDream.App/World/ParentAttachmentState.cs b/src/AcDream.App/World/ParentAttachmentState.cs index 04fa4e7f..029bac83 100644 --- a/src/AcDream.App/World/ParentAttachmentState.cs +++ b/src/AcDream.App/World/ParentAttachmentState.cs @@ -6,19 +6,20 @@ namespace AcDream.App.World; /// /// Update-thread state machine for parent relations that may arrive before /// either CreateObject. Unaccepted wire events remain in arrival order; -/// rollback history contains only relations that passed the canonical retail -/// timestamp gate. +/// committed rollback history contains only relations that passed both the +/// canonical retail timestamp gate and retail PartArray::add_child +/// validation. /// public sealed class ParentAttachmentState { private readonly Dictionary> _unresolvedByChild = new(); - private readonly Dictionary _projectionByChild = new(); + private readonly Dictionary _stagedByChild = new(); + private readonly Dictionary _recoveryByChild = new(); private readonly Dictionary _lastAcceptedByChild = new(); public void AcceptCreateObjectRelation(ParentAttachmentRelation relation) { - _projectionByChild[relation.ChildGuid] = relation; - _lastAcceptedByChild[relation.ChildGuid] = relation; + _stagedByChild[relation.ChildGuid] = relation; } public void Enqueue(ParentEvent.Parsed update) @@ -50,6 +51,8 @@ public sealed class ParentAttachmentState Func resolveInstance, Func accept) { + if (_stagedByChild.ContainsKey(childGuid)) + return; if (!_unresolvedByChild.TryGetValue(childGuid, out Queue? queue)) return; int candidateCount = queue.Count; @@ -95,8 +98,11 @@ public sealed class ParentAttachmentState if (!accept(update)) continue; - _projectionByChild[childGuid] = relation; - _lastAcceptedByChild[childGuid] = relation; + _stagedByChild[childGuid] = relation with + { + WaitOwner = ParentAttachmentWaitOwner.Unknown, + }; + break; } if (queue.Count == 0) @@ -104,22 +110,109 @@ public sealed class ParentAttachmentState } public bool TryGetProjection(uint childGuid, out ParentAttachmentRelation relation) => - _projectionByChild.TryGetValue(childGuid, out relation); + _stagedByChild.TryGetValue(childGuid, out relation) + || _recoveryByChild.TryGetValue(childGuid, out relation); - public void MarkProjected(uint childGuid) => _projectionByChild.Remove(childGuid); + public bool TryGetStagedProjection( + uint childGuid, + out ParentAttachmentRelation relation) => + _stagedByChild.TryGetValue(childGuid, out relation); + + public bool TryGetRecoveryProjection( + uint childGuid, + out ParentAttachmentRelation relation) => + _recoveryByChild.TryGetValue(childGuid, out relation); + + public void CopyPendingProjectionChildrenTo(List destination) + { + ArgumentNullException.ThrowIfNull(destination); + destination.Clear(); + foreach (uint childGuid in _stagedByChild.Keys) + destination.Add(childGuid); + foreach (uint childGuid in _recoveryByChild.Keys) + { + if (!_stagedByChild.ContainsKey(childGuid)) + destination.Add(childGuid); + } + } + + public bool IsCommitted(ParentAttachmentRelation relation) => + _lastAcceptedByChild.TryGetValue( + relation.ChildGuid, + out ParentAttachmentRelation committed) + && committed == relation; + + public bool IsPending( + ParentAttachmentRelation relation, + ParentProjectionCandidateKind kind) => + (kind is ParentProjectionCandidateKind.Staged + ? _stagedByChild + : _recoveryByChild).TryGetValue( + relation.ChildGuid, + out ParentAttachmentRelation pending) + && pending == relation; + + public void MarkProjected( + ParentAttachmentRelation relation, + ParentProjectionCandidateKind kind) + { + Dictionary source = + kind is ParentProjectionCandidateKind.Staged + ? _stagedByChild + : _recoveryByChild; + if (source.TryGetValue( + relation.ChildGuid, + out ParentAttachmentRelation pending) + && pending == relation) + { + source.Remove(relation.ChildGuid); + } + } + + public bool CommitProjection(ParentAttachmentRelation relation) + { + if (!_stagedByChild.TryGetValue( + relation.ChildGuid, + out ParentAttachmentRelation staged) + || staged != relation) + { + return false; + } + _lastAcceptedByChild[relation.ChildGuid] = relation; + _stagedByChild.Remove(relation.ChildGuid); + _recoveryByChild[relation.ChildGuid] = relation; + return true; + } + + public void RejectProjection(ParentAttachmentRelation relation) + { + if (_stagedByChild.TryGetValue( + relation.ChildGuid, + out ParentAttachmentRelation staged) + && staged == relation) + { + _stagedByChild.Remove(relation.ChildGuid); + } + } public bool RestoreLastAccepted(uint childGuid) { if (!_lastAcceptedByChild.TryGetValue(childGuid, out ParentAttachmentRelation relation)) return false; - _projectionByChild[childGuid] = relation; + _recoveryByChild[childGuid] = relation; return true; } public IReadOnlyList ChildrenWaitingForParent(uint parentGuid) { var result = new HashSet(); - foreach ((uint childGuid, ParentAttachmentRelation relation) in _projectionByChild) + foreach ((uint childGuid, ParentAttachmentRelation relation) in _stagedByChild) + { + if (relation.ParentGuid == parentGuid) + result.Add(childGuid); + } + + foreach ((uint childGuid, ParentAttachmentRelation relation) in _recoveryByChild) { if (relation.ParentGuid == parentGuid) result.Add(childGuid); @@ -136,11 +229,13 @@ public sealed class ParentAttachmentState public void RemoveObject(uint guid) { - _projectionByChild.Remove(guid); + _stagedByChild.Remove(guid); + _recoveryByChild.Remove(guid); _lastAcceptedByChild.Remove(guid); _unresolvedByChild.Remove(guid); - RemoveParentReferences(_projectionByChild, guid); + RemoveParentReferences(_stagedByChild, guid); + RemoveParentReferences(_recoveryByChild, guid); RemoveParentReferences(_lastAcceptedByChild, guid); uint[] children = _unresolvedByChild.Keys.ToArray(); @@ -165,9 +260,11 @@ public sealed class ParentAttachmentState FilterChildCandidates( guid, relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); - _projectionByChild.Remove(guid); + _stagedByChild.Remove(guid); + _recoveryByChild.Remove(guid); _lastAcceptedByChild.Remove(guid); - RemoveParentReferences(_projectionByChild, guid); + RemoveParentReferences(_stagedByChild, guid); + RemoveParentReferences(_recoveryByChild, guid); RemoveParentReferences(_lastAcceptedByChild, guid); FilterParentCandidates( guid, @@ -187,9 +284,11 @@ public sealed class ParentAttachmentState FilterChildCandidates( guid, relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); - _projectionByChild.Remove(guid); + _stagedByChild.Remove(guid); + _recoveryByChild.Remove(guid); _lastAcceptedByChild.Remove(guid); - RemoveParentReferences(_projectionByChild, guid); + RemoveParentReferences(_stagedByChild, guid); + RemoveParentReferences(_recoveryByChild, guid); RemoveParentReferences(_lastAcceptedByChild, guid); FilterParentCandidates( guid, @@ -204,13 +303,15 @@ public sealed class ParentAttachmentState /// public void EndChildProjection(uint childGuid) { - _projectionByChild.Remove(childGuid); + _stagedByChild.Remove(childGuid); + _recoveryByChild.Remove(childGuid); _lastAcceptedByChild.Remove(childGuid); } public void RemoveChild(uint childGuid) { - _projectionByChild.Remove(childGuid); + _stagedByChild.Remove(childGuid); + _recoveryByChild.Remove(childGuid); _lastAcceptedByChild.Remove(childGuid); _unresolvedByChild.Remove(childGuid); } @@ -218,7 +319,8 @@ public sealed class ParentAttachmentState public void Clear() { _unresolvedByChild.Clear(); - _projectionByChild.Clear(); + _stagedByChild.Clear(); + _recoveryByChild.Clear(); _lastAcceptedByChild.Clear(); } @@ -286,3 +388,9 @@ public enum ParentAttachmentWaitOwner Parent, Child, } + +public enum ParentProjectionCandidateKind +{ + Staged, + Recovery, +} diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 957d5525..bafdbdd7 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -216,6 +216,92 @@ public sealed class ShadowObjectRegistry CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f); } + /// + /// Replaces an existing live PartArray collision payload in its current + /// shadow-cell membership. Retail CPartArray::SetPart changes the + /// part read by later collision tests; it does not recalculate cross-cells. + /// A suspended owner updates only its retained payload. A newly shaped + /// owner may perform its first flood, then immediately suspend when its + /// canonical projection is Hidden, attached, or cell-less. + /// + public void ReplaceMultiPartPayload( + uint entityId, + Vector3 entityWorldPos, + Quaternion entityWorldRot, + System.Collections.Generic.IReadOnlyList shapes, + uint state, + EntityCollisionFlags flags, + float worldOffsetX, + float worldOffsetY, + uint landblockId, + uint seedCellId = 0u, + bool isStatic = false, + bool suspendIfNew = false) + { + if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior) + || !prior.IsMultiPart) + { + if (shapes.Count == 0) + return; + RegisterMultiPart( + entityId, + entityWorldPos, + entityWorldRot, + shapes, + state, + flags, + worldOffsetX, + worldOffsetY, + landblockId, + seedCellId, + isStatic); + if (suspendIfNew) + Suspend(entityId); + return; + } + + bool suspended = _suspendedEntities.Contains(entityId); + _entityShapes[entityId] = shapes; + _entityReg[entityId] = prior with + { + EntityWorldPos = entityWorldPos, + EntityWorldRot = entityWorldRot, + State = state, + Flags = flags, + }; + + if (suspended || !_entityToCells.TryGetValue(entityId, out List? cells)) + return; + + foreach (uint cellId in cells) + { + if (_cells.TryGetValue(cellId, out List? entries)) + entries.RemoveAll(entry => entry.EntityId == entityId); + } + + foreach (ShadowShape shape in shapes) + { + Vector3 partWorldPos = entityWorldPos + + Vector3.Transform(shape.LocalPosition, entityWorldRot); + Quaternion partWorldRot = entityWorldRot * shape.LocalRotation; + var entry = new ShadowEntry( + EntityId: entityId, + GfxObjId: shape.GfxObjId, + Position: partWorldPos, + Rotation: partWorldRot, + Radius: shape.Radius, + CollisionType: shape.CollisionType, + CylHeight: shape.CylHeight, + Scale: shape.Scale, + State: state, + Flags: flags, + LocalPosition: shape.LocalPosition, + LocalRotation: shape.LocalRotation); + foreach (uint cellId in cells) + AddEntryToCell(entry, cellId); + } + } + /// /// Retail flood-sphere rule (CylSphere overload, Ghidra 0x0052b9f0): /// when the object has cylinder shapes, each contributes one sphere at diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs index a0e28ff3..fcf3ec80 100644 --- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs +++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs @@ -50,11 +50,16 @@ public static class ShadowShapeBuilder /// placement frame per part (entities with no motion table, and the /// CylSphere/Sphere shapes, are unaffected — retail poses those from /// the setup too). + /// Current part identities after + /// retail AnimPartChanged processing. Collision keeps each Setup + /// index and pose, but reads PhysicsBSP from the installed replacement. + /// Null or short lists fall back to the Setup identity. public static IReadOnlyList FromSetup( Setup setup, float entScale, Func hasPhysicsBsp, - IReadOnlyList? partPoseOverride = null) + IReadOnlyList? partPoseOverride = null, + IReadOnlyList? effectivePartGfxObjIds = null) { if (setup is null) throw new ArgumentNullException(nameof(setup)); if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp)); @@ -102,7 +107,14 @@ public static class ShadowShapeBuilder AnimationFrame? placementFrame = ResolvePlacementFrame(setup); for (int i = 0; i < setup.Parts.Count; i++) { - uint gfxId = (uint)setup.Parts[i]; + // Retail CPhysicsPart::SetPart installs AnimPartChanged's current + // degrade array before CPartArray::FindObjCollisions reads it. + // Keep the stable Setup part index/pose, but source collision + // identity from that effective part when one was supplied. + uint gfxId = effectivePartGfxObjIds is not null + && i < effectivePartGfxObjIds.Count + ? effectivePartGfxObjIds[i] + : (uint)setup.Parts[i]; if (!hasPhysicsBsp(gfxId)) continue; Frame partFrame; diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 45f4e278..d39421ed 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -3505,7 +3505,7 @@ public sealed class Transition /// PathClipped movers + airborne head-sphere hits. Non-PerfectClip records the /// center-to-center collision normal and hard-stops (the M1.5 load-bearing /// path — players never set PerfectClip). PerfectClip gets the exact - /// time-of-impact reposition (missiles only — AP-84, dead in M1.5, ported per + /// time-of-impact reposition (missiles only — AP-91, dead in M1.5, ported per /// ACE Sphere.cs:175-210; re-verify vs Ghidra before missiles ship). /// private TransitionState SphereCollideWithPoint(ShadowEntry obj, SpherePath sp, @@ -3521,7 +3521,7 @@ public sealed class Transition return TransitionState.Collided; } - // PerfectClip exact time-of-impact (AP-84 — dead in M1.5). Block offset = 0. + // PerfectClip exact time-of-impact (AP-91 — dead in M1.5). Block offset = 0. Vector3 checkOffset = checkSphere.Origin - gCenter; double toi = FindSphereTimeOfCollision(checkOffset, globalOffset, radsum + PhysicsGlobals.EPSILON); if (toi < PhysicsGlobals.EPSILON || toi > 1.0) diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs new file mode 100644 index 00000000..e0648148 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs @@ -0,0 +1,358 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.World; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Physics; + +public sealed class LiveEntityCollisionBuilderTests +{ + private const uint Guid = 0x70000100u; + private const uint Cell = 0x01010001u; + + [Fact] + public void EffectivePartResolution_IsSetupAndVisualOptionIndependent() + { + uint[] resolved = LiveEntityCollisionBuilder.ResolveEffectivePartIdentities( + [0x01001000u, 0x01002000u], + id => id == 0x01001000u ? 0x01001001u : id); + + Assert.Equal([0x01001001u, 0x01002000u], resolved); + } + + [Fact] + public void RadiusFallback_UsesExactStateFlagsScaleAndFullSeedCell() + { + var setup = new Setup { Radius = 0.75f, Height = 2.5f }; + WorldSession.EntitySpawn spawn = Spawn( + scale: 2f, + itemType: (uint)ItemType.Creature, + descriptionFlags: 0x28u); + var record = new LiveEntityRecord(spawn) + { + FinalPhysicsState = PhysicsStateFlags.Hidden | PhysicsStateFlags.Static, + }; + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = Builder(); + + LiveEntityCollisionRegistration registration = Assert.IsType( + builder.Build(entity, setup, Array.Empty(), spawn, record, new Vector3(192f, -192f, 0f))); + + ShadowShape shape = Assert.Single(registration.Shapes); + Assert.Equal(ShadowCollisionType.Cylinder, shape.CollisionType); + Assert.Equal(1.5f, shape.Radius); + Assert.Equal(5f, shape.CylHeight); + Assert.Equal((uint)record.FinalPhysicsState, registration.State); + Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.HasWeenie)); + Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsCreature)); + Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsPlayer)); + Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsPK)); + Assert.Equal(Cell, registration.LandblockId); + Assert.Equal(Cell, registration.SeedCellId); + Assert.Equal((192f, -192f), (registration.WorldOffsetX, registration.WorldOffsetY)); + } + + [Fact] + public void BspOnlyPart_UsesRealScaledPhysicsBoundingRadius() + { + const uint part = 0x0100ABCDu; + var setup = new Setup(); + setup.Parts.Add(part); + WorldSession.EntitySpawn spawn = Spawn(scale: 1.5f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == part, + id => id == part ? 3f : null, + PoseResolver()); + + LiveEntityCollisionRegistration registration = Assert.IsType( + builder.Build(entity, setup, [part], spawn, record, Vector3.Zero)); + + ShadowShape shape = Assert.Single(registration.Shapes); + Assert.Equal(ShadowCollisionType.BSP, shape.CollisionType); + Assert.Equal(4.5f, shape.Radius); + Assert.Equal(part, shape.GfxObjId); + } + + [Fact] + public void EffectiveReplacementWithoutPhysicsBsp_RemovesBasePartCollision() + { + const uint basePart = 0x0100AB01u; + const uint replacement = 0x0100AB02u; + var setup = new Setup(); + setup.Parts.Add(basePart); + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == basePart, + _ => 1f, + PoseResolver()); + + Assert.Null(builder.Build( + entity, + setup, + [replacement], + spawn, + record, + Vector3.Zero)); + } + + [Fact] + public void EffectiveReplacementWithPhysicsBsp_AddsReplacementCollision() + { + const uint basePart = 0x0100AB11u; + const uint replacement = 0x0100AB12u; + var setup = new Setup(); + setup.Parts.Add(basePart); + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == replacement, + id => id == replacement ? 2.25f : null, + PoseResolver()); + + LiveEntityCollisionRegistration registration = + Assert.IsType(builder.Build( + entity, + setup, + [replacement], + spawn, + record, + Vector3.Zero)); + + ShadowShape shape = Assert.Single(registration.Shapes); + Assert.Equal(replacement, shape.GfxObjId); + Assert.Equal(2.25f, shape.Radius); + } + + [Fact] + public void ReconcileAppearance_VisibleReplacementWithoutBspRemovesOldPayload() + { + const uint basePart = 0x0100AC01u; + const uint replacement = 0x0100AC02u; + var setup = new Setup(); + setup.Parts.Add(basePart); + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == basePart, + _ => 1f, + PoseResolver()); + LiveEntityCollisionRegistration initial = + Assert.IsType(builder.Build( + entity, setup, [basePart], spawn, record, Vector3.Zero)); + var registry = new ShadowObjectRegistry(); + LiveEntityCollisionBuilder.Register(registry, initial); + + LiveEntityCollisionRegistration? changed = builder.Build( + entity, setup, [replacement], spawn, record, Vector3.Zero, + retainEmptyPayload: true); + LiveEntityCollisionBuilder.ReconcileAppearance( + registry, entity.Id, changed, suspendIfNew: false); + + Assert.Equal(1, registry.RetainedRegistrationCount); + Assert.DoesNotContain( + registry.GetObjectsInCell(Cell), + entry => entry.EntityId == entity.Id); + } + + [Fact] + public void ReconcileAppearance_SuspendedReplacementRetainsNewPayloadUntilRestore() + { + const uint basePart = 0x0100AC11u; + const uint replacement = 0x0100AC12u; + var setup = new Setup(); + setup.Parts.Add(basePart); + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == basePart || id == replacement, + _ => 1f, + PoseResolver()); + LiveEntityCollisionRegistration initial = + Assert.IsType(builder.Build( + entity, setup, [basePart], spawn, record, Vector3.Zero)); + var registry = new ShadowObjectRegistry(); + LiveEntityCollisionBuilder.Register(registry, initial); + Assert.True(registry.Suspend(entity.Id)); + LiveEntityCollisionRegistration changed = + Assert.IsType(builder.Build( + entity, setup, [replacement], spawn, record, Vector3.Zero)); + + LiveEntityCollisionBuilder.ReconcileAppearance( + registry, entity.Id, changed, suspendIfNew: true); + + Assert.Equal(1, registry.RetainedRegistrationCount); + Assert.Equal(1, registry.SuspendedRegistrationCount); + Assert.DoesNotContain( + registry.GetObjectsInCell(Cell), + entry => entry.EntityId == entity.Id); + + LiveEntityCollisionRegistration empty = + Assert.IsType(builder.Build( + entity, + setup, + [0x0100AC13u], + spawn, + record, + Vector3.Zero, + retainEmptyPayload: true)); + LiveEntityCollisionBuilder.ReconcileAppearance( + registry, entity.Id, empty, suspendIfNew: true); + LiveEntityCollisionBuilder.ReconcileAppearance( + registry, entity.Id, changed, suspendIfNew: true); + Assert.Equal(1, registry.RetainedRegistrationCount); + Assert.Equal(1, registry.SuspendedRegistrationCount); + + registry.UpdatePosition( + entity.Id, + entity.Position, + entity.Rotation, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: Cell, + seedCellId: Cell); + ShadowEntry restored = Assert.Single( + registry.GetObjectsInCell(Cell), + entry => entry.EntityId == entity.Id); + Assert.Equal(replacement, restored.GfxObjId); + Assert.Equal(0, registry.SuspendedRegistrationCount); + } + + [Fact] + public void ReconcileAppearance_ExistingMembershipDoesNotDependOnNewFlood() + { + const uint basePart = 0x0100AC21u; + const uint replacement = 0x0100AC22u; + var setup = new Setup(); + setup.Parts.Add(basePart); + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == basePart || id == replacement, + _ => 1f, + PoseResolver()); + LiveEntityCollisionRegistration initial = + Assert.IsType(builder.Build( + entity, setup, [basePart], spawn, record, Vector3.Zero)); + var registry = new ShadowObjectRegistry(); + LiveEntityCollisionBuilder.Register(registry, initial); + LiveEntityCollisionRegistration changed = + Assert.IsType(builder.Build( + entity, setup, [replacement], spawn, record, Vector3.Zero)) + with + { + LandblockId = 0u, + SeedCellId = 0u, + }; + + LiveEntityCollisionBuilder.ReconcileAppearance( + registry, entity.Id, changed, suspendIfNew: false); + + ShadowEntry entry = Assert.Single( + registry.GetObjectsInCell(Cell), + candidate => candidate.EntityId == entity.Id); + Assert.Equal(replacement, entry.GfxObjId); + } + + [Fact] + public void ShapelessSetup_ProducesNoRegistration() + { + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + + Assert.Null(Builder().Build( + entity, + new Setup(), + Array.Empty(), + spawn, + record, + Vector3.Zero)); + } + + [Fact] + public void Build_RejectsDifferentRecordIncarnation() + { + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var expected = new LiveEntityRecord(spawn); + var other = new LiveEntityRecord(spawn with { InstanceSequence = 2 }); + WorldEntity entity = Entity(); + expected.WorldEntity = entity; + other.WorldEntity = entity; + + Assert.Throws(() => Builder().Build( + entity, + new Setup { Radius = 1f }, + Array.Empty(), + spawn, + other, + Vector3.Zero)); + } + + private static LiveEntityCollisionBuilder Builder() => new( + _ => false, + _ => null, + PoseResolver()); + + private static LiveEntityDefaultPoseResolver PoseResolver() => new( + _ => null, + new NullAnimationLoader(), + dumpMotion: false); + + private static WorldEntity Entity() => new() + { + Id = 101u, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000100u, + Position = new Vector3(4f, 5f, 6f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = Cell, + }; + + private static WorldSession.EntitySpawn Spawn( + float scale, + uint? itemType = null, + uint? descriptionFlags = null) => + new( + Guid, + new CreateObject.ServerPosition(Cell, 4f, 5f, 6f, 1f, 0f, 0f, 0f), + 0x02000100u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: scale, + Name: "collision fixture", + ItemType: itemType, + MotionState: null, + MotionTableId: null, + PhysicsState: 0u, + ObjectDescriptionFlags: descriptionFlags, + InstanceSequence: 1); + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs new file mode 100644 index 00000000..5f1bf6e3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs @@ -0,0 +1,1151 @@ +using System.Collections; +using System.Numerics; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Content; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Rendering; + +public sealed class EquippedChildProjectionWithdrawalTests +{ + [Fact] + public void WithdrawalFailure_DoesNotPublishAttachedProjectionRemoval() + { + bool published = false; + + ExactProjectionWithdrawalOutcome outcome = + EquippedChildRenderController.WithdrawAttachedProjection( + ChildRecord(), + positionAuthorityVersion: 1, + projectionMutationVersion: 2, + (_, _, _) => new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("injected component cleanup failure")), + () => + { + published = true; + return true; + }); + + Assert.IsType(outcome.Failure); + Assert.Equal(ExactProjectionWithdrawalDisposition.Pending, outcome.Disposition); + Assert.False(published); + } + + [Fact] + public void ExactIncarnationRejection_DoesNotPublishAttachedProjectionRemoval() + { + bool published = false; + + ExactProjectionWithdrawalOutcome outcome = EquippedChildRenderController.WithdrawAttachedProjection( + ChildRecord(), + positionAuthorityVersion: 1, + projectionMutationVersion: 2, + (_, _, _) => new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Superseded, + Failure: null), + () => + { + published = true; + return true; + }); + + Assert.True(published); + Assert.Equal(ExactProjectionWithdrawalDisposition.Superseded, outcome.Disposition); + } + + [Fact] + public void SuccessfulWithdrawal_PublishesLocalProjectionRemovalOnce() + { + int publications = 0; + + ExactProjectionWithdrawalOutcome outcome = EquippedChildRenderController.WithdrawAttachedProjection( + ChildRecord(), + positionAuthorityVersion: 1, + projectionMutationVersion: 2, + (_, _, _) => new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null), + () => + { + publications++; + return true; + }); + + Assert.Equal(ExactProjectionWithdrawalDisposition.Completed, outcome.Disposition); + Assert.Equal(1, publications); + } + + [Fact] + public void LogicalReplacement_RemovesExactAttachedMapWithoutWithdrawingReplacement() + { + int withdrawals = 0; + using var fixture = new ControllerFixture( + (_, _, _) => + { + withdrawals++; + return new( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null); + }); + LiveEntityRecord parent = fixture.Spawn(0x70000200u, generation: 1); + LiveEntityRecord oldChild = fixture.Spawn( + 0x70000201u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, oldChild); + + LiveEntityRecord replacement = fixture.Live.RegisterLiveEntity( + ControllerFixture.SpawnData(0x70000201u, generation: 2)).Record!; + + Assert.Empty(fixture.Controller.AttachedEntityIds); + Assert.True(fixture.Live.TryGetRecord(0x70000201u, out LiveEntityRecord current)); + Assert.Same(replacement, current); + Assert.Equal(0, withdrawals); + } + + [Fact] + public void LogicalDelete_NotificationFailureLeavesRetryableTombstoneButNoAttachedLeak() + { + using var fixture = new ControllerFixture((_, _, _) => + new(ExactProjectionWithdrawalDisposition.Completed, Failure: null)); + LiveEntityRecord parent = fixture.Spawn(0x70000210u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000211u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + fixture.Controller.ProjectionRemoved += Throw; + + Assert.Throws(() => fixture.Live.UnregisterLiveEntity( + new DeleteObject.Parsed(0x70000211u, InstanceSequence: 1), + isLocalPlayer: false)); + Assert.Empty(fixture.Controller.AttachedEntityIds); + + fixture.Controller.ProjectionRemoved -= Throw; + Assert.Equal(1, fixture.Live.RetryPendingTeardowns()); + + static void Throw(uint _) => + throw new InvalidOperationException("injected projection observer failure"); + } + + [Fact] + public void PostCommitWithdrawalFailure_RetiresCapturedAttachedMap() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool committed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + Assert.True(committed); + return new( + ExactProjectionWithdrawalDisposition.Completed, + new InvalidOperationException("observer failed after commit")); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x70000220u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000221u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + + Assert.Throws(() => + fixture.Controller.OnChildBecameUnparented(0x70000221u)); + Assert.Empty(fixture.Controller.AttachedEntityIds); + Assert.False(child.IsSpatiallyProjected); + } + } + + [Fact] + public void PendingTopLevelWithdrawal_RetryRunsAcceptedContinuationOnce() + { + int attempts = 0; + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + attempts++; + if (attempts == 1) + { + return new( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("component cleanup failed")); + } + bool committed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + committed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord record = fixture.Spawn(0x70000230u, generation: 1); + int continuations = 0; + Assert.Throws(() => + fixture.Controller.OnChildBecameUnparented( + 0x70000230u, + () => + { + continuations++; + Assert.True(fixture.Live.RebucketLiveEntity( + 0x70000230u, + 0x01010001u)); + })); + + fixture.Controller.Tick(); + + Assert.Equal(2, attempts); + Assert.Equal(1, continuations); + Assert.True(record.IsSpatiallyProjected); + } + } + + [Fact] + public void PendingUnparent_NewerPositionSupersedesRecoveryContinuation() + { + using var fixture = new ControllerFixture((_, _, _) => + new( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("component cleanup failed"))); + LiveEntityRecord record = fixture.Spawn(0x70000240u, generation: 1); + int continuations = 0; + Assert.Throws(() => + fixture.Controller.OnChildBecameUnparented( + 0x70000240u, + () => continuations++)); + + var newer = new WorldSession.EntityPositionUpdate( + 0x70000240u, + new CreateObject.ServerPosition( + 0x01010001u, 4f, 5f, 6f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0); + Assert.True(fixture.Live.TryApplyPosition( + newer, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _)); + + fixture.Controller.Tick(); + + Assert.Equal(0, continuations); + Assert.True(record.IsSpatiallyProjected); + } + + [Fact] + public void AcceptedValidParent_WithdrawsWorldProjectionBeforePosePrerequisitesExist() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + fixture.Spawn(0x70000250u, generation: 1); + LiveEntityRecord child = fixture.Spawn(0x70000251u, generation: 1); + + fixture.Controller.OnParentEvent(new ParentEvent.Parsed( + ParentGuid: 0x70000250u, + ChildGuid: 0x70000251u, + ParentLocation: 0, + PlacementId: 0, + ParentInstanceSequence: 1, + ChildPositionSequence: 2)); + + Assert.False(child.IsSpatiallyProjected); + Assert.True(fixture.Live.ParentAttachments.TryGetProjection( + 0x70000251u, + out _)); + } + } + + [Fact] + public void InvalidParent_ConsumesPositionTimestampButPreservesWorldProjection() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + fixture.Spawn(0x70000252u, generation: 1); + LiveEntityRecord child = fixture.Spawn(0x70000253u, generation: 1); + + fixture.Controller.OnParentEvent(new ParentEvent.Parsed( + ParentGuid: 0x70000252u, + ChildGuid: 0x70000253u, + ParentLocation: 1, + PlacementId: 0, + ParentInstanceSequence: 1, + ChildPositionSequence: 2)); + + Assert.True(child.IsSpatiallyProjected); + Assert.NotEqual(0u, child.FullCellId); + Assert.True(fixture.Live.TryGetSnapshot( + child.ServerGuid, + out WorldSession.EntitySpawn snapshot)); + Assert.NotNull(snapshot.Position); + Assert.Null(snapshot.ParentGuid); + Assert.Equal((ushort)2, snapshot.PositionSequence); + Assert.False(fixture.Live.ParentAttachments.TryGetProjection( + child.ServerGuid, + out _)); + } + } + + [Fact] + public void QueuedValidThenInvalidParent_LeavesValidParentCommitted() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord validParent = fixture.Spawn(0x70000259u, generation: 1); + LiveEntityRecord invalidParent = fixture.Spawn(0x7000025Au, generation: 1); + const uint childGuid = 0x7000025Bu; + fixture.Controller.OnParentEvent(new ParentEvent.Parsed( + validParent.ServerGuid, childGuid, 0, 0, 1, 1)); + fixture.Controller.OnParentEvent(new ParentEvent.Parsed( + invalidParent.ServerGuid, childGuid, 1, 0, 1, 2)); + + LiveEntityRecord child = fixture.RegisterOnly( + childGuid, + generation: 1, + hasPosition: true); + fixture.Materialize(child); + Assert.True(fixture.Live.TryGetSnapshot( + childGuid, + out WorldSession.EntitySpawn childSpawn)); + fixture.Controller.OnSpawn(childSpawn); + + Assert.True(fixture.Live.TryGetSnapshot(childGuid, out childSpawn)); + Assert.Equal(validParent.ServerGuid, childSpawn.ParentGuid); + Assert.Null(childSpawn.Position); + Assert.Equal((ushort)2, childSpawn.PositionSequence); + var valid = new ParentAttachmentRelation( + validParent.ServerGuid, childGuid, 0, 0, 1, 1); + Assert.True(fixture.Live.ParentAttachments.IsCommitted(valid)); + Assert.False(fixture.Live.ParentAttachments.TryGetStagedProjection( + childGuid, + out _)); + } + } + + [Fact] + public void NoPositionCreateParent_CommitsAfterParentPartArrayValidation() + { + using var fixture = new ControllerFixture((_, _, _) => + new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null)); + LiveEntityRecord parent = fixture.Spawn(0x70000254u, generation: 1); + LiveEntityRecord child = fixture.RegisterOnly( + 0x70000255u, + generation: 1, + hasPosition: false); + var update = new CreateParentUpdate( + child.ServerGuid, + parent.ServerGuid, + ParentLocation: 0, + PlacementId: 0, + ChildInstanceSequence: 1, + ChildPositionSequence: 1); + + Assert.True(fixture.Live.TryApplyCreateParent(update, out _)); + fixture.Controller.OnCreateParentAccepted(update); + + Assert.True(fixture.Live.TryGetSnapshot( + child.ServerGuid, + out WorldSession.EntitySpawn snapshot)); + Assert.Equal(parent.ServerGuid, snapshot.ParentGuid); + Assert.Null(snapshot.Position); + Assert.Null(child.WorldEntity); + var relation = new ParentAttachmentRelation( + parent.ServerGuid, + child.ServerGuid, + 0, + 0, + 0, + 1); + Assert.True(fixture.Live.ParentAttachments.IsCommitted(relation)); + + fixture.Poses.Publish( + parent.WorldEntity!, + Array.Empty()); + fixture.Controller.OnPosePublished(parent.ServerGuid); + + Assert.NotNull(child.WorldEntity); + Assert.True(child.IsSpatiallyProjected); + Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind); + } + + [Fact] + public void ChildWithoutSetup_StillCommitsLogicalParenting() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x7000025Cu, generation: 1); + LiveEntityRecord child = fixture.RegisterOnly( + 0x7000025Du, + generation: 1, + hasPosition: true, + hasSetup: false); + fixture.Materialize(child); + child.HasPartArray = false; + var update = new ParentEvent.Parsed( + parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1); + + fixture.Controller.OnParentEvent(update); + + Assert.True(fixture.Live.TryGetSnapshot( + child.ServerGuid, + out WorldSession.EntitySpawn snapshot)); + Assert.Equal(parent.ServerGuid, snapshot.ParentGuid); + Assert.Null(snapshot.Position); + Assert.False(child.IsSpatiallyProjected); + Assert.True(fixture.Live.ParentAttachments.IsCommitted(new( + parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1))); + } + } + + [Fact] + public void EmbeddedSameGenerationCreateParent_UsesStagedRoute() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x7000025Eu, generation: 1); + LiveEntityRecord child = fixture.Spawn(0x7000025Fu, generation: 1); + PhysicsTimestamps timestamps = new( + Position: 1, + Movement: 0, + State: 0, + Vector: 0, + Teleport: 0, + ServerControlledMove: 0, + ForcePosition: 0, + ObjDesc: 0, + Instance: 1); + PhysicsSpawnData physics = new( + RawState: 0x408u, + Position: null, + Movement: null, + AnimationFrame: 0, + SetupTableId: 0x02000001u, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: new PhysicsAttachment(parent.ServerGuid, 0), + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + WorldSession.EntitySpawn incoming = ControllerFixture.SpawnData( + child.ServerGuid, + generation: 1) with + { + Position = null, + PositionSequence = 1, + PlacementId = 0, + Physics = physics, + }; + + LiveEntityRegistrationResult refresh = fixture.Live.RegisterLiveEntity(incoming); + CreateParentUpdate update = Assert.IsType( + refresh.Inbound.SameGenerationEvents!.Value.Parent); + Assert.True(fixture.Live.TryApplyCreateParent(update, out _)); + fixture.Controller.OnCreateParentAccepted(update); + + Assert.True(fixture.Live.TryGetSnapshot( + child.ServerGuid, + out WorldSession.EntitySpawn snapshot)); + Assert.Equal(parent.ServerGuid, snapshot.ParentGuid); + Assert.Null(snapshot.Position); + } + } + + [Fact] + public void NewWaitingParent_DoesNotDisplaceCommittedRecovery() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord oldParent = fixture.Spawn(0x70000256u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000257u, + generation: 1, + LiveEntityProjectionKind.Attached); + LiveEntityRecord waitingParent = fixture.RegisterOnly( + 0x70000258u, + generation: 1, + hasPosition: true); + var oldRelation = new ParentAttachmentRelation( + oldParent.ServerGuid, + child.ServerGuid, + 0, + 0, + 1, + 1); + fixture.Live.ParentAttachments.AcceptCreateObjectRelation(oldRelation); + Assert.True(fixture.Live.TryApplyParent(new ParentEvent.Parsed( + oldRelation.ParentGuid, + oldRelation.ChildGuid, + oldRelation.ParentLocation, + oldRelation.PlacementId, + oldRelation.ParentInstanceSequence, + oldRelation.ChildPositionSequence), out _)); + Assert.True(fixture.Live.CommitStagedParent(oldRelation, out _)); + Assert.True(fixture.Live.ParentAttachments.CommitProjection(oldRelation)); + fixture.Live.ParentAttachments.MarkProjected( + oldRelation, + ParentProjectionCandidateKind.Recovery); + fixture.InstallAttached(oldParent, child); + + var newer = new ParentEvent.Parsed( + waitingParent.ServerGuid, + child.ServerGuid, + ParentLocation: 0, + PlacementId: 0, + ParentInstanceSequence: 1, + ChildPositionSequence: 2); + fixture.Controller.OnParentEvent(newer); + Assert.True(fixture.Live.ParentAttachments.TryGetStagedProjection( + child.ServerGuid, + out ParentAttachmentRelation staged)); + Assert.Equal(waitingParent.ServerGuid, staged.ParentGuid); + + fixture.Controller.Tick(); + + Assert.Empty(fixture.Controller.AttachedEntityIds); + Assert.True(fixture.Live.ParentAttachments.TryGetStagedProjection( + child.ServerGuid, + out staged)); + Assert.Equal(waitingParent.ServerGuid, staged.ParentGuid); + Assert.True(fixture.Live.ParentAttachments.TryGetRecoveryProjection( + child.ServerGuid, + out ParentAttachmentRelation recovery)); + Assert.Equal(oldParent.ServerGuid, recovery.ParentGuid); + + fixture.Materialize(waitingParent); + fixture.Controller.OnWorldEntityRegistered(waitingParent.ServerGuid); + + Assert.True(fixture.Live.ParentAttachments.IsCommitted(staged)); + Assert.True(fixture.Live.ParentAttachments.TryGetRecoveryProjection( + child.ServerGuid, + out ParentAttachmentRelation committedRecovery)); + Assert.Equal(staged, committedRecovery); + Assert.True(fixture.Live.TryGetSnapshot( + child.ServerGuid, + out WorldSession.EntitySpawn snapshot)); + Assert.Equal(waitingParent.ServerGuid, snapshot.ParentGuid); + } + } + + [Fact] + public void OrdinaryRemoval_PendingFailureRetriesExactCaptureOnTick() + { + int attempts = 0; + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + attempts++; + if (attempts == 1) + { + return new( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("ordinary cleanup failed")); + } + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x70000260u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000261u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + fixture.Live.ParentAttachments.AcceptCreateObjectRelation(new( + parent.ServerGuid, + child.ServerGuid, + ParentLocation: 0, + PlacementId: 0, + ParentInstanceSequence: 1, + ChildPositionSequence: 1)); + + TargetInvocationException error = Assert.Throws(() => + fixture.InvokeOrdinaryRemoval(0x70000261u)); + Assert.IsType(error.InnerException); + + fixture.Controller.Tick(); + + Assert.Equal(2, attempts); + Assert.Empty(fixture.Controller.AttachedEntityIds); + Assert.False(child.IsSpatiallyProjected); + Assert.False(fixture.Live.ParentAttachments.TryGetProjection( + child.ServerGuid, + out _)); + } + } + + [Fact] + public void DetachedRemoval_PendingFailureRetriesAndPreservesRollbackHistory() + { + int attempts = 0; + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + attempts++; + if (attempts == 1) + { + return new( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("detached cleanup failed")); + } + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x70000262u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000263u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + var relation = new ParentAttachmentRelation( + parent.ServerGuid, + child.ServerGuid, + ParentLocation: 0, + PlacementId: 0, + ParentInstanceSequence: 1, + ChildPositionSequence: 1); + fixture.Live.ParentAttachments.AcceptCreateObjectRelation(relation); + Assert.True(fixture.Live.ParentAttachments.CommitProjection(relation)); + fixture.Live.ParentAttachments.MarkProjected( + relation, + ParentProjectionCandidateKind.Recovery); + + TargetInvocationException error = Assert.Throws(() => + fixture.InvokeDetachedRemoval(child.ServerGuid)); + Assert.IsType(error.InnerException); + + fixture.Controller.Tick(); + + Assert.Equal(2, attempts); + Assert.Empty(fixture.Controller.AttachedEntityIds); + Assert.False(child.IsSpatiallyProjected); + Assert.True(fixture.Live.ParentAttachments.RestoreLastAccepted( + child.ServerGuid)); + Assert.True(fixture.Live.ParentAttachments.TryGetRecoveryProjection( + child.ServerGuid, + out ParentAttachmentRelation restored)); + Assert.Equal(relation, restored); + } + } + + [Fact] + public void Unparent_WithdrawsCompleteAttachedSubtreeAndRecoversDescendants() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x70000264u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000265u, + generation: 1, + LiveEntityProjectionKind.Attached); + LiveEntityRecord grandchild = fixture.Spawn( + 0x70000266u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + fixture.InstallAttached(child, grandchild); + var childRelation = new ParentAttachmentRelation( + parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1); + var grandchildRelation = new ParentAttachmentRelation( + child.ServerGuid, grandchild.ServerGuid, 0, 0, 1, 1); + fixture.CommitRenderedRelation(childRelation); + fixture.CommitRenderedRelation(grandchildRelation); + + ChildUnparentDisposition result = + fixture.Controller.OnChildBecameUnparented(child.ServerGuid); + + Assert.Equal(ChildUnparentDisposition.Completed, result); + Assert.Empty(fixture.Controller.AttachedEntityIds); + Assert.False(child.IsSpatiallyProjected); + Assert.False(grandchild.IsSpatiallyProjected); + Assert.False(fixture.Live.ParentAttachments.RestoreLastAccepted( + child.ServerGuid)); + Assert.True(fixture.Live.ParentAttachments.TryGetRecoveryProjection( + grandchild.ServerGuid, + out ParentAttachmentRelation recovered)); + Assert.Equal(grandchildRelation, recovered); + } + } + + [Fact] + public void PendingDetachedRemoval_RollbackCancelsRetryAndKeepsProjection() + { + int attempts = 0; + using var fixture = new ControllerFixture((_, _, _) => + { + attempts++; + return new( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("component withdrawal failed")); + }); + LiveEntityRecord parent = fixture.Spawn(0x70000267u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000268u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + fixture.CommitRenderedRelation(new( + parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1)); + fixture.Poses.Publish(parent.WorldEntity!, Array.Empty()); + fixture.Objects.AddOrUpdate(new ClientObject + { + ObjectId = child.ServerGuid, + WielderId = parent.ServerGuid, + CurrentlyEquippedLocation = EquipMask.MeleeWeapon, + }); + + Assert.Throws(() => + fixture.Objects.MoveItemOptimistic( + child.ServerGuid, + newContainerId: 0x50000001u, + newSlot: 0)); + Assert.True(fixture.Objects.RollbackMove(child.ServerGuid)); + fixture.Controller.Tick(); + + Assert.Equal(1, attempts); + Assert.True(child.IsSpatiallyProjected); + Assert.Single(fixture.Controller.AttachedEntityIds); + } + + [Fact] + public void OrphanRollback_PendingFailureRetainsExactRetryOwner() + { + int attempts = 0; + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + attempts++; + if (attempts == 1) + { + return new( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("orphan component cleanup failed")); + } + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord orphan = fixture.Spawn(0x70000269u, generation: 1); + + TargetInvocationException error = Assert.Throws(() => + fixture.InvokeOrphanRemoval(orphan)); + Assert.IsType(error.InnerException); + + fixture.Controller.Tick(); + + Assert.Equal(2, attempts); + Assert.False(orphan.IsSpatiallyProjected); + } + } + + [Fact] + public void RecoveryContinuation_PostCommitFailureIsNotReplayed() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord record = fixture.Spawn(0x70000270u, generation: 1); + int continuations = 0; + Assert.Throws(() => + fixture.Controller.OnChildBecameUnparented( + 0x70000270u, + () => + { + continuations++; + Assert.True(fixture.Live.RebucketLiveEntity( + 0x70000270u, + 0x01010001u)); + throw new InvalidOperationException("observer failed after recovery"); + })); + + fixture.Controller.Tick(); + + Assert.Equal(1, continuations); + Assert.True(record.IsSpatiallyProjected); + } + } + + private static LiveEntityRecord ChildRecord() => new( + new WorldSession.EntitySpawn( + 0x70000100u, + new CreateObject.ServerPosition( + 0x01010001u, 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: 1)); + + private sealed class ControllerFixture : IDisposable + { + private const uint Cell = 0x01010001u; + private readonly DeferredLiveEntityRuntimeComponentLifecycle _lifecycle = new(); + private readonly Setup _setup; + + internal ControllerFixture( + Func + withdraw) + { + Spatial.AddLandblock(new LoadedLandblock( + (Cell & 0xFFFF0000u) | 0xFFFFu, + new LandBlock(), + Array.Empty())); + Live = new LiveEntityRuntime( + Spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), + _lifecycle); + _setup = new Setup + { + HoldingLocations = + { + [(ParentLocation)0] = new LocationType + { + PartId = -1, + Frame = new Frame { Orientation = Quaternion.Identity }, + }, + }, + }; + IDatReaderWriter dat = DispatchProxy.Create(); + ((NullDatProxy)(object)dat).Setup = _setup; + Controller = new EquippedChildRenderController( + dat, + new object(), + Objects, + Live, + Poses, + update => Live.TryApplyParent(update, out _), + withdraw); + _lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle( + Controller.OnLogicalTeardown)); + } + + internal GpuWorldState Spatial { get; } = new(); + internal ClientObjectTable Objects { get; } = new(); + internal EntityEffectPoseRegistry Poses { get; } = new(); + internal LiveEntityRuntime Live { get; } + internal EquippedChildRenderController Controller { get; } + + internal LiveEntityRecord Spawn( + uint guid, + ushort generation, + LiveEntityProjectionKind kind = LiveEntityProjectionKind.World) + { + LiveEntityRecord record = RegisterOnly(guid, generation, hasPosition: true); + Materialize(record, kind); + return record; + } + + internal LiveEntityRecord RegisterOnly( + uint guid, + ushort generation, + bool hasPosition, + bool hasSetup = true) + { + WorldSession.EntitySpawn spawn = SpawnData(guid, generation); + if (!hasPosition) + spawn = spawn with { Position = null }; + if (!hasSetup) + spawn = spawn with { SetupTableId = null }; + return Live.RegisterLiveEntity(spawn).Record!; + } + + internal void Materialize( + LiveEntityRecord record, + LiveEntityProjectionKind kind = LiveEntityProjectionKind.World) + { + Live.MaterializeLiveEntity( + record.ServerGuid, + Cell, + id => new WorldEntity + { + Id = id, + ServerGuid = record.ServerGuid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = Cell, + }, + kind); + record.HasPartArray = true; + } + + 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 void InstallAttached( + LiveEntityRecord parent, + LiveEntityRecord child) + { + Type attachedType = typeof(EquippedChildRenderController).GetNestedType( + "AttachedChild", + BindingFlags.NonPublic)!; + object attached = Activator.CreateInstance( + attachedType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: + [ + parent, + child, + parent.ServerGuid, + child.ServerGuid, + (ParentLocation)0, + (Placement)0, + _setup, + _setup, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + 1f, + child.WorldEntity!, + ], + culture: null)!; + FieldInfo mapField = typeof(EquippedChildRenderController).GetField( + "_attachedByChild", + BindingFlags.Instance | BindingFlags.NonPublic)!; + var map = (IDictionary)mapField.GetValue(Controller)!; + map.Add(child.ServerGuid, attached); + } + + internal void CommitRenderedRelation(ParentAttachmentRelation relation) + { + Live.ParentAttachments.AcceptCreateObjectRelation(relation); + Assert.True(Live.ParentAttachments.CommitProjection(relation)); + Live.ParentAttachments.MarkProjected( + relation, + ParentProjectionCandidateKind.Recovery); + } + + internal void InvokeOrdinaryRemoval(uint guid) + { + MethodInfo method = typeof(EquippedChildRenderController).GetMethod( + "TearDownCurrentObjectProjections", + BindingFlags.Instance | BindingFlags.NonPublic)!; + method.Invoke(Controller, [guid]); + } + + internal void InvokeDetachedRemoval(uint guid) + { + MethodInfo method = typeof(EquippedChildRenderController).GetMethod( + "BeginDetachedRemoval", + BindingFlags.Instance | BindingFlags.NonPublic)!; + method.Invoke(Controller, [guid]); + } + + internal void InvokeOrphanRemoval(LiveEntityRecord record) + { + FieldInfo mapField = typeof(EquippedChildRenderController).GetField( + "_pendingOrphanRemovalByChild", + BindingFlags.Instance | BindingFlags.NonPublic)!; + object map = mapField.GetValue(Controller)!; + MethodInfo method = typeof(EquippedChildRenderController).GetMethod( + "BeginProjectionSubtreeWithdrawal", + BindingFlags.Instance | BindingFlags.NonPublic)!; + method.Invoke(Controller, [map, record, false, false]); + } + + public void Dispose() => Controller.Dispose(); + } + + private class NullDatProxy : DispatchProxy + { + internal Setup? Setup { get; set; } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod?.Name == "Get" + && targetMethod.ReturnType == typeof(Setup)) + { + return Setup; + } + if (targetMethod?.ReturnType == typeof(void)) + return null; + if (targetMethod?.ReturnType.IsValueType == true) + return Activator.CreateInstance(targetMethod.ReturnType); + return null; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs index 4dcda425..0c690c23 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs @@ -1,5 +1,10 @@ using System.Numerics; using AcDream.App.Rendering; +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.World; using DatReaderWriter.DBObjs; @@ -8,6 +13,50 @@ namespace AcDream.App.Tests.Rendering; public sealed class LiveAppearanceAnimationTests { + [Fact] + public void Capture_UsesCanonicalRecordEntityAndAnimationOwner() + { + const uint guid = 0x70000002u; + var runtime = new AcDream.App.World.LiveEntityRuntime( + new AcDream.App.Streaming.GpuWorldState(), + new AcDream.App.World.DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + var record = runtime.RegisterLiveEntity(new AcDream.Core.Net.WorldSession.EntitySpawn( + guid, + Position: null, + SetupTableId: null, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "appearance fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: 1)).Record!; + WorldEntity entity = Entity(0x70000002u, 0x01000001u); + var state = new LiveEntityAnimationState + { + Entity = entity, + Setup = new Setup(), + Animation = new Animation(), + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = 1f, + PartTemplate = Array.Empty(), + PartAvailability = Array.Empty(), + }; + record.WorldEntity = entity; + record.AnimationRuntime = state; + + LiveEntityAppearanceUpdateState captured = Assert.IsType( + LiveEntityAppearanceBinding.Capture(runtime, guid)); + + Assert.Same(entity, captured.Entity); + Assert.Same(state, captured.Animation); + } + [Fact] public void RebindAppearance_PreservesEntitySequencerAndPlaybackState() { @@ -36,7 +85,7 @@ public sealed class LiveAppearanceAnimationTests ]; oldEntity.ApplyAppearance(dressedParts, paletteOverride: null, partOverrides: []); - GameWindow.RebindAnimatedEntityForAppearance( + LiveEntityAppearanceBinding.RebindAnimation( state, oldEntity, setup, @@ -57,6 +106,88 @@ public sealed class LiveAppearanceAnimationTests state.PartTemplate.Select(part => part.GfxObjId)); } + [Fact] + public void CollisionCommit_DelayedOldIncarnationCannotMutateGuidReplacement() + { + const uint guid = 0x70000010u; + const uint cell = 0x01010001u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new LandBlock(), + Array.Empty())); + var runtime = new LiveEntityRuntime( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + WorldSession.EntitySpawn first = Spawn(guid, cell, instance: 1); + runtime.RegisterLiveEntity(first); + WorldEntity oldEntity = runtime.MaterializeLiveEntity( + guid, + cell, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000010u, + Position = Vector3.One, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = cell, + })!; + var registry = new ShadowObjectRegistry(); + registry.Register( + oldEntity.Id, + 0x01000010u, + oldEntity.Position, + oldEntity.Rotation, + radius: 0.5f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: cell, + seedCellId: cell, + isStatic: false); + var builder = new LiveEntityCollisionBuilder( + _ => false, + _ => null, + new LiveEntityDefaultPoseResolver( + _ => null, + new NullLoader(), + dumpMotion: false)); + LiveEntityAppearanceCollisionUpdate update = + Assert.IsType( + LiveEntityAppearanceBinding.PrepareCollision( + runtime, + builder, + oldEntity, + new Setup(), + Array.Empty(), + first, + Vector3.Zero)); + + runtime.RegisterLiveEntity(Spawn(guid, cell, instance: 2)); + + Assert.False(LiveEntityAppearanceBinding.CommitCollision( + runtime, + registry, + update)); + Assert.Equal(1, registry.RetainedRegistrationCount); + } + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell, ushort instance) => new( + guid, + new CreateObject.ServerPosition(cell, 1f, 1f, 1f, 1f, 0f, 0f, 0f), + 0x02000010u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "appearance collision fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: instance); + private static WorldEntity Entity(uint id, uint gfxObjId) => new() { Id = id, diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs index 7c1dd26d..334d7dc3 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs @@ -134,7 +134,7 @@ public sealed class LiveEntityAnimationPresenterTests replacementSetup.DefaultScale.Add(Vector3.One); fixture.Entity.MeshRefs = [new MeshRef(0x01000009u, Matrix4x4.Identity)]; fixture.Entity.SetIndexedPartPoses([Matrix4x4.Identity], [true]); - GameWindow.RebindAnimatedEntityForAppearance( + LiveEntityAppearanceBinding.RebindAnimation( fixture.State, fixture.Entity, replacementSetup, @@ -169,7 +169,7 @@ public sealed class LiveEntityAnimationPresenterTests var replacement = new Setup(); replacement.Parts.Add(0x01000099u); replacement.DefaultScale.Add(Vector3.One); - GameWindow.RebindAnimatedEntityForAppearance( + LiveEntityAppearanceBinding.RebindAnimation( fixture.State, fixture.Entity, replacement, diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs index 4b36fee5..61d258fc 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs @@ -453,6 +453,78 @@ public sealed class EntitySpawnAdapterLifetimeTests Assert.Equal(0, meshes.TotalReferenceCount); } + [Fact] + public void AppearanceChange_AfterPublicationRunsBeforeOldRetirement() + { + const uint guid = 0x7400C100u; + const ulong oldMesh = 0x01000A10u; + const ulong newMesh = 0x01000A11u; + var meshes = new CallbackMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new RecordingTextureLifetime(), _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(152u, guid); + entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)]; + adapter.OnCreate(entity); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + meshes.Operations.Clear(); + MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)]; + + Assert.True(adapter.OnAppearanceChanged( + entity, + replacement, + [], + () => + { + meshes.Operations.Add("publish"); + entity.ApplyAppearance(replacement, paletteOverride: null, []); + }, + () => + { + Assert.Same(replacement, entity.MeshRefs); + Assert.Equal(1, meshes.ReferenceCounts[oldMesh]); + Assert.Equal(1, meshes.ReferenceCounts[newMesh]); + meshes.Operations.Add("after-publication"); + })); + + int afterPublication = meshes.Operations.IndexOf("after-publication"); + Assert.True(meshes.Operations.IndexOf("publish") < afterPublication); + Assert.True(meshes.Operations.IndexOf($"decrement:{oldMesh:X8}") > afterPublication); + Assert.True(adapter.OnRemove(entity)); + Assert.Equal(0, meshes.TotalReferenceCount); + } + + [Fact] + public void AppearanceChange_AfterPublicationFailureKeepsCommittedSetAndRetryRetiresOld() + { + const uint guid = 0x7400C200u; + const ulong oldMesh = 0x01000A20u; + const ulong newMesh = 0x01000A21u; + var meshes = new RefCountingMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new RecordingTextureLifetime(), _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(153u, guid); + entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)]; + adapter.OnCreate(entity); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)]; + + Assert.Throws(() => adapter.OnAppearanceChanged( + entity, + replacement, + [], + () => entity.ApplyAppearance(replacement, paletteOverride: null, []), + () => throw new InvalidOperationException("injected dependent publication failure"))); + + Assert.Same(replacement, entity.MeshRefs); + Assert.Equal(1, meshes.ReferenceCounts[oldMesh]); + Assert.Equal(1, meshes.ReferenceCounts[newMesh]); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh)); + Assert.Equal(1, meshes.ReferenceCounts[newMesh]); + Assert.True(adapter.OnRemove(entity)); + Assert.Equal(0, meshes.TotalReferenceCount); + } + [Fact] public void AppearanceChange_WhileSuspended_OnlyReplacementSetIsAcquiredOnResume() { diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs new file mode 100644 index 00000000..7cf0d0c6 --- /dev/null +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -0,0 +1,51 @@ +using System.Collections; +using System.Reflection; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.World; + +namespace AcDream.App.Tests.World; + +public sealed class GameWindowLiveEntityCompositionTests +{ + private const BindingFlags PrivateImplementation = + BindingFlags.Instance | BindingFlags.Static | BindingFlags.NonPublic; + + [Theory] + [InlineData("RegisterLiveEntityCollision")] + [InlineData("MotionTableDefaultPose")] + [InlineData("LeaveWorldLiveEntityRuntimeComponents")] + [InlineData("WithdrawLiveEntityWorldProjection")] + [InlineData("RebindAnimatedEntityForAppearance")] + public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName) + { + Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation)); + } + + [Fact] + public void GameWindow_DoesNotOwnAnAppearanceUpdateStateType() + { + Assert.Null(typeof(GameWindow).GetNestedType( + "AppearanceUpdateState", + BindingFlags.NonPublic)); + } + + [Theory] + [InlineData(typeof(LiveEntityCollisionBuilder))] + [InlineData(typeof(LiveEntityDefaultPoseResolver))] + [InlineData(typeof(LiveEntityProjectionWithdrawalController))] + [InlineData(typeof(LocalPlayerShadowState))] + [InlineData(typeof(LiveEntityAppearanceBinding))] + public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType) + { + foreach (FieldInfo field in helperType.GetFields( + BindingFlags.Instance | BindingFlags.Static | BindingFlags.NonPublic)) + { + Assert.False(typeof(IDictionary).IsAssignableFrom(field.FieldType), + $"{helperType.Name}.{field.Name} must resolve identity through LiveEntityRuntime."); + string typeName = field.FieldType.FullName ?? field.FieldType.Name; + Assert.DoesNotContain("Silk.NET", typeName, StringComparison.Ordinal); + Assert.DoesNotContain("OpenGL", typeName, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs b/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs index 85f5dbe3..5bcaa934 100644 --- a/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs +++ b/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs @@ -208,7 +208,7 @@ public sealed class InboundPhysicsStateControllerTests } [Fact] - public void ParentEventWaitsForParent_ThenConsumesChildPositionOnce() + public void ParentEventWaitsForParent_ThenStagesTimestampUntilValidatedCommit() { var controller = new InboundPhysicsStateController(); controller.AcceptCreate(Spawn( @@ -219,7 +219,17 @@ public sealed class InboundPhysicsStateControllerTests Assert.False(controller.TryApplyParent(update, out _)); controller.AcceptCreate(Spawn( 0x70000004u, 9, 1, 1, Position(0x0101FFFFu, 15f), 0x408u)); - Assert.True(controller.TryApplyParent(update, out WorldSession.EntitySpawn attached)); + Assert.True(controller.TryApplyParent(update, out WorldSession.EntitySpawn staged)); + Assert.Null(staged.ParentGuid); + Assert.NotNull(staged.Position); + Assert.Equal((ushort)5, staged.PositionSequence); + Assert.True(controller.TryCommitParent( + update.ChildGuid, + update.ParentGuid, + update.ParentLocation, + update.PlacementId, + update.ChildPositionSequence, + out WorldSession.EntitySpawn attached)); Assert.Equal(0x70000004u, attached.ParentGuid); Assert.Null(attached.Position); Assert.False(controller.TryApplyParent(update, out _)); diff --git a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs new file mode 100644 index 00000000..20195c3d --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs @@ -0,0 +1,289 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; +using AcDream.Core.World; +using AcDream.Plugin.Abstractions; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.World; + +public sealed class LiveEntityProjectionWithdrawalControllerTests +{ + private const uint Guid = 0x70000200u; + private const uint Cell = 0x01010001u; + + [Fact] + public void ComponentFailure_StopsBeforeCanonicalWithdrawal_AndRetryConverges() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + fixture.Poses.EffectPoseChanged += ThrowOnPoseChange; + + Assert.Throws(() => + fixture.Controller.Withdraw(Guid, localPlayerGuid: 0u)); + + Assert.True(record.IsSpatiallyProjected); + Assert.Empty(fixture.GameState.Entities); + Assert.Equal(0, fixture.Poses.Count); + Assert.DoesNotContain( + fixture.Physics.ShadowObjects.GetObjectsInCell(Cell), + entry => entry.EntityId == record.WorldEntity!.Id); + Assert.Equal(1, fixture.Physics.ShadowObjects.SuspendedRegistrationCount); + + fixture.Poses.EffectPoseChanged -= ThrowOnPoseChange; + Assert.True(fixture.Controller.Withdraw(Guid, localPlayerGuid: 0u)); + Assert.False(record.IsSpatiallyProjected); + + static void ThrowOnPoseChange(uint _) => + throw new InvalidOperationException("fixture pose observer failure"); + } + + [Fact] + public void RuntimeObserverFailure_CommitsCanonicalWithdrawalAfterComponentsRemoved() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + fixture.Live.ProjectionVisibilityChanged += ThrowOnHidden; + + Assert.ThrowsAny(() => + fixture.Controller.Withdraw(Guid, localPlayerGuid: 0u)); + + Assert.False(record.IsSpatiallyProjected); + Assert.False(record.IsSpatiallyVisible); + Assert.Empty(fixture.GameState.Entities); + Assert.Equal(0, fixture.Poses.Count); + + static void ThrowOnHidden(LiveEntityRecord _, bool visible) + { + if (!visible) + throw new InvalidOperationException("fixture runtime observer failure"); + } + } + + [Fact] + public void OrdinaryLeaveWorld_SuspendsRetainedShadow_AndReentryRestoresIt() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + uint localId = record.WorldEntity!.Id; + Assert.Contains( + fixture.Physics.ShadowObjects.GetObjectsInCell(Cell), + entry => entry.EntityId == localId); + + Assert.True(fixture.Controller.Withdraw(Guid, localPlayerGuid: 0u)); + Assert.DoesNotContain( + fixture.Physics.ShadowObjects.GetObjectsInCell(Cell), + entry => entry.EntityId == localId); + + Assert.True(fixture.Live.RebucketLiveEntity(Guid, Cell)); + Assert.Contains( + fixture.Physics.ShadowObjects.GetObjectsInCell(Cell), + entry => entry.EntityId == localId); + } + + [Fact] + public void ComponentCallbackReplacesGuid_DoesNotWithdrawReplacement() + { + var fixture = new Fixture(); + LiveEntityRecord old = fixture.Spawn(instance: 1); + LiveEntityRecord? replacement = null; + bool replacing = false; + fixture.LocalShadow.Set(Vector3.One, Quaternion.Identity, Cell); + fixture.Poses.EffectPoseChanged += OnPoseChanged; + + Assert.False(fixture.Controller.Withdraw(Guid, localPlayerGuid: Guid)); + + Assert.NotNull(replacement); + Assert.True(fixture.Live.TryGetRecord(Guid, out LiveEntityRecord current)); + Assert.Same(replacement, current); + Assert.True(current.IsSpatiallyProjected); + Assert.Equal(new Vector3(9f, 9f, 9f), current.WorldEntity!.Position); + Assert.NotNull(fixture.LocalShadow.Current); + Assert.NotSame(old, current); + + void OnPoseChanged(uint localId) + { + if (replacement is not null || replacing || localId != old.WorldEntity!.Id) + return; + replacing = true; + replacement = fixture.Spawn(instance: 2, position: new Vector3(9f, 9f, 9f)); + fixture.LocalShadow.Set(new Vector3(9f), Quaternion.Identity, Cell); + } + } + + [Fact] + public void ComponentCallbackReprojectsSameIncarnation_DoesNotWithdrawNewPosition() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + bool reentered = false; + fixture.Poses.EffectPoseChanged += OnPoseChanged; + + Assert.False(fixture.Controller.Withdraw(Guid, localPlayerGuid: 0u)); + + Assert.True(record.IsSpatiallyProjected); + Assert.True(record.IsSpatiallyVisible); + Assert.Equal(new Vector3(8f, 7f, 6f), record.WorldEntity!.Position); + Assert.Contains(fixture.GameState.Entities, entity => entity.Id == record.WorldEntity.Id); + Assert.Equal(1, fixture.Poses.Count); + + void OnPoseChanged(uint localId) + { + if (reentered || localId != record.WorldEntity!.Id) + return; + reentered = true; + var position = new WorldSession.EntityPositionUpdate( + Guid, + new CreateObject.ServerPosition( + Cell, 8f, 7f, 6f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0); + Assert.True(fixture.Live.TryApplyPosition( + position, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn accepted, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + record.WorldEntity.SetPosition(new Vector3(8f, 7f, 6f)); + Assert.True(fixture.Live.RebucketLiveEntity( + Guid, + accepted.Position!.Value.LandblockId)); + var snapshot = new WorldEntitySnapshot( + record.WorldEntity.Id, + record.WorldEntity.SourceGfxObjOrSetupId, + record.WorldEntity.Position, + record.WorldEntity.Rotation); + fixture.GameState.Add(snapshot); + fixture.Events.UpsertCurrent(snapshot); + fixture.Poses.PublishMeshRefs(record.WorldEntity); + } + } + + [Fact] + public void LogicalLocalPlayerTeardownWithoutReplacement_ClearsShadowSnapshot() + { + var fixture = new Fixture(); + fixture.Spawn(instance: 1); + fixture.LocalShadow.Set(Vector3.One, Quaternion.Identity, Cell); + + Assert.True(fixture.Live.UnregisterLiveEntity( + new DeleteObject.Parsed(Guid, InstanceSequence: 1), + isLocalPlayer: false)); + + Assert.Null(fixture.LocalShadow.Current); + } + + private sealed class Fixture + { + internal Fixture() + { + Spatial.AddLandblock(new LoadedLandblock( + (Cell & 0xFFFF0000u) | 0xFFFFu, + new LandBlock(), + Array.Empty())); + var lifecycle = new DeferredLiveEntityRuntimeComponentLifecycle(); + Live = new LiveEntityRuntime( + Spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), + lifecycle); + Projectiles = new ProjectileController( + Live, + Physics, + _ => null, + _ => { }, + () => (0, 0)); + Controller = new LiveEntityProjectionWithdrawalController( + Live, + Projectiles, + GameState, + Events, + Physics.ShadowObjects, + Poses, + LocalShadow); + Presentation = new LiveEntityPresentationController( + Live, + Physics.ShadowObjects, + (_, _, _) => false); + lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle( + record => Controller.LeaveWorld(record, Guid))); + } + + internal GpuWorldState Spatial { get; } = new(); + internal LiveEntityRuntime Live { get; } + internal PhysicsEngine Physics { get; } = new(); + internal ProjectileController Projectiles { get; } + internal WorldGameState GameState { get; } = new(); + internal WorldEvents Events { get; } = new(); + internal EntityEffectPoseRegistry Poses { get; } = new(); + internal LocalPlayerShadowState LocalShadow { get; } = new(); + internal LiveEntityProjectionWithdrawalController Controller { get; } + internal LiveEntityPresentationController Presentation { get; } + + internal LiveEntityRecord Spawn(ushort instance, Vector3? position = null) + { + WorldSession.EntitySpawn spawn = new( + Guid, + new CreateObject.ServerPosition(Cell, 1f, 2f, 3f, 1f, 0f, 0f, 0f), + 0x02000200u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "projection fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: instance); + LiveEntityRecord record = Live.RegisterLiveEntity(spawn).Record!; + WorldEntity entity = Live.MaterializeLiveEntity( + Guid, + Cell, + id => new WorldEntity + { + Id = id, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000200u, + Position = position ?? new Vector3(1f, 2f, 3f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = Cell, + })!; + var snapshot = new WorldEntitySnapshot( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation); + GameState.Add(snapshot); + Events.UpsertCurrent(snapshot); + Poses.PublishMeshRefs(entity); + Physics.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation, + radius: 0.5f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: Cell, + seedCellId: Cell, + isStatic: false); + Assert.True(Presentation.OnLiveEntityReady(Guid)); + return record; + } + } +} diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index ebf3c29e..dfa9a0d4 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -659,7 +659,7 @@ public sealed class LiveEntityRuntimeTests } [Fact] - public void InitialChildCreate_PreservesParentEventQueuedForMissingChild() + public void InitialChildCreate_PreservesParentEventQueuedForRenderValidation() { const uint parentGuid = 0x70000020u; const uint childGuid = 0x70000021u; @@ -676,7 +676,9 @@ public sealed class LiveEntityRuntimeTests childGuid, out ParentAttachmentRelation relation)); Assert.Equal(parentGuid, relation.ParentGuid); Assert.True(runtime.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn child)); - Assert.Null(child.Position); + Assert.NotNull(child.Position); + Assert.Null(child.ParentGuid); + Assert.Equal((ushort)5, child.PositionSequence); } [Fact] diff --git a/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs b/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs index 176808f4..5a1dd98b 100644 --- a/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs +++ b/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs @@ -27,9 +27,12 @@ public sealed class ParentAttachmentStateTests Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection)); Assert.Equal(parentGuid, projection.ParentGuid); Assert.True(inbound.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn child)); + Assert.NotNull(child.Position); + Assert.Null(child.ParentGuid); + Assert.Equal((ushort)5, child.PositionSequence); + Assert.True(Commit(relations, inbound, projection, out child)); Assert.Null(child.Position); Assert.Equal(parentGuid, child.ParentGuid); - Assert.Equal((ushort)5, child.PositionSequence); } [Fact] @@ -50,9 +53,24 @@ public sealed class ParentAttachmentStateTests update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence)); Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection)); + Assert.Equal((ushort)5, projection.ChildPositionSequence); + Assert.Equal((uint)2, projection.PlacementId); + Assert.True(relations.CommitProjection(projection)); + relations.MarkProjected( + projection, + ParentProjectionCandidateKind.Recovery); + relations.Resolve( + childGuid, + _ => true, + guid => guid == parentGuid ? (ushort)9 : null, + update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence)); + Assert.True(relations.TryGetStagedProjection(childGuid, out projection)); Assert.Equal((ushort)6, projection.ChildPositionSequence); Assert.Equal((uint)3, projection.PlacementId); - relations.MarkProjected(childGuid); + Assert.True(relations.CommitProjection(projection)); + relations.MarkProjected( + projection, + ParentProjectionCandidateKind.Recovery); Assert.True(relations.RestoreLastAccepted(childGuid)); Assert.True(relations.TryGetProjection(childGuid, out projection)); Assert.Equal((ushort)6, projection.ChildPositionSequence); @@ -136,6 +154,7 @@ public sealed class ParentAttachmentStateTests Assert.Equal(parentGuid, projection.ParentGuid); Assert.Equal((ushort)10, projection.ParentInstanceSequence); Assert.Equal((ushort)5, projection.ChildPositionSequence); + Assert.True(Commit(relations, inbound, projection, out _)); Assert.True(inbound.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn child)); Assert.Equal(parentGuid, child.ParentGuid); } @@ -257,6 +276,20 @@ public sealed class ParentAttachmentStateTests : null, update => inbound.TryApplyParent(update, out _)); + private static bool Commit( + ParentAttachmentState relations, + InboundPhysicsStateController inbound, + ParentAttachmentRelation relation, + out WorldSession.EntitySpawn accepted) => + inbound.TryCommitParent( + relation.ChildGuid, + relation.ParentGuid, + relation.ParentLocation, + relation.PlacementId, + relation.ChildPositionSequence, + out accepted) + && relations.CommitProjection(relation); + private static WorldSession.EntitySpawn Spawn( uint guid, ushort instance, diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index 4673fb83..9c22ad6e 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -160,6 +160,110 @@ public class ShadowObjectRegistryTests Assert.Equal(0x44u, restored.State); } + [Fact] + public void ReplaceMultiPartPayload_VisibleOwnerKeepsExistingCellMembership() + { + const uint entityId = 25u; + var reg = new ShadowObjectRegistry(); + Vector3 position = new(12f, 12f, 50f); + reg.RegisterMultiPart( + entityId, + position, + Quaternion.Identity, + [BspShape(0x01000011u, radius: 0.5f)], + state: 0u, + flags: EntityCollisionFlags.None, + OffX, + OffY, + LbId, + seedCellId: LbId | 1u, + isStatic: false); + + reg.ReplaceMultiPartPayload( + entityId, + position, + Quaternion.Identity, + [BspShape(0x01000012u, radius: 40f)], + state: 0u, + flags: EntityCollisionFlags.None, + OffX, + OffY, + landblockId: 0u, + seedCellId: 0u, + isStatic: false); + + ShadowEntry replaced = Assert.Single(reg.GetObjectsInCell(LbId | 1u)); + Assert.Equal(0x01000012u, replaced.GfxObjId); + Assert.Empty(reg.GetObjectsInCell(LbId | 9u)); + } + + [Fact] + public void ReplaceMultiPartPayload_SuspendedShapedEmptyShaped_RemainsSuspended() + { + const uint entityId = 26u; + var reg = new ShadowObjectRegistry(); + Vector3 position = new(12f, 12f, 50f); + reg.RegisterMultiPart( + entityId, + position, + Quaternion.Identity, + [BspShape(0x01000021u, radius: 0.5f)], + state: 0u, + flags: EntityCollisionFlags.None, + OffX, + OffY, + LbId, + seedCellId: LbId | 1u, + isStatic: false); + Assert.True(reg.Suspend(entityId)); + + reg.ReplaceMultiPartPayload( + entityId, position, Quaternion.Identity, [], 0u, + EntityCollisionFlags.None, OffX, OffY, LbId, LbId | 1u); + reg.ReplaceMultiPartPayload( + entityId, + position, + Quaternion.Identity, + [BspShape(0x01000022u, radius: 0.5f)], + 0u, + EntityCollisionFlags.None, + OffX, + OffY, + LbId, + LbId | 1u); + + Assert.Equal(1, reg.RetainedRegistrationCount); + Assert.Equal(1, reg.SuspendedRegistrationCount); + Assert.Empty(reg.GetObjectsInCell(LbId | 1u)); + reg.UpdatePosition( + entityId, position, Quaternion.Identity, OffX, OffY, LbId, LbId | 1u); + Assert.Equal(0x01000022u, Assert.Single(reg.GetObjectsInCell(LbId | 1u)).GfxObjId); + } + + [Fact] + public void ReplaceMultiPartPayload_NewCelllessOwnerRegistersRetainedButSuspended() + { + const uint entityId = 27u; + var reg = new ShadowObjectRegistry(); + reg.ReplaceMultiPartPayload( + entityId, + new Vector3(12f, 12f, 50f), + Quaternion.Identity, + [BspShape(0x01000031u, radius: 0.5f)], + 0u, + EntityCollisionFlags.None, + OffX, + OffY, + LbId, + seedCellId: LbId | 1u, + isStatic: false, + suspendIfNew: true); + + Assert.Equal(1, reg.RetainedRegistrationCount); + Assert.Equal(1, reg.SuspendedRegistrationCount); + Assert.Empty(reg.GetObjectsInCell(LbId | 1u)); + } + // ----------------------------------------------------------------------- // RemoveLandblock // ----------------------------------------------------------------------- @@ -652,6 +756,15 @@ public class ShadowObjectRegistryTests }; } + private static ShadowShape BspShape(uint gfxObjId, float radius) => new( + gfxObjId, + Vector3.Zero, + Quaternion.Identity, + Scale: 1f, + CollisionType: ShadowCollisionType.BSP, + Radius: radius, + CylHeight: 0f); + private static CellPhysics BuildShadowCellSetTests_MakeLeafCell(Matrix4x4 worldTransform) { Matrix4x4.Invert(worldTransform, out var inv); diff --git a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs index 21a5c88e..a1ede47c 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs @@ -103,6 +103,26 @@ public class ShadowShapeBuilderTests Assert.Equal(1, bspCount); } + [Fact] + public void FromSetup_EffectivePartIdentitiesControlPhysicsBspSelection() + { + const uint replacementWithBsp = 0x0100AA01u; + const uint replacementWithoutBsp = 0x0100AA02u; + var setup = new Setup + { + Parts = { 0x010044B5u, 0x010044B6u }, + }; + + var shapes = ShadowShapeBuilder.FromSetup( + setup, + entScale: 1f, + hasPhysicsBsp: id => id == replacementWithBsp, + effectivePartGfxObjIds: [replacementWithBsp, replacementWithoutBsp]); + + ShadowShape shape = Assert.Single(shapes); + Assert.Equal(replacementWithBsp, shape.GfxObjId); + } + [Fact] public void FromSetup_CreatureWithCylSpheres_OnlyEmitsCylinders() { From 27dcd0ecb797284a35d3490fafb3e875256adbdb Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 16:18:52 +0200 Subject: [PATCH 44/98] docs(plan): record live projection checkpoint --- docs/architecture/code-structure.md | 8 ++++++++ ...gamewindow-slice-4-live-entity-integration.md | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 625a0b6e..cecfe279 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -517,6 +517,14 @@ through `LiveEntityRuntime` and must preserve the inbound FIFO/authority-token rules. Run the complete live-entity stress suite, R6 connected route, inventory equip/parenting, death/corpse, and portal gates. +Checkpoints A and B are complete. `d68c83d1`, `5882b308`, and `fcb66198` +established the shared origin, lifecycle, remote-motion, and exact-record +physics-host seams. `69a2ca0c` extracted appearance/collision/default-pose and +projection-withdrawal mechanics, including exact recursive `leave_world` +subtree handling and retained retry ownership. Three independent reviews were +clean and the Release suite passes 6,799 tests with five intentional skips. +Checkpoint C (CreateObject and first materialization ownership) is active. + #### Slice 5 — extract landblock presentation Create `LandblockPresentationPipeline` around the existing immutable diff --git a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md index 3457e740..635b7ca0 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md @@ -17,8 +17,11 @@ them inside a mechanical extraction. ownership. Landed in `d68c83d1`, `5882b308`, and `fcb66198`. The final exact-record host checkpoint passed three clean reviews plus 6,743 Release tests / 5 skipped on 2026-07-21. -- [ ] B — pure appearance/projection helpers and controller-level - characterization tests. +- [x] B — pure appearance/projection helpers and controller-level + characterization tests. Landed in `69a2ca0c`. Exact appearance/collision + replacement, default-pose resolution, local-shadow ownership, and recursive + retained projection withdrawal passed three clean reviews plus 6,799 Release + tests / 5 skipped on 2026-07-21. - [ ] C — hydration create/materialize/landblock-recovery ownership and direct CreateObject/streaming cutover. - [ ] D — ObjDesc/Parent/Pickup/ready/withdraw ownership and exact retained @@ -245,6 +248,15 @@ one-line façades. - Run appearance, animation, spawn-adapter, collision, resource-lifecycle, and new characterization suites; full Release build/test; review cycle. +Completed in `69a2ca0c`. The checkpoint also pinned the retail `add_child` / +`set_parent` / `leave_world` boundary required by C and D: parent timestamps +are staged before validation, accepted relationships are committed one at a +time, and world withdrawal recursively removes the exact parent-first +projection subtree while retaining logical identity and retry ownership. +Release build completed with zero warnings and errors; 6,799 tests passed with +five intentional skips; retail, architecture, and adversarial re-reviews were +clean. + ### C — create and materialization - Implement hydration CreateObject registration, first materialization, From d10c5f2d54704f3446ef27e47321eb1bb37426e7 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 17:32:45 +0200 Subject: [PATCH 45/98] refactor(world): extract live entity hydration --- .../DatLiveEntityProjectionMaterializer.cs | 1119 +++++++++++++ .../EquippedChildRenderController.cs | 38 +- src/AcDream.App/Rendering/GameWindow.cs | 1343 +--------------- .../LiveEntityCreateSupersessionRecovery.cs | 105 ++ .../Rendering/SpawnMotionInitializer.cs | 20 + src/AcDream.App/RuntimeOptions.cs | 2 + .../World/LiveEntityHydrationController.cs | 613 +++++++ .../World/LiveEntityHydrationPorts.cs | 150 ++ src/AcDream.App/World/LiveEntityRuntime.cs | 136 ++ src/AcDream.Core.Net/ObjectTableWiring.cs | 21 +- src/AcDream.Core/Items/ClientObjectTable.cs | 12 +- ...veEntityCreateSupersessionRecoveryTests.cs | 439 +++++ .../AcDream.App.Tests/RuntimeOptionsTests.cs | 5 + .../GameWindowLiveEntityCompositionTests.cs | 7 + .../LiveEntityHydrationControllerTests.cs | 1413 +++++++++++++++++ .../LiveEntityWorldOriginCoordinatorTests.cs | 117 ++ .../ObjectTableWiringTests.cs | 39 + 17 files changed, 4310 insertions(+), 1269 deletions(-) create mode 100644 src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs create mode 100644 src/AcDream.App/Rendering/LiveEntityCreateSupersessionRecovery.cs create mode 100644 src/AcDream.App/World/LiveEntityHydrationController.cs create mode 100644 src/AcDream.App/World/LiveEntityHydrationPorts.cs create mode 100644 tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs create mode 100644 tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs create mode 100644 tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs new file mode 100644 index 00000000..79660df4 --- /dev/null +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -0,0 +1,1119 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Wb; +using AcDream.App.World; +using AcDream.Content; +using AcDream.Core.Meshing; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Rendering; + +/// +/// DAT-backed CPhysicsObj::set_description @ 0x00514F40 projection +/// materializer. It owns no identity map and may mutate only the exact +/// supplied by . +/// Logical registration and spatial rebucketing remain owned by +/// . +/// +internal sealed class DatLiveEntityProjectionMaterializer + : ILiveEntityProjectionMaterializer +{ + private readonly RuntimeOptions _options; + private readonly IDatReaderWriter _dats; + private readonly LiveEntityRuntime _runtime; + private readonly PhysicsDataCache _physicsData; + private readonly IAnimationLoader _animationLoader; + private readonly EntitySpawnAdapter _spawnAdapter; + private readonly TextureCache _textures; + private readonly EntityClassificationCache _classification; + private readonly EntityEffectPoseRegistry _effectPoses; + private readonly EquippedChildRenderController _equippedChildren; + private readonly WorldGameState _worldState; + private readonly WorldEvents _worldEvents; + private readonly ShadowObjectRegistry _shadows; + private readonly LiveEntityCollisionBuilder _collisionBuilder; + private readonly ProjectileController _projectiles; + private readonly LiveEntityAnimationPresenter _animationPresenter; + private readonly RetailStaticAnimatingObjectScheduler _staticAnimations; + private readonly LiveWorldOriginState _origin; + private readonly Func _gameTime; + + private int _received; + private int _hydrated; + private int _noPosition; + private int _noSetup; + private int _missingSetup; + private int _noMesh; + private int _noCycle; + private int _zeroFramerate; + private int _singleFrame; + private int _missingPartFrames; + + public DatLiveEntityProjectionMaterializer( + RuntimeOptions options, + IDatReaderWriter dats, + LiveEntityRuntime runtime, + PhysicsDataCache physicsData, + IAnimationLoader animationLoader, + EntitySpawnAdapter spawnAdapter, + TextureCache textures, + EntityClassificationCache classification, + EntityEffectPoseRegistry effectPoses, + EquippedChildRenderController equippedChildren, + WorldGameState worldState, + WorldEvents worldEvents, + ShadowObjectRegistry shadows, + LiveEntityCollisionBuilder collisionBuilder, + ProjectileController projectiles, + LiveEntityAnimationPresenter animationPresenter, + RetailStaticAnimatingObjectScheduler staticAnimations, + LiveWorldOriginState origin, + Func gameTime) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _physicsData = physicsData ?? throw new ArgumentNullException(nameof(physicsData)); + _animationLoader = animationLoader ?? throw new ArgumentNullException(nameof(animationLoader)); + _spawnAdapter = spawnAdapter ?? throw new ArgumentNullException(nameof(spawnAdapter)); + _textures = textures ?? throw new ArgumentNullException(nameof(textures)); + _classification = classification ?? throw new ArgumentNullException(nameof(classification)); + _effectPoses = effectPoses ?? throw new ArgumentNullException(nameof(effectPoses)); + _equippedChildren = equippedChildren ?? throw new ArgumentNullException(nameof(equippedChildren)); + _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); + _worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents)); + _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); + _collisionBuilder = collisionBuilder ?? throw new ArgumentNullException(nameof(collisionBuilder)); + _projectiles = projectiles ?? throw new ArgumentNullException(nameof(projectiles)); + _animationPresenter = animationPresenter ?? throw new ArgumentNullException(nameof(animationPresenter)); + _staticAnimations = staticAnimations ?? throw new ArgumentNullException(nameof(staticAnimations)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _gameTime = gameTime ?? throw new ArgumentNullException(nameof(gameTime)); + } + + public void ResetSessionState() + { + _received = 0; + _hydrated = 0; + _noPosition = 0; + _noSetup = 0; + _missingSetup = 0; + _noMesh = 0; + _noCycle = 0; + _zeroFramerate = 0; + _singleFrame = 0; + _missingPartFrames = 0; + } + + public bool TryMaterialize( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn canonicalSpawn, + LiveProjectionPurpose purpose, + ulong expectedCreateIntegrationVersion, + LiveEntityAppearanceUpdateState? appearanceUpdate = null) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (purpose is LiveProjectionPurpose.AppearanceMutation + != (appearanceUpdate is not null)) + { + throw new ArgumentException( + "Appearance mutation requires the exact captured visual owner.", + nameof(appearanceUpdate)); + } + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || canonicalSpawn.Guid != expectedRecord.ServerGuid + || canonicalSpawn.InstanceSequence != expectedRecord.Generation) + { + return false; + } + + _received++; + bool dumpLiveSpawns = _options.DumpLiveSpawns; + DumpSpawn(canonicalSpawn, dumpLiveSpawns); + + if (!_origin.IsKnown) + return false; + if (canonicalSpawn.Position is null || canonicalSpawn.SetupTableId is null) + { + if (canonicalSpawn.Position is null) + _noPosition++; + else + _noSetup++; + return false; + } + + CreateObject.ServerPosition position = canonicalSpawn.Position.Value; + int lbX = (int)((position.LandblockId >> 24) & 0xFFu); + int lbY = (int)((position.LandblockId >> 16) & 0xFFu); + var worldOrigin = new Vector3( + (lbX - _origin.CenterX) * 192f, + (lbY - _origin.CenterY) * 192f, + 0f); + Vector3 worldPosition = new( + position.PositionX + worldOrigin.X, + position.PositionY + worldOrigin.Y, + position.PositionZ); + var rotation = new Quaternion( + position.RotationX, + position.RotationY, + position.RotationZ, + position.RotationW); + + Setup? setup = _dats.Get(canonicalSpawn.SetupTableId.Value); + if (setup is not null) + _physicsData.CacheSetup(canonicalSpawn.SetupTableId.Value, setup); + if (setup is null) + { + _missingSetup++; + if (dumpLiveSpawns) + { + Console.WriteLine( + $"live: DROP setup dat 0x{canonicalSpawn.SetupTableId.Value:X8} missing " + + $"(guid=0x{canonicalSpawn.Guid:X8})"); + } + return false; + } + + expectedRecord.HasPartArray = true; + ushort? stanceOverride = canonicalSpawn.MotionState?.Stance; + ushort? commandOverride = canonicalSpawn.MotionState?.ForwardCommand; + var idleCycle = MotionResolver.GetIdleCycle( + setup, + _dats, + _animationLoader, + motionTableIdOverride: canonicalSpawn.MotionTableId, + stanceOverride: stanceOverride, + commandOverride: commandOverride); + AnimationFrame? idleFrame = null; + if (idleCycle is not null) + { + int startIndex = idleCycle.LowFrame; + if (startIndex < 0 || startIndex >= idleCycle.Animation.PartFrames.Count) + startIndex = 0; + idleFrame = idleCycle.Animation.PartFrames[startIndex]; + } + + List flattened = [.. SetupMesh.Flatten(setup, idleFrame)]; + IReadOnlyList animPartChanges = + canonicalSpawn.AnimPartChanges ?? Array.Empty(); + bool dumpClothing = _options.DumpClothing && setup.Parts.Count >= 10; + DumpClothingHeader( + canonicalSpawn, + setup, + flattened, + idleFrame, + animPartChanges, + dumpClothing); + + foreach (CreateObject.AnimPartChange change in animPartChanges) + { + if (change.PartIndex < flattened.Count) + { + flattened[change.PartIndex] = new MeshRef( + change.NewModelId, + flattened[change.PartIndex].PartTransform); + } + } + + uint[] collisionPartGfxObjIds = + LiveEntityCollisionBuilder.ResolveEffectivePartIdentities( + flattened.Select(static part => part.GfxObjId).ToArray(), + baseId => ResolveCollisionPart(baseId)); + + if (_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup)) + ApplyRetailCloseDegrades(flattened, dumpClothing); + + IReadOnlyList textureChanges = + canonicalSpawn.TextureChanges ?? Array.Empty(); + Dictionary>? surfaceOverrides = + ResolveSurfaceOverrides( + canonicalSpawn, + flattened, + textureChanges, + dumpClothing, + dumpLiveSpawns); + + float scale = canonicalSpawn.ObjScale ?? 1f; + Matrix4x4 scaleMatrix = Matrix4x4.CreateScale(scale); + IReadOnlyList rigidPartTransforms = + SetupPartTransforms.Compute(setup, idleFrame, scale); + var meshRefs = new List(); + var indexedPartTransforms = new Matrix4x4[flattened.Count]; + var indexedPartAvailable = new bool[flattened.Count]; + var animatedPartTemplate = new LiveAnimationPartTemplate[flattened.Count]; + var bounds = new LocalBoundsAccumulator(); + int clothingTriangles = 0; + + for (int partIndex = 0; partIndex < flattened.Count; partIndex++) + { + MeshRef part = flattened[partIndex]; + IReadOnlyDictionary? overrides = null; + if (surfaceOverrides?.TryGetValue(partIndex, out var found) == true) + overrides = found; + + Matrix4x4 transform = scale == 1f + ? part.PartTransform + : part.PartTransform * scaleMatrix; + indexedPartTransforms[partIndex] = partIndex < rigidPartTransforms.Count + ? rigidPartTransforms[partIndex] + : Matrix4x4.Identity; + GfxObj? gfx = _dats.Get(part.GfxObjId); + bool drawable = gfx is not null; + indexedPartAvailable[partIndex] = drawable; + animatedPartTemplate[partIndex] = new LiveAnimationPartTemplate( + part.GfxObjId, + overrides, + drawable); + if (gfx is null) + { + if (dumpClothing) + Console.WriteLine($" EMIT part={partIndex:D2} gfx=0x{part.GfxObjId:X8} GFXOBJ_DAT_MISSING -> 0 tris"); + continue; + } + + _physicsData.CacheGfxObj(part.GfxObjId, gfx); + if (dumpClothing) + { + var subMeshes = GfxObjMesh.Build(gfx, _dats); + int triangles = 0; + foreach (var subMesh in subMeshes) + triangles += subMesh.Indices.Length / 3; + clothingTriangles += triangles; + Console.WriteLine( + $" EMIT part={partIndex:D2} gfx=0x{part.GfxObjId:X8} " + + $"subMeshes={subMeshes.Count} tris={triangles}"); + } + + if (GfxObjBounds.Get(gfx) is { } partBounds) + bounds.Add(transform, partBounds); + meshRefs.Add(new MeshRef(part.GfxObjId, transform) + { + SurfaceOverrides = overrides, + }); + } + + if (meshRefs.Count == 0) + { + _noMesh++; + if (dumpLiveSpawns) + { + Console.WriteLine( + $"live: DROP no mesh refs from setup 0x{canonicalSpawn.SetupTableId.Value:X8} " + + $"(guid=0x{canonicalSpawn.Guid:X8})"); + } + return false; + } + if (dumpClothing) + { + Console.WriteLine( + $" TOTAL tris={clothingTriangles} meshRefs={meshRefs.Count} " + + $"(parts.Count={flattened.Count})"); + } + + PaletteOverride? paletteOverride = CreatePaletteOverride(canonicalSpawn); + PartOverride[] partOverrides = CreatePartOverrides(animPartChanges); + + bool supersessionRecovery = + purpose is LiveProjectionPurpose.CreateSupersessionRecovery; + if (supersessionRecovery && expectedRecord.WorldEntity is not null) + { + return LiveEntityCreateSupersessionRecovery.TryApply( + _runtime, + expectedRecord, + expectedCreateIntegrationVersion, + captureAppearance: () => appearanceUpdate + ?? LiveEntityAppearanceBinding.Capture( + _runtime, + expectedRecord.ServerGuid), + publishAppearance: visualUpdate => ApplyAppearance( + expectedRecord, + canonicalSpawn, + visualUpdate, + setup, + scale, + worldOrigin, + collisionPartGfxObjIds, + meshRefs, + paletteOverride, + partOverrides, + indexedPartTransforms, + indexedPartAvailable, + animatedPartTemplate, + bounds, + expectedCreateIntegrationVersion), + publishCurrentSnapshot: visualUpdate => + { + var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( + visualUpdate.Entity.Id, + visualUpdate.Entity.SourceGfxObjOrSetupId, + visualUpdate.Entity.Position, + visualUpdate.Entity.Rotation); + _worldState.Add(snapshot); + _worldEvents.UpsertCurrent(snapshot); + }, + synchronizeAnimation: visualUpdate => RegisterAnimation( + expectedRecord, + visualUpdate.Entity, + setup, + canonicalSpawn, + idleCycle, + scale, + animatedPartTemplate, + indexedPartAvailable, + retainedAnimation: + expectedRecord.AnimationRuntime is LiveEntityAnimationState, + synchronizeAnimation: true)); + } + + if (appearanceUpdate is { } visualUpdate) + { + return ApplyAppearance( + expectedRecord, + canonicalSpawn, + visualUpdate, + setup, + scale, + worldOrigin, + collisionPartGfxObjIds, + meshRefs, + paletteOverride, + partOverrides, + indexedPartTransforms, + indexedPartAvailable, + animatedPartTemplate, + bounds, + expectedCreateIntegrationVersion); + } + + return MaterializeProjection( + expectedRecord, + canonicalSpawn, + setup, + idleCycle, + worldOrigin, + worldPosition, + rotation, + scale, + collisionPartGfxObjIds, + meshRefs, + paletteOverride, + partOverrides, + indexedPartTransforms, + indexedPartAvailable, + animatedPartTemplate, + bounds, + dumpLiveSpawns, + expectedCreateIntegrationVersion, + synchronizeAnimation: supersessionRecovery); + } + + private uint ResolveCollisionPart(uint baseId) + { + if (!GfxObjDegradeResolver.TryResolveCloseGfxObj( + _dats, + baseId, + out uint slotZeroId, + out GfxObj? slotZeroGfx)) + { + return baseId; + } + + if (slotZeroGfx is not null) + _physicsData.CacheGfxObj(slotZeroId, slotZeroGfx); + return slotZeroId; + } + + private void ApplyRetailCloseDegrades( + List parts, + bool dumpClothing) + { + for (int partIndex = 0; partIndex < parts.Count; partIndex++) + { + MeshRef part = parts[partIndex]; + if (!GfxObjDegradeResolver.TryResolveCloseGfxObj( + _dats, + part.GfxObjId, + out uint resolvedId, + out _) + || resolvedId == part.GfxObjId) + { + continue; + } + + parts[partIndex] = new MeshRef(resolvedId, part.PartTransform); + if (dumpClothing) + { + Console.WriteLine( + $" DEGRADE part={partIndex:D2} gfx=0x{part.GfxObjId:X8} " + + $"-> close=0x{resolvedId:X8}"); + } + } + } + + private Dictionary>? ResolveSurfaceOverrides( + WorldSession.EntitySpawn spawn, + IReadOnlyList parts, + IReadOnlyList textureChanges, + bool dumpClothing, + bool dumpLiveSpawns) + { + if (dumpClothing) + { + Console.WriteLine($" TextureChanges count={textureChanges.Count}"); + foreach (CreateObject.TextureChange change in textureChanges) + { + Console.WriteLine( + $" TC part={change.PartIndex:D2} oldTex=0x{change.OldTexture:X8} " + + $"-> newTex=0x{change.NewTexture:X8}"); + } + } + + if (textureChanges.Count == 0) + return null; + + var oldToNewByPart = new Dictionary>(); + foreach (CreateObject.TextureChange change in textureChanges) + { + if (!oldToNewByPart.TryGetValue(change.PartIndex, out var oldToNew)) + { + oldToNew = []; + oldToNewByPart.Add(change.PartIndex, oldToNew); + } + oldToNew[change.OldTexture] = change.NewTexture; + } + + bool statueDiagnostic = dumpLiveSpawns + && spawn.Name?.Contains("Statue", StringComparison.OrdinalIgnoreCase) == true; + var result = new Dictionary>(); + for (int partIndex = 0; partIndex < parts.Count; partIndex++) + { + if (!oldToNewByPart.TryGetValue(partIndex, out var oldToNew)) + continue; + + GfxObj? gfx = _dats.Get(parts[partIndex].GfxObjId); + if (gfx is null) + { + if (statueDiagnostic) + { + Console.WriteLine( + $"live: [STATUE] resolve part={partIndex} " + + $"GfxObj 0x{parts[partIndex].GfxObjId:X8} missing"); + } + continue; + } + _physicsData.CacheGfxObj(parts[partIndex].GfxObjId, gfx); + + Dictionary? resolved = null; + foreach (var surfaceQid in gfx.Surfaces) + { + uint surfaceId = (uint)surfaceQid; + Surface? surface = _dats.Get(surfaceId); + if (surface is null) + continue; + uint originalTexture = (uint)surface.OrigTextureId; + if (originalTexture == 0 + || !oldToNew.TryGetValue(originalTexture, out uint newTexture)) + { + continue; + } + + (resolved ??= [])[surfaceId] = newTexture; + } + + if (resolved is not null) + result[partIndex] = resolved; + } + + return result.Count == 0 ? null : result; + } + + private static PaletteOverride? CreatePaletteOverride( + WorldSession.EntitySpawn spawn) + { + if (spawn.SubPalettes is not { Count: > 0 } subPalettes) + return null; + + var ranges = new PaletteOverride.SubPaletteRange[subPalettes.Count]; + for (int i = 0; i < subPalettes.Count; i++) + { + ranges[i] = new PaletteOverride.SubPaletteRange( + subPalettes[i].SubPaletteId, + subPalettes[i].Offset, + subPalettes[i].Length); + } + return new PaletteOverride(spawn.BasePaletteId ?? 0u, ranges); + } + + private static PartOverride[] CreatePartOverrides( + IReadOnlyList changes) + { + if (changes.Count == 0) + return []; + + var result = new PartOverride[changes.Count]; + for (int i = 0; i < changes.Count; i++) + result[i] = new PartOverride(changes[i].PartIndex, changes[i].NewModelId); + return result; + } + + private bool ApplyAppearance( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn spawn, + LiveEntityAppearanceUpdateState visualUpdate, + Setup setup, + float scale, + Vector3 worldOrigin, + IReadOnlyList collisionPartGfxObjIds, + IReadOnlyList meshRefs, + PaletteOverride? paletteOverride, + IReadOnlyList partOverrides, + IReadOnlyList indexedPartTransforms, + IReadOnlyList indexedPartAvailable, + IReadOnlyList animatedPartTemplate, + LocalBoundsAccumulator bounds, + ulong expectedCreateIntegrationVersion) + { + WorldEntity entity = visualUpdate.Entity; + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + + LiveEntityAppearanceCollisionUpdate? appearanceCollision = + LiveEntityAppearanceBinding.PrepareCollision( + _runtime, + _collisionBuilder, + entity, + setup, + collisionPartGfxObjIds, + spawn, + worldOrigin); + + bool published = _spawnAdapter.OnAppearanceChanged( + entity, + meshRefs, + partOverrides, + () => + { + _textures.ReleaseOwner(entity.Id); + entity.ApplyAppearance(meshRefs, paletteOverride, partOverrides); + }, + () => + { + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return; + } + if (appearanceCollision is not null) + { + LiveEntityAppearanceBinding.CommitCollision( + _runtime, + _shadows, + appearanceCollision); + } + entity.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); + if (bounds.TryGet(out Vector3 minimum, out Vector3 maximum)) + entity.SetLocalBounds(minimum, maximum); + if (visualUpdate.Animation is { } animation) + { + LiveEntityAppearanceBinding.RebindAnimation( + animation, + entity, + setup, + scale, + animatedPartTemplate, + indexedPartAvailable); + } + _classification.InvalidateEntity(entity.Id); + if (_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + && expectedRecord.ProjectionKind is LiveEntityProjectionKind.Attached) + { + _equippedChildren.OnSpawn(expectedRecord.Snapshot); + } + else + { + _effectPoses.PublishMeshRefs(entity); + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return; + } + _equippedChildren.OnPosePublished(spawn.Guid); + } + }); + return published + && _runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + && ReferenceEquals(expectedRecord.WorldEntity, entity); + } + + private bool MaterializeProjection( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn spawn, + Setup setup, + MotionResolver.IdleCycle? idleCycle, + Vector3 worldOrigin, + Vector3 worldPosition, + Quaternion rotation, + float scale, + IReadOnlyList collisionPartGfxObjIds, + IReadOnlyList meshRefs, + PaletteOverride? paletteOverride, + IReadOnlyList partOverrides, + IReadOnlyList indexedPartTransforms, + IReadOnlyList indexedPartAvailable, + IReadOnlyList animatedPartTemplate, + LocalBoundsAccumulator bounds, + bool dumpLiveSpawns, + ulong expectedCreateIntegrationVersion, + bool synchronizeAnimation) + { + if (!_runtime.TryGetEffectProfile(spawn.Guid, out _)) + { + EntityEffectProfile profile = spawn.Physics is { } physics + ? EntityEffectProfile.CreateLive(setup, physics) + : EntityEffectProfile.CreateDatStatic(setup); + _runtime.SetEffectProfile(spawn.Guid, profile); + } + + bool createdProjection = false; + WorldEntity? entity = _runtime.MaterializeLiveEntity( + spawn.Guid, + spawn.Position!.Value.LandblockId, + localId => + { + createdProjection = true; + var created = new WorldEntity + { + Id = localId, + ServerGuid = spawn.Guid, + SourceGfxObjOrSetupId = spawn.SetupTableId!.Value, + Position = worldPosition, + Rotation = rotation, + MeshRefs = meshRefs, + PaletteOverride = paletteOverride, + PartOverrides = partOverrides, + ParentCellId = spawn.Position.Value.LandblockId, + }; + created.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); + if (bounds.TryGet(out Vector3 minimum, out Vector3 maximum)) + created.SetLocalBounds(minimum, maximum); + return created; + }); + if (entity is null + || !_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + + if (!createdProjection) + { + entity.SetPosition(worldPosition); + entity.Rotation = rotation; + entity.ParentCellId = spawn.Position.Value.LandblockId; + entity.ApplyAppearance(meshRefs, paletteOverride, partOverrides); + entity.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); + if (bounds.TryGet(out Vector3 minimum, out Vector3 maximum)) + entity.SetLocalBounds(minimum, maximum); + _effectPoses.PublishMeshRefs(entity); + } + + bool retainedAnimation = !createdProjection + && expectedRecord.AnimationRuntime is LiveEntityAnimationState; + var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation); + _worldState.Add(snapshot); + _worldEvents.UpsertCurrent(snapshot); + if (_runtime.TryMarkWorldSpawnPublished(spawn.Guid)) + _worldEvents.FireEntitySpawned(snapshot); + _hydrated++; + + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + + _equippedChildren.OnWorldEntityRegistered(spawn.Guid); + + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + + if (_collisionBuilder.Build( + entity, + setup, + collisionPartGfxObjIds, + spawn, + expectedRecord, + worldOrigin) is { } collision) + { + LiveEntityCollisionBuilder.Register(_shadows, collision); + } + + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + return false; + _projectiles.TryBind( + expectedRecord, + setup, + _gameTime(), + _origin.CenterX, + _origin.CenterY); + + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + + RegisterAnimation( + expectedRecord, + entity, + setup, + spawn, + idleCycle, + scale, + animatedPartTemplate, + indexedPartAvailable, + retainedAnimation, + synchronizeAnimation); + + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + + if (dumpLiveSpawns && _received % 20 == 0) + { + Console.WriteLine( + $"live: animated={_runtime.AnimationRuntimes.Count} " + + $"animReject: noCycle={_noCycle} fr0={_zeroFramerate} " + + $"1frame={_singleFrame} partFrames={_missingPartFrames}"); + Console.WriteLine( + $"live: summary recv={_received} hydrated={_hydrated} " + + $"drops: noPos={_noPosition} noSetup={_noSetup} " + + $"setupMissing={_missingSetup} noMesh={_noMesh}"); + } + + return _runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion); + } + + private void RegisterAnimation( + LiveEntityRecord expectedRecord, + WorldEntity entity, + Setup setup, + WorldSession.EntitySpawn spawn, + MotionResolver.IdleCycle? idleCycle, + float scale, + IReadOnlyList partTemplate, + IReadOnlyList partAvailability, + bool retainedAnimation, + bool synchronizeAnimation) + { + if (retainedAnimation + && synchronizeAnimation + && expectedRecord.AnimationRuntime is LiveEntityAnimationState retained) + { + SynchronizeRetainedAnimation( + expectedRecord, + retained, + setup, + spawn, + idleCycle); + } + if (!retainedAnimation) + { + if (idleCycle is null) + _noCycle++; + else if (idleCycle.Framerate == 0f) + _zeroFramerate++; + else if (idleCycle.HighFrame <= idleCycle.LowFrame) + _singleFrame++; + else if (idleCycle.Animation.PartFrames.Count <= 1) + _missingPartFrames++; + } + + if (!retainedAnimation + && idleCycle is not null + && idleCycle.Framerate != 0f + && idleCycle.HighFrame > idleCycle.LowFrame + && idleCycle.Animation.PartFrames.Count > 1) + { + AnimationSequencer? sequencer = CreateMotionSequencer(setup, spawn); + _runtime.SetAnimationRuntime( + spawn.Guid, + new LiveEntityAnimationState + { + Entity = entity, + Setup = setup, + Animation = idleCycle.Animation, + LowFrame = Math.Max(0, idleCycle.LowFrame), + HighFrame = Math.Min( + idleCycle.HighFrame, + idleCycle.Animation.PartFrames.Count - 1), + Framerate = idleCycle.Framerate, + Scale = scale, + PartTemplate = partTemplate, + PartAvailability = partAvailability, + CurrFrame = idleCycle.LowFrame, + Sequencer = sequencer, + }); + } + else if (!retainedAnimation) + { + uint motionTableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; + if (motionTableId != 0 + && _dats.Get(motionTableId) is { } motionTable) + { + AnimationSequencer sequencer = SpawnMotionInitializer.Create( + setup, + motionTable, + _animationLoader, + spawn.MotionState); + _runtime.SetAnimationRuntime( + spawn.Guid, + new LiveEntityAnimationState + { + Entity = entity, + Setup = setup, + Animation = null!, + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = scale, + PartTemplate = partTemplate, + PartAvailability = partAvailability, + CurrFrame = 0, + Sequencer = sequencer, + }); + + if (PhysicsDiagnostics.ProbeBuildingEnabled) + { + var initial = SpawnMotionInitializer.ResolvePlan( + motionTable, + spawn.MotionState); + Console.WriteLine( + $"[reactive-anim] registered guid=0x{spawn.Guid:X8} " + + $"entityId=0x{entity.Id:X8} mtable=0x{motionTableId:X8} " + + $"initialStyle=0x{initial.Style:X8} initialCycle=0x{initial.Motion:X8}"); + } + } + } + + bool physicsStatic = (expectedRecord.FinalPhysicsState + & PhysicsStateFlags.Static) != 0; + if (!retainedAnimation + && expectedRecord.AnimationRuntime is null + && (uint)setup.DefaultAnimation != 0) + { + var sequencer = new AnimationSequencer( + setup, + new MotionTable(), + _animationLoader); + if (sequencer.HasCurrentNode) + { + _runtime.SetAnimationRuntime( + spawn.Guid, + new LiveEntityAnimationState + { + Entity = entity, + Setup = setup, + Animation = null!, + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = scale, + PartTemplate = partTemplate, + PartAvailability = partAvailability, + CurrFrame = 0, + Sequencer = sequencer, + }); + } + } + + if (expectedRecord.AnimationRuntime is not LiveEntityAnimationState animation) + return; + + _animationPresenter.PrepareAnimation(expectedRecord, animation); + if (!physicsStatic + || animation.Sequencer is not { } staticSequencer + || spawn.Position is not { } staticPosition) + { + return; + } + + PhysicsBody body = _runtime.GetOrCreatePhysicsBody( + spawn.Guid, + incarnation => + { + var created = new PhysicsBody { Orientation = entity.Rotation }; + RemotePhysicsBodyInitializer.Initialize(created, incarnation); + created.SnapToCell( + staticPosition.LandblockId, + entity.Position, + new Vector3( + staticPosition.PositionX, + staticPosition.PositionY, + staticPosition.PositionZ)); + return created; + }); + _staticAnimations.BindLiveOwner( + entity, + animation, + body); + } + + private void SynchronizeRetainedAnimation( + LiveEntityRecord expectedRecord, + LiveEntityAnimationState animation, + Setup setup, + WorldSession.EntitySpawn spawn, + MotionResolver.IdleCycle? idleCycle) + { + // Retail's same-instance HandleCreateObject branch never constructs a + // second CPhysicsObj/CPartArray/MovementManager. A completed object has + // already consumed the newer MovementData through SetObjectMovement, + // so its sequencer and every MotionInterpreter binding stay intact. + // Only an interrupted *initial* hydration needs the retained, not-yet- + // published sequencer reset to the newest canonical spawn state. + uint motionTableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; + MotionTable? motionTable = motionTableId == 0 + ? null + : _dats.Get(motionTableId); + LiveEntityCreateAnimationSynchronization + .TrySynchronizeInterruptedInitialOwner( + expectedRecord, + animation, + idleCycle?.Animation, + idleCycle is null ? 0 : Math.Max(0, idleCycle.LowFrame), + idleCycle is null + ? 0 + : Math.Min( + idleCycle.HighFrame, + idleCycle.Animation.PartFrames.Count - 1), + idleCycle?.Framerate ?? 0f, + motionTable, + spawn.MotionState); + } + + private AnimationSequencer? CreateMotionSequencer( + Setup setup, + WorldSession.EntitySpawn spawn) + { + uint motionTableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; + return motionTableId != 0 + && _dats.Get(motionTableId) is { } motionTable + ? SpawnMotionInitializer.Create( + setup, + motionTable, + _animationLoader, + spawn.MotionState) + : null; + } + + private void DumpSpawn(WorldSession.EntitySpawn spawn, bool enabled) + { + if (!enabled) + return; + + string position = spawn.Position is { } p + ? $"({p.PositionX:F1},{p.PositionY:F1},{p.PositionZ:F1})@0x{p.LandblockId:X8}" + : "no-pos"; + string setup = spawn.SetupTableId is { } setupId + ? $"0x{setupId:X8}" + : "no-setup"; + string physicsTable = spawn.Physics?.PhysicsScriptTableId is { } tableId + ? $"0x{tableId:X8}" + : "no-petable"; + string name = spawn.Name is { Length: > 0 } foundName + ? $"\"{foundName}\"" + : "no-name"; + string itemType = spawn.ItemType is { } foundItemType + ? $"0x{foundItemType:X8}" + : "no-itemtype"; + Console.WriteLine( + $"live: spawn guid=0x{spawn.Guid:X8} name={name} setup={setup} pos={position} " + + $"petable={physicsTable} itemType={itemType} " + + $"animParts={spawn.AnimPartChanges?.Count ?? 0} " + + $"texChanges={spawn.TextureChanges?.Count ?? 0} " + + $"subPalettes={spawn.SubPalettes?.Count ?? 0}"); + } + + private void DumpClothingHeader( + WorldSession.EntitySpawn spawn, + Setup setup, + IReadOnlyList flattened, + AnimationFrame? idleFrame, + IReadOnlyList changes, + bool enabled) + { + if (!enabled) + return; + + Console.WriteLine( + $"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' " + + $"setup=0x{setup.Id:X8} setup.Parts.Count={setup.Parts.Count} " + + $"flatten.Count={flattened.Count} APC={changes.Count} ==="); + foreach (CreateObject.AnimPartChange change in changes) + Console.WriteLine($" APC part={change.PartIndex:D2} -> gfx=0x{change.NewModelId:X8}"); + Console.WriteLine( + $" basePalette=0x{spawn.BasePaletteId ?? 0:X8} " + + $"subPalettes={spawn.SubPalettes?.Count ?? 0}"); + } + + private static bool IsIssue47HumanoidSetup(Setup setup) + { + if (setup.Parts.Count != 34) + return false; + const uint nullPartGfx = 0x010001ECu; + int nullSlots = 0; + for (int i = 17; i < setup.Parts.Count; i++) + { + if ((uint)setup.Parts[i] == nullPartGfx) + nullSlots++; + } + return nullSlots >= 8; + } +} diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index 87adf4fb..4da28c83 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -36,7 +36,7 @@ public sealed class EquippedChildRenderController : IDisposable private ParentAttachmentState Relations => _liveEntities.ParentAttachments; /// Raised after the attached projection is fully registered. - public event Action? EntityReady; + internal event Action? EntityReady; /// Raised after an attached projection has its composed pose. public event Action? ProjectionPoseReady; /// Raised when an attached projection leaves its cell presentation. @@ -448,14 +448,46 @@ public sealed class EquippedChildRenderController : IDisposable // CPhysicsObj::set_hidden (0x00514C60). _liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true); } + ulong readyCreateIntegrationVersion = childRecord.CreateIntegrationVersion; PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose); Console.WriteLine( $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " + $"location={parentLocation} placement={placement}"); Relations.MarkProjected(pending, candidateKind); ProjectionPoseReady?.Invoke(childGuid); - EntityReady?.Invoke(childGuid); - return true; + return PublishEntityReadyExact( + _liveEntities, + childRecord, + readyCreateIntegrationVersion, + entity, + EntityReady); + } + + internal static bool PublishEntityReadyExact( + LiveEntityRuntime runtime, + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion, + WorldEntity expectedEntity, + Action? publish) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(expectedRecord); + ArgumentNullException.ThrowIfNull(expectedEntity); + if (!runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, expectedEntity)) + { + return false; + } + + publish?.Invoke(new LiveEntityReadyCandidate( + expectedRecord, + expectedCreateIntegrationVersion)); + return runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + && ReferenceEquals(expectedRecord.WorldEntity, expectedEntity); } private void PublishChildPose( diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 37de367f..e4d299cb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -195,6 +195,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.Physics.ProjectileController? _projectileController; private AcDream.App.World.LiveEntityProjectionWithdrawalController? _liveEntityProjectionWithdrawal; + private AcDream.App.World.LiveEntityHydrationController? _liveEntityHydration; // Step 4: portal-based interior cell visibility. private readonly CellVisibility _cellVisibility = new(); @@ -213,7 +214,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Those closures already acquire _datLock, so no additional wrapping is // needed for reads inside BuildLandblockForStreamingLocked / // BuildSceneryEntitiesForStreaming / BuildInteriorEntitiesForStreaming. - // Render-thread paths (ApplyLoadedTerrain, OnLiveEntitySpawned) already + // Render-thread paths (ApplyLoadedTerrain and live hydration) already // hold this lock via their outer wrappers; all remaining render-thread // _dats.Get calls run only when no worker dat read can be in flight (during // initialization or within the same lock scope). @@ -362,26 +363,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Backed by ACDREAM_DUMP_SCENERY_Z via RuntimeOptions.DumpSceneryZ. /// - /// Issue #47 humanoid-setup detector. Matches Aluvian Male - /// (0x02000001) and the 34-part heritage sibling setups - /// (Aluvian Female, Sho M/F, Gharu M/F, Viamont/Empyrean, etc.) - /// by structure rather than id list: a humanoid setup has exactly - /// 34 parts, and the trailing attachment slots (parts 17–33) are - /// the AC null-part sentinel 0x010001EC. Non-humanoid - /// 34-part setups (rare) won't have the sentinel pattern. - /// - private static bool IsIssue47HumanoidSetup(DatReaderWriter.DBObjs.Setup setup) - { - if (setup.Parts.Count != 34) return false; - const uint NullPartGfx = 0x010001ECu; - int nullSlots = 0; - for (int i = 17; i < setup.Parts.Count; i++) - if ((uint)setup.Parts[i] == NullPartGfx) nullSlots++; - // At least half of slots 17–33 wired to the null sentinel — enough - // to distinguish humanoids from any future 34-part creature setup. - return nullSlots >= 8; - } - private readonly HashSet _activeSkyPes = new(); private readonly HashSet _missingSkyPes = new(); @@ -415,7 +396,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// L.2g S1 (DEV-6): per-entity inbound movement-event staleness gates, /// keyed by server guid. Retail keeps these stamps on CPhysicsObj /// (update_times); seeded from CreateObject's PhysicsDesc timestamp - /// block in , consulted at the + /// block during , consulted at the /// top of , dropped with the entity in /// . /// @@ -589,7 +570,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 _characterOptions1 = AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; - private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character private MovementTruthOutbound? _lastMovementTruthOutbound; private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new(); @@ -767,8 +747,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// /// Latest for each /// guid. Captured before the renderability gate so no-position inventory / - /// parented children retain Setup and parent metadata; hydrated world objects - /// refresh it again at the end of . + /// parented children retain Setup and parent metadata; hydration rereads + /// this canonical snapshot after every relationship callback. /// reuses the cached position/setup/motion /// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals. /// @@ -787,20 +767,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // (player); pruned only by logical LiveEntityRuntime teardown. private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; + private static bool IsDoorName(string? name) => name == "Door"; // ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184 // Slice 2a — the DR tick's stale-velocity anim-stop was its only user). - private int _liveSpawnReceived; // diagnostics - private int _liveSpawnHydrated; - private int _liveDropReasonNoPos; - private int _liveDropReasonNoSetup; - private int _liveDropReasonSetupDatMissing; - private int _liveDropReasonNoMeshRefs; - // Phase 6.4 animation-registration diagnostics - private int _liveAnimRejectNoCycle; - private int _liveAnimRejectFramerate; - private int _liveAnimRejectSingleFrame; - private int _liveAnimRejectPartFrames; - public GameWindow( AcDream.App.RuntimeOptions options, WorldGameState worldGameState, @@ -2365,10 +2334,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext deferShadowRestore), (guid, generation) => _liveEntityPresentation.BeginAuthoritativePlacement(guid, generation)); - _equippedChildRenderer.EntityReady += guid => - { - CompleteLiveEntityReady(guid); - }; _equippedChildRenderer.ProjectionPoseReady += guid => _liveEntityLights.OnAttachedPoseReady(guid); _hookRouter.Register(entityEffects); @@ -2544,7 +2509,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // #138: restore retained server objects when a landblock reloads // (dungeon-exit expand or Far→Near promote). ACE won't re-send the // objects it thinks we still know, so we re-project them ourselves. - onLandblockLoaded: RehydrateServerEntitiesForLandblock, + onLandblockLoaded: loadedLandblockId => + _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin, retirementCoordinator: _landblockRetirements); // A.5 T22.5: apply max-completions from resolved quality. @@ -2565,6 +2531,59 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable, log: message => Console.WriteLine(message)); + var networkUpdateBridge = + new AcDream.App.World.DeferredLiveEntityNetworkUpdateSink(); + var projectionMaterializer = new DatLiveEntityProjectionMaterializer( + _options, + _dats!, + _liveEntities!, + _physicsDataCache, + _animLoader!, + _wbEntitySpawnAdapter!, + _textureCache!, + _classificationCache, + _effectPoses, + _equippedChildRenderer!, + _worldGameState, + _worldEvents, + _physicsEngine.ShadowObjects, + _liveEntityCollisionBuilder!, + _projectileController!, + _animationPresenter, + _staticAnimationScheduler!, + _liveWorldOrigin, + () => _physicsScriptGameTime); + var originCoordinator = + new AcDream.App.World.LiveEntityWorldOriginCoordinator( + _liveWorldOrigin, + _streamingController, + _worldState, + _worldReveal, + () => _playerServerGuid, + IsSealedDungeonCell, + Console.WriteLine); + _liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController( + _liveEntities, + Objects, + _datLock, + projectionMaterializer, + new AcDream.App.World.DelegateLiveEntitySpawnRelationshipSink( + _equippedChildRenderer.OnSpawn), + new AcDream.App.World.LiveEntityReadyPublisher( + _liveEntities, + _entityEffects!, + _liveEntityPresentation!), + originCoordinator, + networkUpdateBridge, + PublishLocalPhysicsTimestamps, + () => _playerServerGuid, + _options.DumpLiveSpawns ? Console.WriteLine : null); + networkUpdateBridge.Bind( + new AcDream.App.World.DelegateLiveEntityNetworkUpdateSink( + RouteSameGenerationCreateObject)); + _equippedChildRenderer.EntityReady += candidate => + _liveEntityHydration.OnEntityReady(candidate); + // Phase 4.7: optional live-mode startup. Connect to the ACE server, // enter the world as the first character on the account, and stream // CreateObject messages into _worldGameState as they arrive. Entirely @@ -2712,20 +2731,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _activeToonKey = "default"; _characterOptions1 = AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; - _playerMotionTableId = null; _lastSeenRunSkill = -1; _lastSeenJumpSkill = -1; _lastLivePlayerLandblockId = null; - _liveSpawnReceived = 0; - _liveSpawnHydrated = 0; - _liveDropReasonNoPos = 0; - _liveDropReasonNoSetup = 0; - _liveDropReasonSetupDatMissing = 0; - _liveDropReasonNoMeshRefs = 0; - _liveAnimRejectNoCycle = 0; - _liveAnimRejectFramerate = 0; - _liveAnimRejectSingleFrame = 0; - _liveAnimRejectPartFrames = 0; + _liveEntityHydration?.ResetSessionState(); Shortcuts = Array.Empty(); DesiredComponents = Array.Empty<(uint Id, uint Amount)>(); _paperdollDollDirty = true; @@ -2808,7 +2817,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.Net.LiveEntitySessionSink CreateLiveEntitySessionSink() => new( Spawned: spawn => _inboundEntityEvents.Run( - this, spawn, static (window, value) => window.OnLiveEntitySpawned(value)), + _liveEntityHydration!, + spawn, + static (hydration, value) => hydration.OnCreate(value)), Deleted: deletion => _inboundEntityEvents.Run( this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)), PickedUp: pickup => _inboundEntityEvents.Run( @@ -2983,1187 +2994,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - private enum LiveProjectionPurpose - { - LogicalRegistration, - SpatialRecovery, - AppearanceMutation, - } - - private void OnLiveEntitySpawned(AcDream.Core.Net.WorldSession.EntitySpawn spawn) - { - // Phase A.1 hotfix: live CreateObject handler reads dats extensively - // (Setup, GfxObj, Surface, SurfaceTexture) to hydrate the spawned - // entity. All of it must run under the dat lock so it doesn't race - // with BuildLandblockForStreaming on the worker thread. - lock (_datLock) - { - AcDream.App.World.LiveEntityRegistrationResult registration = - _liveEntities!.RegisterLiveEntity(spawn); - AcDream.App.World.InboundCreateResult result = registration.Inbound; - if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration) - return; - - try - { - PublishLocalPhysicsTimestamps(spawn.Guid, result.Timestamps); - - AcDream.Core.Net.ObjectTableWiring.ApplyEntitySpawn( - Objects, - spawn, - replaceGeneration: result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration); - - if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.ExistingGeneration) - { - if (result.SameGenerationEvents is { } refresh) - RouteSameGenerationCreateObject(refresh); - return; - } - - OnLiveEntitySpawnedLocked( - result.Snapshot, - LiveProjectionPurpose.LogicalRegistration); - } - catch (Exception applyFailure) when (registration.PriorGenerationCleanupFailure is not null) - { - throw new AggregateException( - $"Live entity 0x{spawn.Guid:X8} replacement cleanup and installation both failed.", - registration.PriorGenerationCleanupFailure, - applyFailure); - } - - if (registration.PriorGenerationCleanupFailure is { } cleanupFailure) - throw new AggregateException( - $"Prior incarnation of live entity 0x{spawn.Guid:X8} failed teardown after its replacement was installed.", - cleanupFailure); - } - } - - /// - /// #138: re-hydrate retained server objects (doors, NPCs, chests, portals) - /// into a landblock that just (re)loaded. Fired by - /// after - /// AddLandblock / AddEntitiesToExistingLandblock. - /// - /// - /// A full landblock unload drops its dat-static render layer and parks live - /// projections pending, while keeping the parsed spawns in - /// (our weenie_object_table for world objects). ACE never - /// re-broadcasts objects it believes we still know — its per-player - /// KnownObjects set is not cleared on a normal teleport (verified - /// against references/ACE ObjectMaint; a real client keeps its - /// table and re-renders from it, per references/holtburger). So on - /// reload the render side would stay empty. We re-project the objects from - /// our own retained table instead — independent of any server re-send. - /// - /// - /// - /// Idempotent and cheap when nothing is missing. A materialized record is - /// rebucketed with the same identity; a record whose Setup was unavailable - /// on first arrival gets one first-materialization attempt. Neither path - /// replays logical renderer or script registration. - /// - /// - private void RehydrateServerEntitiesForLandblock(uint loadedLandblockId) - { - if (_liveEntities is null || _liveEntities.Count == 0) return; - - uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu; - LiveEntityRecord[] records = _liveEntities.Records - .Where(record => record.ServerGuid != _playerServerGuid - && record.ProjectionCellId != 0 - && ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical - && record.Snapshot.SetupTableId is not null) - .ToArray(); - if (records.Length == 0) return; - - int projected = 0; - lock (_datLock) - { - foreach (LiveEntityRecord record in records) - { - // A materialized object keeps the same WorldEntity, renderer - // registration, animation owner, and scripts. Reloading a - // landblock changes only its spatial bucket. Hydration is used - // solely for a record that never acquired a projection. - if (record.WorldEntity is not null) - { - if (_liveEntities.RebucketLiveEntity( - record.ServerGuid, - record.ProjectionCellId)) - projected++; - } - else - { - OnLiveEntitySpawnedLocked( - record.Snapshot, - LiveProjectionPurpose.SpatialRecovery); - if (record.WorldEntity is not null) - projected++; - } - } - } - - if (_options.DumpLiveSpawns && projected > 0) - Console.WriteLine( - $"live: re-projected {projected} server object(s) into landblock 0x{loadedLandblockId:X8}"); - } - - /// - /// Diagnostic-only label filter for the [door-cycle] UM dispatch trail - /// (below). #187: this is NOT the functional registration gate — that - /// was an exact-name match (`spawn.Name == "Door"`) which silently - /// dropped every door-like object whose display name isn't literally - /// "Door" (Sliding Door, Portcullis, Gate, "Magic Wall"). The real - /// registration gate is now data-driven (MotionTableId != 0, matching - /// retail's CPartArray::SetMotionTableID 0x005186e0) — see the rescue - /// branch below. Kept only to scope the debug print to door-ish - /// entities so the log doesn't fill with every reactive-motion prop. - /// - private static bool IsDoorName(string? name) => name == "Door"; - - private void TryInitializeLiveCenter( - AcDream.Core.Net.WorldSession.EntitySpawn spawn) - { - if (spawn.Guid != _playerServerGuid - || spawn.Position is not { } position) - return; - - int lbX = (int)((position.LandblockId >> 24) & 0xFFu); - int lbY = (int)((position.LandblockId >> 16) & 0xFFu); - int oldCenterX = _liveWorldOrigin.CenterX; - int oldCenterY = _liveWorldOrigin.CenterY; - if (!_liveWorldOrigin.TryInitialize(lbX, lbY)) - return; - - if (lbX != oldCenterX || lbY != oldCenterY) - { - Console.WriteLine( - $"live: first player position — recentering streaming from ({oldCenterX},{oldCenterY}) " + - $"to ({lbX},{lbY}) @0x{position.LandblockId:X8}"); - } - - if (_streamingController is not null && _dats is not null) - { - _streamingController.InitializeKnownLoginCenter( - lbX, - lbY, - isSealedDungeon: IsSealedDungeonCell(position.LandblockId)); - } - - // Retail SmartBox::UseTime (0x00455410) does not complete the player - // position while CellManager is blocking for destination cells. Begin - // the same shared reveal lifetime used by portal arrivals: initial - // login must not inherit the dispatcher's default/previous composite - // readiness and expose a terrain-only or partially uploaded world. - _worldReveal?.Begin(AcDream.App.Streaming.WorldRevealKind.Login); - - // Streaming is normally gated until this point, so the next loaded- - // landblock callback will materialize deferred records. Also cover an - // already-resident block (tests, reconnect teardown overlap) without - // rebuilding any still-live identity. - foreach (uint loaded in _worldState.LoadedLandblockIds.ToArray()) - RehydrateServerEntitiesForLandblock(loaded); - } - - private void OnLiveEntitySpawnedLocked( - AcDream.Core.Net.WorldSession.EntitySpawn spawn, - LiveProjectionPurpose purpose, - LiveEntityAppearanceUpdateState? appearanceUpdate = null) - { - _liveSpawnReceived++; - - // LiveEntityRuntime has already classified this as a first - // materialization, a spatial re-entry of the same incarnation, or an - // appearance mutation. This method never de-duplicates by destroying a - // still-live projection and never reconstructs from stale spawn data. - - // Retail's weenie-object table retains CreateObject data for inventory - // and parented children even when they have no world Position. Held - // weapon CreateObjects are intentionally no-position and carry their - // Parent + Placement in PhysicsData, so cache before the renderability - // gate and offer the relationship to the focused child controller. - _equippedChildRenderer?.OnSpawn(spawn); - - // A ParentEvent may have arrived before this CreateObject. Resolving - // it above can synchronously advance the canonical child POSITION_TS - // and turn this object into an attachment. Select the projection from - // that post-callback snapshot, never from the stale method argument. - if (_liveEntities!.TryGetSnapshot(spawn.Guid, out var canonicalSpawn)) - spawn = canonicalSpawn; - - // Streaming readiness belongs to the first accepted canonical player - // Position, not to mesh projection. A partial CreateObject with no - // Setup can establish the real world origin before a later mutation or - // spatial recovery makes it renderable. - TryInitializeLiveCenter(spawn); - - // When requested, log every spawn that arrives so we can inventory what the server - // sends (including the ones we can't render yet). The Name field - // is the critical one — we can grep the log for "Nullified Statue - // of a Drudge" or similar to find a specific weenie by its - // in-game name. - bool dumpLiveSpawns = _options.DumpLiveSpawns; - if (dumpLiveSpawns) - { - string posStr = spawn.Position is { } sp - ? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}" - : "no-pos"; - string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup"; - string physicsTableStr = spawn.Physics?.PhysicsScriptTableId is { } pe - ? $"0x{pe:X8}" - : "no-petable"; - string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name"; - string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype"; - int animPartCount = spawn.AnimPartChanges?.Count ?? 0; - int texChangeCount = spawn.TextureChanges?.Count ?? 0; - int subPalCount = spawn.SubPalettes?.Count ?? 0; - Console.WriteLine( - $"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " + - $"petable={physicsTableStr} itemType={itemTypeStr} animParts={animPartCount} " + - $"texChanges={texChangeCount} subPalettes={subPalCount}"); - } - - // Target the statue specifically for full diagnostic dump: Name match - // is cheap and gives us exactly one entity's worth of log regardless - // of arrival order. - bool isStatue = dumpLiveSpawns - && spawn.Name is not null - && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase); - if (isStatue) - { - Console.WriteLine($"live: [STATUE] objScale={spawn.ObjScale?.ToString("F3") ?? "null"}"); - Console.WriteLine($"live: [STATUE] mtable=0x{(spawn.MotionTableId ?? 0):X8} stance=0x{(spawn.MotionState?.Stance ?? 0):X4} cmd=0x{(spawn.MotionState?.ForwardCommand ?? 0):X4}"); - if (spawn.TextureChanges is { } tcs) - { - foreach (var tc in tcs) - Console.WriteLine($"live: [STATUE] texChange part={tc.PartIndex} old=0x{tc.OldTexture:X8} new=0x{tc.NewTexture:X8}"); - } - if (spawn.SubPalettes is { } sps) - { - Console.WriteLine($"live: [STATUE] basePalette=0x{(spawn.BasePaletteId ?? 0):X8}"); - foreach (var subPal in sps) - Console.WriteLine($"live: [STATUE] subPalette id=0x{subPal.SubPaletteId:X8} offset={subPal.Offset} length={subPal.Length}"); - } - if (spawn.AnimPartChanges is { } apcs) - { - foreach (var apc in apcs) - Console.WriteLine($"live: [STATUE] animPart index={apc.PartIndex} newModel=0x{apc.NewModelId:X8}"); - } - - // Dump the BASE setup's part list before AnimPartChanges, so we can - // see how many parts the statue's Setup actually has + what their - // default GfxObjs are. The retail statue may have additional parts - // (e.g. a pedestal sub-mesh) that our setup loader is dropping or - // we're rendering with wrong default GfxObjs. - if (spawn.SetupTableId is { } sid && _dats is not null) - { - var baseSetup = _dats.Get(sid); - if (baseSetup is not null) - { - Console.WriteLine($"live: [STATUE] base Setup 0x{sid:X8} has {baseSetup.Parts.Count} parts:"); - for (int pi = 0; pi < baseSetup.Parts.Count; pi++) - { - uint partGfxId = (uint)baseSetup.Parts[pi]; - var pgfx = _dats.Get(partGfxId); - int subCount = pgfx?.Surfaces.Count ?? -1; - Console.WriteLine($"live: [STATUE] part[{pi}] gfxObj=0x{partGfxId:X8} surfaces={subCount}"); - } - Console.WriteLine($"live: [STATUE] placementFrames count={baseSetup.PlacementFrames.Count}"); - } - } - } - - if (_dats is null) return; - - // The retained live record and its canonical packet state are valid - // before the player's first Position, but render-world coordinates are - // not. Defer every projection until the authoritative origin exists; - // StreamingController's loaded callback (or the already-resident pass - // in TryInitializeLiveCenter) materializes it once with the correct - // landblock translation. - if (!_liveCenterKnown) - return; - - if (spawn.Position is null || spawn.SetupTableId is null) - { - // Can't place a mesh without both. Most of these are inventory - // items anyway (no position because they're held), which have no - // visible world presence. - if (spawn.Position is null) _liveDropReasonNoPos++; - else _liveDropReasonNoSetup++; - return; - } - - var p = spawn.Position.Value; - - // Translate server position into acdream world space. The server sends - // (landblockId, local x/y/z). acdream's world origin is the center - // landblock; each neighbor landblock is offset by 192 units per step. - int lbX = (int)((p.LandblockId >> 24) & 0xFFu); - int lbY = (int)((p.LandblockId >> 16) & 0xFFu); - - var origin = new System.Numerics.Vector3( - (lbX - _liveCenterX) * 192f, - (lbY - _liveCenterY) * 192f, - 0f); - var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin; - - // AC quaternion wire order is (W, X, Y, Z); System.Numerics.Quaternion is (X, Y, Z, W). - var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); - - // Hydrate mesh refs from the Setup dat. This is the same code path - // used by the static scenery pipeline (see the Setup hydration above). - var setup = _dats.Get(spawn.SetupTableId.Value); - if (setup is not null) - _physicsDataCache.CacheSetup(spawn.SetupTableId.Value, setup); - if (setup is null) - { - _liveDropReasonSetupDatMissing++; - if (dumpLiveSpawns) - Console.WriteLine($"live: DROP setup dat 0x{spawn.SetupTableId.Value:X8} missing " + - $"(guid=0x{spawn.Guid:X8})"); - return; - } - if (_liveEntities.TryGetRecord(spawn.Guid, out var setupRecord)) - setupRecord.HasPartArray = true; - - // Phase 6: resolve the entity's idle motion frame from its - // MotionTable chain. For creatures and characters this gives us - // the upright "Resting" pose instead of the Setup's Default - // (T-pose / aggressive crouch). Static items with no motion table - // get null and fall back to PlacementFrames in Flatten. - // Honor the server's CurrentMotionState (CreateObject MovementData) - // when present. The Foundry's drudge statue is the canonical case: - // its MotionTable's default style is upright "Ready" but the weenie - // is sent with a combat stance + Crouch ForwardCommand override, so - // resolving the cycle key from those gives the aggressive crouch. - ushort? stanceOverride = spawn.MotionState?.Stance; - ushort? commandOverride = spawn.MotionState?.ForwardCommand; - // Critical for entities like the Foundry's drudge statue: their - // base Setup has DefaultMotionTable=0, but the server tells us - // which motion table to use via PhysicsDescriptionFlag.MTable. - // Without this override the resolver returns null and we fall - // back to PlacementFrames[Default] which renders the wrong pose. - // Phase 6.4: prefer the full cycle so we can play it forward over - // time. Falls back to GetIdleFrame's static-frame behavior when - // the cycle resolves but only the first frame is rendered (no - // animated entry registered) — this happens for entities the - // resolver short-circuits on. - var idleCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( - setup, _dats, _animLoader!, - motionTableIdOverride: spawn.MotionTableId, - stanceOverride: stanceOverride, - commandOverride: commandOverride); - DatReaderWriter.Types.AnimationFrame? idleFrame = null; - if (idleCycle is not null) - { - int startIdx = idleCycle.LowFrame; - if (startIdx < 0 || startIdx >= idleCycle.Animation.PartFrames.Count) startIdx = 0; - idleFrame = idleCycle.Animation.PartFrames[startIdx]; - } - var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup, idleFrame); - - // Apply the server's AnimPartChanges: "replace part at index N - // with GfxObj M". This is how characters become clothed (head → - // helmet, torso → chestplate, ...) and how server-weenie statues - // and props pick up their unique visual meshes on top of a generic - // base Setup. Start with a mutable copy, patch in the replacements, - // then proceed with the normal upload loop. - var parts = new List(flat); - var animPartChanges = spawn.AnimPartChanges ?? Array.Empty(); - // Diagnostic: dump AnimPartChanges + TextureChanges for humanoid setups - // gated on ACDREAM_DUMP_CLOTHING=1. Used to verify whether the server is - // sending coverage for the neck (part 9 for Aluvian Male) etc. - bool dumpClothing = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DUMP_CLOTHING"), "1", StringComparison.Ordinal) - && setup.Parts.Count >= 10; - if (dumpClothing) - { - Console.WriteLine($"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' setup=0x{setup.Id:X8} setup.Parts.Count={setup.Parts.Count} flatten.Count={flat.Count} APC={animPartChanges.Count} ==="); - // Dump the Setup's ParentIndex + DefaultScale arrays to verify hierarchy. - var parentStr = string.Join(",", setup.ParentIndex.Take(Math.Min(34, setup.ParentIndex.Count)).Select(p => p == 0xFFFFFFFFu ? "-1" : p.ToString())); - Console.WriteLine($" ParentIndex[{setup.ParentIndex.Count}]: {parentStr}"); - var scaleStr = string.Join(",", setup.DefaultScale.Take(Math.Min(34, setup.DefaultScale.Count)).Select(s => $"({s.X:F2},{s.Y:F2},{s.Z:F2})")); - Console.WriteLine($" DefaultScale[{setup.DefaultScale.Count}]: {scaleStr}"); - // Dump the resolved idle frame's per-part Origin + Orientation. - // If retail composes parent_world * animation_local but acdream - // treats animation_local as world-relative, we'd see specific - // patterns of non-zero per-part origins/rotations that should - // be parent-relative. For setups whose idle has all parts at - // (0,0,0)/identity, parent walking would be a no-op (which - // matches my earlier "no change" experiment if that was the - // human-idle case) — diagnostic confirms. - if (idleFrame is not null) - { - Console.WriteLine($" IdleFrame.Frames[{idleFrame.Frames.Count}]:"); - int dumpCount = Math.Min(idleFrame.Frames.Count, 17); // first 17 (real body parts, not the 17-33 placeholders) - for (int fi = 0; fi < dumpCount; fi++) - { - var f = idleFrame.Frames[fi]; - Console.WriteLine($" [{fi:D2}] Origin=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) Orient=(W={f.Orientation.W:F3} X={f.Orientation.X:F3} Y={f.Orientation.Y:F3} Z={f.Orientation.Z:F3})"); - } - } - else - { - Console.WriteLine($" IdleFrame: NULL"); - } - foreach (var c in animPartChanges) - Console.WriteLine($" APC part={c.PartIndex:D2} -> gfx=0x{c.NewModelId:X8}"); - - // #37: per-spawn palette swaps. The server's clothing pipeline - // sends a basePalette + a list of (subPaletteId, offset, length) - // triples that splice palette ranges into the rendered character. - // We need their IDs to know whether the coat texture's underlying - // palette is being overridden by a coat-tone subPalette or left - // alone (in which case the texture's DefaultPaletteId — a SKIN - // palette — leaks through and the coat ends up neck-colored). - Console.WriteLine($" basePalette=0x{(spawn.BasePaletteId ?? 0):X8} subPalettes={(spawn.SubPalettes?.Count ?? 0)}"); - if (spawn.SubPalettes is { } subPaletteList) - { - foreach (var subPal in subPaletteList) - { - int rawOffset = subPal.Offset * 8; - int rawLen = subPal.Length == 0 ? 2048 : subPal.Length * 8; - var pal = _dats.Get(subPal.SubPaletteId); - string palInfo = pal is null ? "Palette dat NOT FOUND (might be PaletteSet 0x0F?)" : $"Colors.Count={pal.Colors.Count}"; - Console.WriteLine($" SP id=0x{subPal.SubPaletteId:X8} wireOffset={subPal.Offset} wireLength={subPal.Length} -> rawIdx[{rawOffset}..{rawOffset + rawLen}) {palInfo}"); - // If pal is non-null and small, show first 4 colors - if (pal is not null && pal.Colors.Count > 0) - { - int sample = Math.Min(4, pal.Colors.Count); - for (int s = 0; s < sample; s++) - { - var c = pal.Colors[s]; - Console.WriteLine($" pal[{s:D3}] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2}"); - } - // Also probe at the rawOffset (if in range) — that's where overlay copies FROM in our code - if (rawOffset < pal.Colors.Count) - { - var c = pal.Colors[rawOffset]; - Console.WriteLine($" pal[{rawOffset:D4}] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2} <-- our code reads here"); - } - else - { - Console.WriteLine($" pal[{rawOffset:D4}] OUT OF RANGE (Colors.Count={pal.Colors.Count}) -- our code's read SKIPS the overlay !!"); - } - } - } - } - } - foreach (var change in animPartChanges) - { - if (change.PartIndex < parts.Count) - { - parts[change.PartIndex] = new AcDream.Core.World.MeshRef( - change.NewModelId, parts[change.PartIndex].PartTransform); - } - } - - // Issue #47 — retail's close/player rendering path resolves each - // part's base GfxObj through its DIDDegrade table to the close- - // detail mesh in slot 0. Without this, humanoid arms/torso draw - // the LOW-detail base GfxObj (e.g. 0x01000055, 14 verts / 17 - // polys) instead of the close mesh (0x01001795, 32 verts / 60 - // polys), losing all bicep/shoulder/back geometry. See - // for the named-retail - // citation (CPhysicsPart::LoadGfxObjArray at 0x0050DCF0, - // ::UpdateViewerDistance at 0x0050E030, ::Draw at 0x0050D7A0). - // - // Order matters: the swap happens AFTER AnimPartChanges have - // installed the server's body/clothing/head ids, BEFORE texture - // changes resolve (which match against the resolved mesh's - // surfaces) and BEFORE the GfxObjMesh.Build / texture upload - // path consumes the part list. - // Collision reads slot 0 of the installed CPhysicsPart degrade array. - // Keep this independent from the optional visual close-LOD policy: - // draw settings and Setup type must never change physical geometry. - uint[] collisionPartGfxObjIds = - AcDream.App.Physics.LiveEntityCollisionBuilder.ResolveEffectivePartIdentities( - parts.Select(static part => part.GfxObjId).ToArray(), - baseId => - { - if (!AcDream.Core.Meshing.GfxObjDegradeResolver.TryResolveCloseGfxObj( - _dats, - baseId, - out uint slotZeroId, - out DatReaderWriter.DBObjs.GfxObj? slotZeroGfx)) - { - return baseId; - } - - if (slotZeroGfx is not null) - _physicsDataCache.CacheGfxObj(slotZeroId, slotZeroGfx); - return slotZeroId; - }); - - if (_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup)) - { - for (int partIdx = 0; partIdx < parts.Count; partIdx++) - { - var part = parts[partIdx]; - if (!AcDream.Core.Meshing.GfxObjDegradeResolver.TryResolveCloseGfxObj( - _dats, part.GfxObjId, - out uint resolvedId, out _)) - continue; - if (resolvedId == part.GfxObjId) - continue; - - parts[partIdx] = new AcDream.Core.World.MeshRef( - resolvedId, part.PartTransform); - - if (dumpClothing) - Console.WriteLine($" DEGRADE part={partIdx:D2} gfx=0x{part.GfxObjId:X8} -> close=0x{resolvedId:X8}"); - } - } - - // Build per-part texture overrides. The server sends TextureChanges as - // (partIdx, oldSurfaceTextureId, newSurfaceTextureId) where both ids - // are in the SurfaceTexture (0x05) range. Our sub-meshes are keyed - // by Surface (0x08) ids whose `OrigTextureId` field points to a - // SurfaceTexture. So we have to resolve each Surface → OrigTextureId, - // match that against the part's oldSurfaceTextureId set, and build - // a new dict keyed by Surface id → replacement OrigTextureId. The - // renderer then resolves an owner-scoped texture with the replacement - // SurfaceTexture substituted inside the Surface's decode chain. - var textureChanges = spawn.TextureChanges ?? Array.Empty(); - if (dumpClothing) - { - Console.WriteLine($" TextureChanges count={textureChanges.Count}"); - foreach (var tc in textureChanges) - Console.WriteLine($" TC part={tc.PartIndex:D2} oldTex=0x{tc.OldTexture:X8} -> newTex=0x{tc.NewTexture:X8}"); - - // For each part (post-AnimPartChange), dump its Surface chain so we - // can see which OrigTextureIds the part references and check which - // are covered by our TextureChanges. - var tcByPart = new Dictionary>(); - foreach (var tc in textureChanges) - { - if (!tcByPart.TryGetValue(tc.PartIndex, out var set)) { set = new HashSet(); tcByPart[tc.PartIndex] = set; } - set.Add(tc.OldTexture); - } - for (int pi = 0; pi < parts.Count; pi++) - { - var pgfx = _dats.Get(parts[pi].GfxObjId); - if (pgfx is null) continue; - if (pgfx.Surfaces.Count == 0) continue; - tcByPart.TryGetValue(pi, out var coveredOldTex); - int matched = 0; - int unmatched = 0; - var unmatchedList = new List(); - foreach (var surfQid in pgfx.Surfaces) - { - uint surfId = (uint)surfQid; - var surf = _dats.Get(surfId); - if (surf is null) continue; - uint origTex = (uint)surf.OrigTextureId; - if (coveredOldTex is not null && coveredOldTex.Contains(origTex)) matched++; - else { unmatched++; unmatchedList.Add($"surf=0x{surfId:X8} origTex=0x{origTex:X8}"); } - } - if (pgfx.Surfaces.Count > 0) - Console.WriteLine($" part[{pi:D2}] gfx=0x{parts[pi].GfxObjId:X8} surfaces={pgfx.Surfaces.Count} matched={matched} unmatched={unmatched}"); - foreach (var s in unmatchedList) - Console.WriteLine($" UNMATCHED {s}"); - } - } - Dictionary>? resolvedOverridesByPart = null; - if (textureChanges.Count > 0) - { - // First pass: group (oldOrigTex → newOrigTex) per part. - var perPartOldToNew = new Dictionary>(); - foreach (var tc in textureChanges) - { - if (!perPartOldToNew.TryGetValue(tc.PartIndex, out var dict)) - { - dict = new Dictionary(); - perPartOldToNew[tc.PartIndex] = dict; - } - // Last write wins — matches observed duplicate semantics. - dict[tc.OldTexture] = tc.NewTexture; - } - - // Second pass: resolve each affected part's Surface chain and - // build the Surface-id-keyed override map the renderer consumes. - bool isStatueDiag = dumpLiveSpawns - && spawn.Name is not null - && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase); - resolvedOverridesByPart = new Dictionary>(); - for (int pi = 0; pi < parts.Count; pi++) - { - if (!perPartOldToNew.TryGetValue(pi, out var oldToNew)) continue; - var partGfx = _dats.Get(parts[pi].GfxObjId); - if (partGfx is null) - { - if (isStatueDiag) - Console.WriteLine($"live: [STATUE] resolve part={pi} GfxObj 0x{parts[pi].GfxObjId:X8} missing"); - continue; - } - _physicsDataCache.CacheGfxObj(parts[pi].GfxObjId, partGfx); - - if (isStatueDiag) - Console.WriteLine($"live: [STATUE] resolve part={pi} gfx=0x{parts[pi].GfxObjId:X8} surfaces={partGfx.Surfaces.Count}"); - - Dictionary? resolved = null; - foreach (var surfQid in partGfx.Surfaces) - { - uint surfId = (uint)surfQid; - var surfDat = _dats.Get(surfId); - if (surfDat is null) continue; - uint origTexId = (uint)surfDat.OrigTextureId; - bool hit = origTexId != 0 && oldToNew.TryGetValue(origTexId, out uint newOrigTex) && (newOrigTex != 0 || true); - if (isStatueDiag) - Console.WriteLine($"live: [STATUE] surface=0x{surfId:X8} origTex=0x{origTexId:X8} " + (hit ? "[MATCH]" : "[miss]")); - if (origTexId == 0) continue; - if (oldToNew.TryGetValue(origTexId, out uint newId)) - { - resolved ??= new Dictionary(); - resolved[surfId] = newId; - } - } - - if (resolved is not null) - resolvedOverridesByPart[pi] = resolved; - } - } - - // Apply ObjScale by baking a scale matrix into each MeshRef's - // PartTransform. Scenery hydration already does this pattern - // (scaleMat baked into PartTransform at Setup flatten time). - // Fallback to 1.0 if the server didn't send ObjScale (common for - // creatures/characters whose size is intrinsic to the mesh). - float scale = spawn.ObjScale ?? 1.0f; - var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale); - IReadOnlyList rigidPartTransforms = - AcDream.Core.Meshing.SetupPartTransforms.Compute( - setup, - idleFrame, - scale); - - var meshRefs = new List(); - var indexedPartTransforms = new System.Numerics.Matrix4x4[parts.Count]; - var indexedPartAvailable = new bool[parts.Count]; - var animatedPartTemplate = new LiveAnimationPartTemplate[parts.Count]; - var liveBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); - int dumpClothingTotalTris = 0; - for (int partIdx = 0; partIdx < parts.Count; partIdx++) - { - var mr = parts[partIdx]; - IReadOnlyDictionary? surfaceOverrides = null; - if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides)) - surfaceOverrides = partOverrides; - - // Keep the Setup index even when the visual GfxObj is absent. - // MeshRefs is a drawable-only list, while hooks and holding - // locations address the stable CPartArray slot number. - var transform = scale == 1.0f ? mr.PartTransform : mr.PartTransform * scaleMat; - indexedPartTransforms[partIdx] = partIdx < rigidPartTransforms.Count - ? rigidPartTransforms[partIdx] - : System.Numerics.Matrix4x4.Identity; - var gfx = _dats.Get(mr.GfxObjId); - bool isDrawable = gfx is not null; - indexedPartAvailable[partIdx] = isDrawable; - animatedPartTemplate[partIdx] = new LiveAnimationPartTemplate( - mr.GfxObjId, - surfaceOverrides, - isDrawable); - if (gfx is null) - { - if (dumpClothing) - Console.WriteLine($" EMIT part={partIdx:D2} gfx=0x{mr.GfxObjId:X8} GFXOBJ_DAT_MISSING -> 0 tris"); - continue; - } - _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); - if (dumpClothing) - { - var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); - int tris = 0; int subs = 0; - foreach (var sm in subMeshes) { tris += sm.Indices.Length / 3; subs++; } - dumpClothingTotalTris += tris; - Console.WriteLine($" EMIT part={partIdx:D2} gfx=0x{mr.GfxObjId:X8} subMeshes={subs} tris={tris}"); - } - - // Multiplication order matches offline scenery hydration: - // `PartTransform * scaleMat`. In row-vector semantics this means - // "apply PartTransform first (which includes the part-attachment - // translation), then scale in the resulting space." Using the - // opposite order (`scaleMat * PartTransform`) scales in mesh-local - // space first, which leaves the part-attachment offset unscaled — - // for multi-part entities like the Nullified Statue that causes - // the parts to drift relative to each other ("distorted") and the - // base anchor to end up below the ground ("sinks into foundry"). - // #119 follow-up: vertex-derived root-local bounds (see WorldEntity.RefreshAabb). - var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); - if (pb is not null) liveBounds.Add(transform, pb.Value); - - meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, transform) - { - SurfaceOverrides = surfaceOverrides, - }); - } - if (meshRefs.Count == 0) - { - _liveDropReasonNoMeshRefs++; - if (dumpLiveSpawns) - Console.WriteLine($"live: DROP no mesh refs from setup 0x{spawn.SetupTableId.Value:X8} " + - $"(guid=0x{spawn.Guid:X8})"); - return; - } - if (dumpClothing) - Console.WriteLine($" TOTAL tris={dumpClothingTotalTris} meshRefs={meshRefs.Count} (parts.Count={parts.Count})"); - - // Build optional per-entity palette override from the server's base - // palette + subpalette overlays. The renderer applies these to - // palette-indexed textures (PFID_P8 / PFID_INDEX16) to get per-entity - // skin/hair/body colors and statue stone recoloring. Non-palette - // textures ignore the override. - AcDream.Core.World.PaletteOverride? paletteOverride = null; - if (spawn.SubPalettes is { Count: > 0 } spList) - { - var ranges = new AcDream.Core.World.PaletteOverride.SubPaletteRange[spList.Count]; - for (int i = 0; i < spList.Count; i++) - ranges[i] = new AcDream.Core.World.PaletteOverride.SubPaletteRange( - spList[i].SubPaletteId, spList[i].Offset, spList[i].Length); - paletteOverride = new AcDream.Core.World.PaletteOverride( - BasePaletteId: spawn.BasePaletteId ?? 0, - SubPalettes: ranges); - } - - AcDream.Core.World.PartOverride[] entityPartOverrides; - if (animPartChanges.Count == 0) - { - entityPartOverrides = Array.Empty(); - } - else - { - entityPartOverrides = new AcDream.Core.World.PartOverride[animPartChanges.Count]; - for (int i = 0; i < animPartChanges.Count; i++) - entityPartOverrides[i] = new AcDream.Core.World.PartOverride( - animPartChanges[i].PartIndex, animPartChanges[i].NewModelId); - } - - if (appearanceUpdate is { } visualUpdate) - { - AcDream.Core.World.WorldEntity existing = visualUpdate.Entity; - LiveEntityAppearanceCollisionUpdate? appearanceCollision = - LiveEntityAppearanceBinding.PrepareCollision( - _liveEntities!, - _liveEntityCollisionBuilder!, - existing, - setup, - collisionPartGfxObjIds, - spawn, - origin); - _wbEntitySpawnAdapter!.OnAppearanceChanged( - existing, - meshRefs, - entityPartOverrides, - () => - { - // Retail CSurface::SetTextureAndPalette (0x00535FB0) - // releases the prior combined ImgTex before installing the - // replacement. Mesh references for the replacement have - // already been acquired before this publication callback. - _textureCache!.ReleaseOwner(existing.Id); - existing.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides); - }, - () => - { - if (appearanceCollision is not null) - LiveEntityAppearanceBinding.CommitCollision( - _liveEntities, - _physicsEngine.ShadowObjects, - appearanceCollision); - existing.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); - if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax)) - existing.SetLocalBounds(appearanceMin, appearanceMax); - if (visualUpdate.Animation is { } animation) - LiveEntityAppearanceBinding.RebindAnimation( - animation, - existing, - setup, - scale, - animatedPartTemplate, - indexedPartAvailable); - _classificationCache.InvalidateEntity(existing.Id); - if (_liveEntities!.TryGetRecord(spawn.Guid, out LiveEntityRecord record) - && record.ProjectionKind is LiveEntityProjectionKind.Attached) - { - // The attachment controller composes child-local parts - // through the parent's current animated pose. Re-run - // that composition after replacing the child visual. - _equippedChildRenderer?.OnSpawn(spawn); - } - else - { - // SmartBox::UpdateVisualDesc mutates the existing - // PartArray. Publish those exact replacement part frames - // before another packet can execute an attached hook. - _effectPoses.PublishMeshRefs(existing); - _equippedChildRenderer?.OnPosePublished(spawn.Guid); - } - }); - return; - } - - if (!_liveEntities!.TryGetEffectProfile(spawn.Guid, out _)) - { - var effectProfile = spawn.Physics is { } physics - ? AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateLive(setup, physics) - : AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup); - _liveEntities.SetEffectProfile(spawn.Guid, effectProfile); - } - - bool createdProjection = false; - var entity = _liveEntities!.MaterializeLiveEntity( - spawn.Guid, - spawn.Position!.Value.LandblockId, - localId => - { - createdProjection = true; - var created = new AcDream.Core.World.WorldEntity - { - Id = localId, - ServerGuid = spawn.Guid, - SourceGfxObjOrSetupId = spawn.SetupTableId.Value, - Position = worldPos, - Rotation = rot, - MeshRefs = meshRefs, - PaletteOverride = paletteOverride, - PartOverrides = entityPartOverrides, - ParentCellId = spawn.Position.Value.LandblockId, - }; - created.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); - if (liveBounds.TryGet(out var createdMin, out var createdMax)) - created.SetLocalBounds(createdMin, createdMax); - return created; - }); - if (entity is null) - return; - - if (!createdProjection) - { - // A parented child already owns this incarnation's WorldEntity. - // Reuse it when a fresh Position returns the object to the world. - entity.SetPosition(worldPos); - entity.Rotation = rot; - entity.ParentCellId = spawn.Position.Value.LandblockId; - entity.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides); - entity.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); - if (liveBounds.TryGet(out var retainedMin, out var retainedMax)) - entity.SetLocalBounds(retainedMin, retainedMax); - _effectPoses.PublishMeshRefs(entity); - } - - // Retail CPhysicsObj::leave_world removes cell/shadow membership but - // retains PartArray and MovementManager. A Position after Pickup or - // parenting therefore re-enters with the same animation owner; do not - // replace its sequencer or replay initialization. - bool retainedAnimationRuntime = !createdProjection - && _animatedEntities.TryGetValue(entity.Id, out _); - - var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( - Id: entity.Id, - SourceId: entity.SourceGfxObjOrSetupId, - Position: entity.Position, - Rotation: entity.Rotation); - _worldGameState.Add(snapshot); - _worldEvents.UpsertCurrent(snapshot); - if (_liveEntities!.TryMarkWorldSpawnPublished(spawn.Guid)) - _worldEvents.FireEntitySpawned(snapshot); - - // Phase A.1: register entity into GpuWorldState so the next frame picks - // it up. Materialization parks the projection when its landblock is - // not loaded yet, then AddLandblock merges the same identity. - // MaterializeLiveEntity above performed the spatial projection. - _liveSpawnHydrated++; - - // Phase 6.6/6.7: remember the server-guid → WorldEntity mapping so - // UpdateMotion / UpdatePosition events can reseat this entity by guid. - // The GUID/local-id mapping is owned by LiveEntityRuntime. - - // The root now exists, so parent relations that arrived before this - // object's render projection can compose their child meshes. - _equippedChildRenderer?.OnWorldEntityRegistered(spawn.Guid); - - // Commit B 2026-04-29 — live-entity collision registration. - // The local player is the simulator (its PhysicsBody is the source of - // truth for our own movement), but retail still registers its resolved - // CPhysicsObj as a collision target for remote creatures. The player's - // own transition skips this entry by LocalEntityId / OBJECTINFO::object. - // Phantom-Setup entities (no CylSpheres / no Spheres / no Radius) - // are deliberately skipped — retail FUN's `FindObjCollisions` - // falls through to OK_TS for any object with no collision - // geometry (acclient_2013_pseudo_c.txt:276917,276987). - if (_liveEntities.TryGetRecord(spawn.Guid, out var liveRecord) - && _liveEntityCollisionBuilder!.Build( - entity, - setup, - collisionPartGfxObjIds, - spawn, - liveRecord, - origin) is { } collision) - { - AcDream.App.Physics.LiveEntityCollisionBuilder.Register( - _physicsEngine.ShadowObjects, - collision); - } - if (_liveEntities.TryGetRecord(spawn.Guid, out liveRecord)) - _projectileController?.TryBind( - liveRecord, - setup, - _physicsScriptGameTime, - _liveCenterX, - _liveCenterY); - - // Phase B.2: capture the server-sent MotionTableId for our own - // character so UpdatePlayerAnimation can pass it to GetIdleCycle. - // The Setup's DefaultMotionTable is often 0 for human characters; - // the real table comes from PhysicsDescriptionFlag.MTable. - if (spawn.Guid == _playerServerGuid && spawn.MotionTableId is not null) - _playerMotionTableId = spawn.MotionTableId; - - // Phase 6.4: register for per-frame playback if we resolved a real - // cycle with a non-zero framerate and at least two frames in the - // cycle (single-frame poses are static and don't need ticking). - // Diagnostic: log why we did / didn't register so we can tell - // which entities fall through the filter. - if (!retainedAnimationRuntime) - { - if (idleCycle is null) - _liveAnimRejectNoCycle++; - else if (idleCycle.Framerate == 0f) - _liveAnimRejectFramerate++; - else if (idleCycle.HighFrame <= idleCycle.LowFrame) - _liveAnimRejectSingleFrame++; - else if (idleCycle.Animation.PartFrames.Count <= 1) - _liveAnimRejectPartFrames++; - } - - if (!retainedAnimationRuntime - && idleCycle is not null && idleCycle.Framerate != 0f - && idleCycle.HighFrame > idleCycle.LowFrame - && idleCycle.Animation.PartFrames.Count > 1) - { - // Snapshot per-part identity from the hydrated meshRefs so the - // tick can rebuild MeshRefs without redoing AnimPartChanges or - // texture-override resolution every frame. - LiveAnimationPartTemplate[] template = animatedPartTemplate; - - // Create an AnimationSequencer if we can load the MotionTable. - AcDream.Core.Physics.AnimationSequencer? sequencer = null; - if (_animLoader is not null) - { - uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; - if (mtableId != 0) - { - var mtable = _dats.Get(mtableId); - if (mtable is not null) - { - sequencer = SpawnMotionInitializer.Create( - setup, mtable, _animLoader, spawn.MotionState); - } - } - } - - _animatedEntities[entity.Id] = new LiveEntityAnimationState - { - Entity = entity, - Setup = setup, - Animation = idleCycle.Animation, - LowFrame = Math.Max(0, idleCycle.LowFrame), - HighFrame = Math.Min(idleCycle.HighFrame, idleCycle.Animation.PartFrames.Count - 1), - Framerate = idleCycle.Framerate, - Scale = scale, - PartTemplate = template, - PartAvailability = indexedPartAvailable, - CurrFrame = idleCycle.LowFrame, - Sequencer = sequencer, - }; - - } - else if (!retainedAnimationRuntime && _animLoader is not null) - { - // Phase B.4c / #187 — reactive motion-table rescue. An entity - // whose REST pose is a static single frame fails the generic - // multi-frame-idle gate above, but may still carry a MotionTable - // with On/Off (or other) cycles the server drives reactively via - // UpdateMotion. Hinged doors, sliding doors, gates/portcullises, - // and disguised secret-passage props ("Magic Wall") all share - // this exact shape. #187: this branch used to be gated on - // `spawn.Name == "Door"`, which silently dropped every door-like - // object whose display name isn't literally "Door" — the - // production weenie data confirms Sliding Door / Portcullis / - // Gate / Magic Wall all carry the SAME WeenieType=Door + - // non-zero MotionTableId shape as a plain Door, differing only - // in name. Retail's own gate for creating a motion dispatcher is - // purely data-driven on the motion table id - // (CPartArray::SetMotionTableID 0x005186e0: `if (ebx != 0)`) — no - // WeenieType switch, no name check — so the `mtableId != 0` test - // just below is now the ENTIRE gate, matching retail exactly. - // Register with a seeded sequencer so the per-frame tick has - // frames to advance from frame 1 (without the seed, - // Sequencer.Advance(dt) returns no frames and the MeshRefs - // rebuild at line 7691 collapses the entity to origin). - // - // #204: this path is not door-specific. Corpses also arrive here - // because Dead is a persistent static rest pose. The old rescue - // hard-coded Door On/Off from PhysicsState, discarded the wire's - // authoritative Dead command, and left the corpse on Ready. Both - // spawn paths must seed the same wire MovementData through retail's - // description-then-enter-world lifecycle. - uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; - if (mtableId != 0) - { - var mtable = _dats.Get(mtableId); - if (mtable is not null) - { - var sequencer = SpawnMotionInitializer.Create( - setup, mtable, _animLoader, spawn.MotionState); - - LiveAnimationPartTemplate[] template = animatedPartTemplate; - - _animatedEntities[entity.Id] = new LiveEntityAnimationState - { - Entity = entity, - Setup = setup, - Animation = null!, // sequencer-driven; tick reads sequencer state - LowFrame = 0, - HighFrame = 0, - Framerate = 0f, - Scale = scale, - PartTemplate = template, - PartAvailability = indexedPartAvailable, - CurrFrame = 0, - Sequencer = sequencer, - }; - - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) - { - var initial = SpawnMotionInitializer.ResolvePlan(mtable, spawn.MotionState); - Console.WriteLine(System.FormattableString.Invariant( - $"[reactive-anim] registered guid=0x{spawn.Guid:X8} entityId=0x{entity.Id:X8} mtable=0x{mtableId:X8} initialStyle=0x{initial.Style:X8} initialCycle=0x{initial.Motion:X8}")); - } - } - } - } - - // CPartArray::InitDefaults (0x00518980) installs Setup.DefaultAnimation - // for every setup-backed PartArray, even when there is no MotionTable - // and no independently resolved idle cycle. Physics-Static owners are - // advanced by RetailStaticAnimatingObjectScheduler; a non-static live - // owner remains in the ordinary CPhysics object workset. - bool isPhysicsStatic = _liveEntities.TryGetRecord( - spawn.Guid, - out LiveEntityRecord animationRecord) - && (animationRecord.FinalPhysicsState - & AcDream.Core.Physics.PhysicsStateFlags.Static) != 0; - AcDream.Core.Physics.IAnimationLoader? setupDefaultLoader = _animLoader; - if (!retainedAnimationRuntime - && !_animatedEntities.TryGetValue(entity.Id, out _) - && setupDefaultLoader is not null - && (uint)setup.DefaultAnimation != 0) - { - var sequencer = new AcDream.Core.Physics.AnimationSequencer( - setup, - new DatReaderWriter.DBObjs.MotionTable(), - setupDefaultLoader); - if (sequencer.HasCurrentNode) - { - _animatedEntities[entity.Id] = new LiveEntityAnimationState - { - Entity = entity, - Setup = setup, - Animation = null!, - LowFrame = 0, - HighFrame = 0, - Framerate = 0f, - Scale = scale, - PartTemplate = animatedPartTemplate, - PartAvailability = indexedPartAvailable, - CurrFrame = 0, - Sequencer = sequencer, - }; - } - } - - // MotionDone ownership is established with the logical animation - // runtime, before EnterPlayerModeNow can dispatch SetPosition -> - // StopCompletely. That call may synchronously finish a zero-tick - // Ready entry; dropping its callback leaves CMotionInterp with an - // unmatched node that permanently starves later MoveTo/TurnTo work. - if (_animatedEntities.TryGetValue(entity.Id, out var registeredAnimation)) - { - if (_liveEntities.TryGetRecord(spawn.Guid, out LiveEntityRecord bindingRecord)) - _animationPresenter.PrepareAnimation(bindingRecord, registeredAnimation); - - if (isPhysicsStatic - && registeredAnimation.Sequencer is { } staticSequencer - && spawn.Position is { } staticPosition) - { - AcDream.Core.Physics.PhysicsBody staticBody = - _liveEntities.GetOrCreatePhysicsBody( - spawn.Guid, - incarnation => - { - var body = new AcDream.Core.Physics.PhysicsBody - { - Orientation = entity.Rotation, - }; - AcDream.App.Physics.RemotePhysicsBodyInitializer.Initialize( - body, - incarnation); - body.SnapToCell( - staticPosition.LandblockId, - entity.Position, - new System.Numerics.Vector3( - staticPosition.PositionX, - staticPosition.PositionY, - staticPosition.PositionZ)); - return body; - }); - _staticAnimationScheduler?.BindLiveOwner( - entity, - registeredAnimation, - staticBody); - } - } - - // Renderer, script owner, optional animation owner, collision body, - // and effect profile are now all installed. Only at this boundary may - // the mixed pre-Create F754/F755 FIFO replay against the local ID. - CompleteLiveEntityReady(spawn.Guid); - - // Dump a summary periodically so we can see drop breakdowns without - // waiting for a graceful shutdown. - if (dumpLiveSpawns && _liveSpawnReceived % 20 == 0) - { - Console.WriteLine( - $"live: animated={_animatedEntities.Count} " + - $"animReject: noCycle={_liveAnimRejectNoCycle} fr0={_liveAnimRejectFramerate} " + - $"1frame={_liveAnimRejectSingleFrame} partFrames={_liveAnimRejectPartFrames}"); - Console.WriteLine( - $"live: summary recv={_liveSpawnReceived} hydrated={_liveSpawnHydrated} " + - $"drops: noPos={_liveDropReasonNoPos} noSetup={_liveDropReasonNoSetup} " + - $"setupMissing={_liveDropReasonSetupDatMissing} noMesh={_liveDropReasonNoMeshRefs}"); - } - } - - /// - /// Triangle-aware terrain Z sample directly from a landblock's raw - /// heightmap. Used as the bilinear fallback in scenery hydration when - /// physics hasn't built a TerrainSurface for the landblock yet - /// (streaming race). Delegates to - /// - /// so this fallback and the player-physics path stay in lock-step on - /// sloped cells. - /// - /// - /// Issue #48: the previous in-place implementation here had its two - /// diagonal arms swapped (SWtoNE cells used the SEtoNW triangle test - /// and vice versa), so scenery on hilly terrain sat at a different Z - /// than the visible terrain mesh — a multi-meter offset in some - /// cells, the user-reported "floating trees" symptom. - /// - /// private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY) { uint landblockX = (block.Id >> 24) & 0xFFu; @@ -4230,13 +3060,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext return; } - lock (_datLock) + if (_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record)) { LiveEntityAppearanceUpdateState? appearanceState = LiveEntityAppearanceBinding.Capture(_liveEntities, update.Guid); - OnLiveEntitySpawnedLocked( + _liveEntityHydration!.ApplyAppearance( + record, newSpawn, - LiveProjectionPurpose.AppearanceMutation, appearanceState); } @@ -4785,19 +3615,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext entityHost.PositionManager.StickTo(targetGuid, radius, height); } - /// - /// Completes retail object construction in order: register the effect - /// owner, apply constructor/PhysicsDesc state transitions, then replay - /// SmartBox's queued F754/F755 blobs. - /// - private void CompleteLiveEntityReady(uint serverGuid) - { - if (_entityEffects?.PrepareLiveEntityOwner(serverGuid) != true) - return; - _liveEntityPresentation?.OnLiveEntityReady(serverGuid); - _entityEffects.ReplayPendingForLiveEntity(serverGuid); - } - private void ClearTargetForHiddenEntity(uint serverGuid) { if (_selectionInteractions is { } interactions) @@ -5973,9 +4790,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // readiness directly to this first accepted canonical Position before // translating it through the current world origin; projection recovery // below is a separate concern and may never be needed for UI-only state. - lock (_datLock) - TryInitializeLiveCenter(acceptedSpawn); - if (!IsCurrentPositionOwner()) + if (_liveEntityHydration?.EnsureWorldOrigin( + acceptedPositionRecord, + acceptedPositionAuthorityVersion, + acceptedSpawn) != true + || !IsCurrentPositionOwner()) return; var p = update.Position; @@ -6013,10 +4832,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { if (!IsCurrentPositionOwner()) return; - lock (_datLock) - OnLiveEntitySpawnedLocked( - acceptedSpawn, - LiveProjectionPurpose.SpatialRecovery); + _liveEntityHydration!.RecoverProjection( + acceptedPositionRecord, + acceptedPositionAuthorityVersion, + acceptedSpawn); }) ?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached; if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.Superseded @@ -6026,10 +4845,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext return; if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.NotAttached) { - lock (_datLock) - OnLiveEntitySpawnedLocked( - acceptedSpawn, - LiveProjectionPurpose.SpatialRecovery); + _liveEntityHydration!.RecoverProjection( + acceptedPositionRecord, + acceptedPositionAuthorityVersion, + acceptedSpawn); if (!IsCurrentPositionOwner()) return; } diff --git a/src/AcDream.App/Rendering/LiveEntityCreateSupersessionRecovery.cs b/src/AcDream.App/Rendering/LiveEntityCreateSupersessionRecovery.cs new file mode 100644 index 00000000..416d7aa4 --- /dev/null +++ b/src/AcDream.App/Rendering/LiveEntityCreateSupersessionRecovery.cs @@ -0,0 +1,105 @@ +using AcDream.App.World; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// Completes a fresher same-incarnation CreateObject after it interrupted an +/// active projection transaction. Logical entity/resource ownership is +/// retained; the exact current appearance is republished, the plugin/current +/// snapshot is refreshed, and the retained animation owner is synchronized in +/// that order. Every potentially reentrant stage is guarded by the monotonic +/// Create-integration authority. +/// +internal static class LiveEntityCreateSupersessionRecovery +{ + public static bool TryApply( + LiveEntityRuntime runtime, + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion, + Func captureAppearance, + Func publishAppearance, + Action publishCurrentSnapshot, + Action synchronizeAnimation) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(expectedRecord); + ArgumentNullException.ThrowIfNull(captureAppearance); + ArgumentNullException.ThrowIfNull(publishAppearance); + ArgumentNullException.ThrowIfNull(publishCurrentSnapshot); + ArgumentNullException.ThrowIfNull(synchronizeAnimation); + + if (!runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || captureAppearance() is not { } visualUpdate + || !runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !publishAppearance(visualUpdate) + || !runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return false; + } + + publishCurrentSnapshot(visualUpdate); + if (!runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return false; + } + + synchronizeAnimation(visualUpdate); + return runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion); + } +} + +/// +/// Reconciles the exceptional case where a fresher same-incarnation Create +/// interrupts initial PartArray construction. Completed owners already route +/// MovementData through SetObjectMovement and therefore retain their exact +/// sequencer, dispatch sink, and paired manager/interpreter queues. +/// +internal static class LiveEntityCreateAnimationSynchronization +{ + public static bool TrySynchronizeInterruptedInitialOwner( + LiveEntityRecord record, + LiveEntityAnimationState animation, + Animation? canonicalAnimation, + int canonicalLowFrame, + int canonicalHighFrame, + float canonicalFramerate, + MotionTable? motionTable, + CreateObject.ServerMotionState? wireState) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(animation); + if (record.InitialHydrationCompleted) + return false; + + if (canonicalAnimation is not null) + { + animation.Animation = canonicalAnimation; + animation.LowFrame = canonicalLowFrame; + animation.HighFrame = canonicalHighFrame; + animation.Framerate = canonicalFramerate; + animation.CurrFrame = canonicalLowFrame; + } + + if (animation.Sequencer is { } sequencer && motionTable is not null) + { + SpawnMotionInitializer.Reinitialize( + sequencer, + motionTable, + wireState); + } + return true; + } +} diff --git a/src/AcDream.App/Rendering/SpawnMotionInitializer.cs b/src/AcDream.App/Rendering/SpawnMotionInitializer.cs index 05df260b..75ac456d 100644 --- a/src/AcDream.App/Rendering/SpawnMotionInitializer.cs +++ b/src/AcDream.App/Rendering/SpawnMotionInitializer.cs @@ -41,6 +41,26 @@ internal static class SpawnMotionInitializer return sequencer; } + /// + /// Replays description-time MovementData into the same retained PartArray + /// sequencer. This is used only when a fresher same-incarnation CreateObject + /// interrupted initial hydration before the object crossed its ready edge; + /// completed objects consume the packet through SetObjectMovement instead. + /// + public static void Reinitialize( + AnimationSequencer sequencer, + MotionTable motionTable, + CreateObject.ServerMotionState? wireState) + { + ArgumentNullException.ThrowIfNull(sequencer); + ArgumentNullException.ThrowIfNull(motionTable); + Plan plan = ResolvePlan(motionTable, wireState); + sequencer.Reset(); + sequencer.InitializeState(); + sequencer.SetCycle(plan.Style, plan.Motion); + sequencer.Manager.HandleEnterWorld(); + } + internal static Plan ResolvePlan( MotionTable motionTable, CreateObject.ServerMotionState? wireState) diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 8fa4ae39..82e3e3a5 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -40,6 +40,7 @@ public sealed record RuntimeOptions( bool RetailCloseDegrades, bool DumpSceneryZ, bool DumpLiveSpawns, + bool DumpClothing, int? LegacyStreamRadius, bool RetailUi, string? AcDir, @@ -89,6 +90,7 @@ public sealed record RuntimeOptions( RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal), DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")), DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")), + DumpClothing: IsExactlyOne(env("ACDREAM_DUMP_CLOTHING")), // Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on // top of the quality preset's radii. Null when unset or invalid. LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs new file mode 100644 index 00000000..d361a7fb --- /dev/null +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -0,0 +1,613 @@ +using AcDream.App.Rendering; +using AcDream.Core.Items; +using AcDream.Core.Net; + +namespace AcDream.App.World; + +internal enum LiveProjectionPurpose +{ + LogicalRegistration, + SpatialRecovery, + CreateSupersessionRecovery, + AppearanceMutation, +} + +/// +/// DAT-backed projection boundary used by . +/// The implementation owns mesh/animation/collision construction, but never +/// owns or indexes live identity. +/// +internal interface ILiveEntityProjectionMaterializer +{ + bool TryMaterialize( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn canonicalSpawn, + LiveProjectionPurpose purpose, + ulong expectedCreateIntegrationVersion, + LiveEntityAppearanceUpdateState? appearanceUpdate = null); + + void ResetSessionState(); +} + +internal interface ILiveEntitySpawnRelationshipSink +{ + void OnSpawn(WorldSession.EntitySpawn spawn); +} + +internal interface ILiveEntityReadyPublisher +{ + bool Publish(LiveEntityRecord expectedRecord); +} + +internal readonly record struct LiveEntityReadyCandidate( + LiveEntityRecord Record, + ulong CreateIntegrationVersion); + +internal interface ILiveEntityNetworkUpdateSink +{ + void ApplySameGeneration(SameGenerationCreateObjectEvents events); +} + +/// +/// Single-assignment construction seam between hydration and the Slice-4F +/// network-update owner. A session cannot publish CreateObject until this is +/// bound. +/// +internal sealed class DeferredLiveEntityNetworkUpdateSink : ILiveEntityNetworkUpdateSink +{ + private ILiveEntityNetworkUpdateSink? _inner; + + public void Bind(ILiveEntityNetworkUpdateSink inner) + { + ArgumentNullException.ThrowIfNull(inner); + if (Interlocked.CompareExchange(ref _inner, inner, null) is not null) + throw new InvalidOperationException("The live-entity network sink is already bound."); + } + + public void ApplySameGeneration(SameGenerationCreateObjectEvents events) => + (_inner ?? throw new InvalidOperationException( + "The live-entity network sink must be bound before a session starts.")) + .ApplySameGeneration(events); +} + +internal sealed class DelegateLiveEntityNetworkUpdateSink( + Action apply) : ILiveEntityNetworkUpdateSink +{ + private readonly Action _apply = + apply ?? throw new ArgumentNullException(nameof(apply)); + + public void ApplySameGeneration(SameGenerationCreateObjectEvents events) => + _apply(events); +} + +internal readonly record struct LiveEntityOriginInitialization( + bool IsKnown, + IReadOnlyList AlreadyLoadedLandblocks); + +internal interface ILiveEntityWorldOriginCoordinator +{ + bool IsKnown { get; } + LiveEntityOriginInitialization TryInitialize(WorldSession.EntitySpawn spawn); +} + +/// +/// Owns the retail CreateObject integration transaction over the canonical +/// . It has no GUID dictionary: every callback +/// captures and revalidates the exact record supplied by the runtime. +/// +/// Retail anchors: SmartBox::HandleCreateObject @ 0x00454C80 and +/// ACCObjectMaint::CreateObject @ 0x00558870. +/// +internal sealed class LiveEntityHydrationController +{ + private readonly LiveEntityRuntime _runtime; + private readonly ClientObjectTable _objects; + private readonly object _datLock; + private readonly ILiveEntityProjectionMaterializer _materializer; + private readonly ILiveEntitySpawnRelationshipSink _relationships; + private readonly ILiveEntityReadyPublisher _ready; + private readonly ILiveEntityWorldOriginCoordinator _origin; + private readonly ILiveEntityNetworkUpdateSink _networkUpdates; + private readonly Action _publishTimestamps; + private readonly Func _playerGuid; + private readonly Action? _diagnostic; + + public LiveEntityHydrationController( + LiveEntityRuntime runtime, + ClientObjectTable objects, + object datLock, + ILiveEntityProjectionMaterializer materializer, + ILiveEntitySpawnRelationshipSink relationships, + ILiveEntityReadyPublisher ready, + ILiveEntityWorldOriginCoordinator origin, + ILiveEntityNetworkUpdateSink networkUpdates, + Action publishTimestamps, + Func playerGuid, + Action? diagnostic = null) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); + _materializer = materializer ?? throw new ArgumentNullException(nameof(materializer)); + _relationships = relationships ?? throw new ArgumentNullException(nameof(relationships)); + _ready = ready ?? throw new ArgumentNullException(nameof(ready)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _networkUpdates = networkUpdates ?? throw new ArgumentNullException(nameof(networkUpdates)); + _publishTimestamps = publishTimestamps + ?? throw new ArgumentNullException(nameof(publishTimestamps)); + _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _diagnostic = diagnostic; + } + + public void OnCreate(WorldSession.EntitySpawn spawn) + { + // DatCollection uses one mutable reader cursor shared with streaming. + // Registration, canonical reread, and projection construction remain + // one update-thread transaction under that lock. + lock (_datLock) + { + LiveEntityRegistrationResult registration = + _runtime.RegisterLiveEntity(spawn); + InboundCreateResult result = registration.Inbound; + if (result.Disposition is + AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration) + { + return; + } + + // An accepted retransmit can still be parked behind a retryable + // teardown tombstone. It owns no active record and therefore must + // not mutate retained qualities or route an update tail. + if (registration.Record is not { } record) + return; + ulong createIntegrationVersion = record.CreateIntegrationVersion; + + try + { + _publishTimestamps(spawn.Guid, result.Timestamps); + if (_runtime.IsCurrentCreateIntegration( + record, + createIntegrationVersion) + && ObjectTableWiring.ApplyEntitySpawn( + _objects, + spawn, + replaceGeneration: result.Disposition is + AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration, + accepting: () => _runtime.IsCurrentCreateIntegration( + record, + createIntegrationVersion)) + && _runtime.IsCurrentCreateIntegration( + record, + createIntegrationVersion)) + { + if (result.Disposition is + AcDream.Core.Physics.CreateObjectTimestampDisposition.ExistingGeneration) + { + if (registration.LogicalRegistrationCreated) + { + // A converged rollback tombstone can be repaired by + // a same-generation retransmit. Its accepted + // snapshot is already canonical, so construct from + // that snapshot rather than replaying a delta tail + // against owners that did not exist. + ProjectExact( + record, + result.Snapshot, + LiveProjectionPurpose.LogicalRegistration, + expectedCreateIntegrationVersion: + createIntegrationVersion); + } + else + { + if (result.SameGenerationEvents is { } refresh) + _networkUpdates.ApplySameGeneration(refresh); + + if (_runtime.IsCurrentCreateIntegration( + record, + createIntegrationVersion) + && (record.CreateProjectionSynchronizationPending + || !record.InitialHydrationCompleted)) + { + ProjectExact( + record, + record.Snapshot, + record.CreateProjectionSynchronizationPending + ? LiveProjectionPurpose.CreateSupersessionRecovery + : LiveProjectionPurpose.SpatialRecovery, + expectedCreateIntegrationVersion: + createIntegrationVersion); + } + } + } + else + { + ProjectExact( + record, + result.Snapshot, + LiveProjectionPurpose.LogicalRegistration, + expectedCreateIntegrationVersion: + createIntegrationVersion); + } + } + } + catch (Exception applyFailure) + when (registration.PriorGenerationCleanupFailure is not null) + { + throw new AggregateException( + $"Live entity 0x{spawn.Guid:X8} replacement cleanup and installation both failed.", + registration.PriorGenerationCleanupFailure, + applyFailure); + } + + if (registration.PriorGenerationCleanupFailure is { } cleanupFailure) + { + throw new AggregateException( + $"Prior incarnation of live entity 0x{spawn.Guid:X8} failed teardown after its replacement was installed.", + cleanupFailure); + } + } + } + + /// + /// Reprojects retained live objects after their landblock is loaded. An + /// fully hydrated record only rebuckets. A retained partial projection + /// resumes its exact initial transaction without registering logical + /// mesh/script resources a second time. + /// + public void OnLandblockLoaded(uint loadedLandblockId) + { + if (_runtime.Count == 0) + return; + + uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu; + LiveEntityRecord[] records = _runtime.Records + .Where(record => (record.ServerGuid != _playerGuid() + || !record.InitialHydrationCompleted + || record.CreateProjectionSynchronizationPending) + && record.ProjectionCellId != 0 + && ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical + && record.Snapshot.SetupTableId is not null) + .ToArray(); + if (records.Length == 0) + return; + + int projected = 0; + lock (_datLock) + { + foreach (LiveEntityRecord record in records) + { + if (!_runtime.IsCurrentRecord(record)) + continue; + + if (record.CreateProjectionSynchronizationPending) + { + if (ProjectExact( + record, + record.Snapshot, + LiveProjectionPurpose.CreateSupersessionRecovery)) + { + projected++; + } + } + else if (record.WorldEntity is not null + && record.InitialHydrationCompleted) + { + if (_runtime.RebucketLiveEntity( + record.ServerGuid, + record.ProjectionCellId)) + { + projected++; + } + } + else if (ProjectExact( + record, + record.Snapshot, + LiveProjectionPurpose.SpatialRecovery)) + { + projected++; + } + } + } + + if (projected > 0) + { + _diagnostic?.Invoke( + $"live: re-projected {projected} server object(s) into landblock 0x{loadedLandblockId:X8}"); + } + } + + public bool EnsureWorldOrigin( + LiveEntityRecord expectedRecord, + ulong positionAuthorityVersion, + WorldSession.EntitySpawn acceptedSpawn) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + lock (_datLock) + { + if (!_runtime.IsCurrentPositionAuthority( + expectedRecord, + positionAuthorityVersion)) + { + return false; + } + + InitializeOriginAndRecoverLoaded(acceptedSpawn); + return _runtime.IsCurrentPositionAuthority( + expectedRecord, + positionAuthorityVersion); + } + } + + public bool RecoverProjection( + LiveEntityRecord expectedRecord, + ulong positionAuthorityVersion, + WorldSession.EntitySpawn acceptedSpawn) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + lock (_datLock) + { + if (!_runtime.IsCurrentPositionAuthority( + expectedRecord, + positionAuthorityVersion)) + { + return false; + } + + return ProjectExact( + expectedRecord, + acceptedSpawn, + expectedRecord.CreateProjectionSynchronizationPending + ? LiveProjectionPurpose.CreateSupersessionRecovery + : LiveProjectionPurpose.SpatialRecovery) + && _runtime.IsCurrentPositionAuthority( + expectedRecord, + positionAuthorityVersion); + } + } + + public bool ApplyAppearance( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn acceptedSpawn, + LiveEntityAppearanceUpdateState? appearanceUpdate) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + lock (_datLock) + { + return ProjectExact( + expectedRecord, + acceptedSpawn, + appearanceUpdate is null + ? LiveProjectionPurpose.SpatialRecovery + : LiveProjectionPurpose.AppearanceMutation, + appearanceUpdate); + } + } + + public bool OnEntityReady(LiveEntityReadyCandidate candidate) + { + if (!PublishReady( + candidate.Record, + candidate.CreateIntegrationVersion)) + { + return false; + } + + return !candidate.Record.CreateProjectionSynchronizationPending + || _runtime.TryCompleteCreateProjectionSynchronization( + candidate.Record, + candidate.CreateIntegrationVersion); + } + + public void ResetSessionState() => _materializer.ResetSessionState(); + + private bool ProjectExact( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn acceptedSpawn, + LiveProjectionPurpose purpose, + LiveEntityAppearanceUpdateState? appearanceUpdate = null, + ulong? expectedCreateIntegrationVersion = null) + { + while (true) + { + ulong createIntegrationVersion = expectedCreateIntegrationVersion + ?? expectedRecord.CreateIntegrationVersion; + if (purpose is LiveProjectionPurpose.CreateSupersessionRecovery + && !_runtime.TryMarkCreateProjectionSynchronizationPending( + expectedRecord, + createIntegrationVersion)) + { + return false; + } + if (!_runtime.TryBeginProjectionHydration( + expectedRecord, + createIntegrationVersion)) + { + return false; + } + + bool retry; + bool result; + try + { + result = ProjectExactOnce( + expectedRecord, + acceptedSpawn, + purpose, + appearanceUpdate, + createIntegrationVersion); + } + finally + { + retry = _runtime.EndProjectionHydration(expectedRecord); + } + + if (!retry || !_runtime.IsCurrentRecord(expectedRecord)) + return result; + + // A fresher same-incarnation CreateObject arrived while this + // transaction owned the hydration edge. Resume in-place from the + // newest canonical snapshot; logical resources remain registered. + acceptedSpawn = expectedRecord.Snapshot; + purpose = LiveProjectionPurpose.CreateSupersessionRecovery; + appearanceUpdate = null; + expectedCreateIntegrationVersion = + expectedRecord.CreateIntegrationVersion; + } + } + + private bool ProjectExactOnce( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn acceptedSpawn, + LiveProjectionPurpose purpose, + LiveEntityAppearanceUpdateState? appearanceUpdate, + ulong createIntegrationVersion) + { + _relationships.OnSpawn(acceptedSpawn); + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion)) + return false; + + // A queued Parent event can synchronously mutate the canonical + // Position/Parent snapshot. Never continue from the stale argument. + WorldSession.EntitySpawn canonicalSpawn = expectedRecord.Snapshot; + InitializeOriginAndRecoverLoaded(canonicalSpawn); + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion) + || !_origin.IsKnown) + { + return false; + } + + // Retail's same-instance CreateObject branch completes a POSITION_TS + // with no world Position through DoParentEvent or DoPickupEvent, not + // through CPhysicsObj::enter_world. The relationship/update owners + // above have already committed that cell-less state. An attached + // child discharges the obligation at its exact EntityReady edge; + // pickup completes here after exact projection withdrawal. + if (canonicalSpawn.Position is null) + { + if (canonicalSpawn.ParentGuid is not null and not 0) + { + if (expectedRecord.InitialHydrationCompleted + && !expectedRecord.CreateProjectionSynchronizationPending) + { + return _runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion); + } + + if (expectedRecord.WorldEntity is null + || expectedRecord.ProjectionKind is not + LiveEntityProjectionKind.Attached + || !expectedRecord.IsSpatiallyProjected + || !PublishReady( + expectedRecord, + createIntegrationVersion)) + { + return false; + } + + return !expectedRecord.CreateProjectionSynchronizationPending + || _runtime.TryCompleteCreateProjectionSynchronization( + expectedRecord, + createIntegrationVersion); + } + + if (expectedRecord.FullCellId != 0 + || expectedRecord.IsSpatiallyProjected) + { + return false; + } + + if (expectedRecord.WorldEntity is null) + { + // A pickup can supersede initial hydration before a render + // projection exists. The logical snapshot/table state is + // already canonical; ready remains false so a later world + // Position still constructs and publishes the first owner. + return !expectedRecord.CreateProjectionSynchronizationPending + || _runtime.TryCompleteCreateProjectionSynchronization( + expectedRecord, + createIntegrationVersion); + } + + if (!PublishReady( + expectedRecord, + createIntegrationVersion)) + { + return false; + } + + return !expectedRecord.CreateProjectionSynchronizationPending + || _runtime.TryCompleteCreateProjectionSynchronization( + expectedRecord, + createIntegrationVersion); + } + + bool materialized = _materializer.TryMaterialize( + expectedRecord, + expectedRecord.Snapshot, + purpose, + createIntegrationVersion, + appearanceUpdate); + if (!materialized + || !_runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion) + || purpose is LiveProjectionPurpose.AppearanceMutation) + { + return materialized; + } + + if (!PublishReady( + expectedRecord, + createIntegrationVersion)) + { + return false; + } + + return purpose is not LiveProjectionPurpose.CreateSupersessionRecovery + || _runtime.TryCompleteCreateProjectionSynchronization( + expectedRecord, + createIntegrationVersion); + } + + private bool PublishReady( + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion) + { + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !_ready.Publish(expectedRecord) + || !_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return false; + } + + return _runtime.TryMarkInitialHydrationCompleted( + expectedRecord, + expectedCreateIntegrationVersion) + && _runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion); + } + + private void InitializeOriginAndRecoverLoaded( + WorldSession.EntitySpawn canonicalSpawn) + { + LiveEntityOriginInitialization initialization = + _origin.TryInitialize(canonicalSpawn); + if (!initialization.IsKnown) + return; + + // Preserve SmartBox's blocking world-entry ordering: already-resident + // cells are made available before the outer CreateObject completes. + for (int i = 0; i < initialization.AlreadyLoadedLandblocks.Count; i++) + OnLandblockLoaded(initialization.AlreadyLoadedLandblocks[i]); + } +} diff --git a/src/AcDream.App/World/LiveEntityHydrationPorts.cs b/src/AcDream.App/World/LiveEntityHydrationPorts.cs new file mode 100644 index 00000000..be5f9007 --- /dev/null +++ b/src/AcDream.App/World/LiveEntityHydrationPorts.cs @@ -0,0 +1,150 @@ +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.Core.Net; + +namespace AcDream.App.World; + +internal sealed class DelegateLiveEntitySpawnRelationshipSink( + Action onSpawn) : ILiveEntitySpawnRelationshipSink +{ + private readonly Action _onSpawn = + onSpawn ?? throw new ArgumentNullException(nameof(onSpawn)); + + public void OnSpawn(WorldSession.EntitySpawn spawn) => _onSpawn(spawn); +} + +internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher +{ + private readonly LiveEntityRuntime _runtime; + private readonly Func _prepareEffects; + private readonly Func _presentState; + private readonly Func _replayEffects; + + public LiveEntityReadyPublisher( + LiveEntityRuntime runtime, + EntityEffectController effects, + LiveEntityPresentationController presentation) + : this( + runtime, + record => effects.PrepareLiveEntityOwner(record.ServerGuid), + record => presentation.OnLiveEntityReady(record.ServerGuid), + record => effects.ReplayPendingForLiveEntity(record.ServerGuid)) + { + ArgumentNullException.ThrowIfNull(effects); + ArgumentNullException.ThrowIfNull(presentation); + } + + internal LiveEntityReadyPublisher( + LiveEntityRuntime runtime, + Func prepareEffects, + Func presentState, + Func replayEffects) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _prepareEffects = prepareEffects + ?? throw new ArgumentNullException(nameof(prepareEffects)); + _presentState = presentState + ?? throw new ArgumentNullException(nameof(presentState)); + _replayEffects = replayEffects + ?? throw new ArgumentNullException(nameof(replayEffects)); + } + + public bool Publish(LiveEntityRecord expectedRecord) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + ulong createIntegrationVersion = expectedRecord.CreateIntegrationVersion; + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion) + || !_prepareEffects(expectedRecord) + || !_runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion)) + return false; + + if (!_presentState(expectedRecord) + || !_runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion)) + { + return false; + } + + return _replayEffects(expectedRecord) + && _runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion); + } +} + +/// +/// Converts the first accepted local-player Position into the shared streaming +/// origin. Retail SmartBox::UseTime @ 0x00455410 blocks completion until +/// destination cells are ready; already-resident landblocks are returned to +/// hydration for the same synchronous recovery edge. +/// +internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginCoordinator +{ + private readonly LiveWorldOriginState _origin; + private readonly StreamingController _streaming; + private readonly GpuWorldState _worldState; + private readonly WorldRevealCoordinator _worldReveal; + private readonly Func _playerGuid; + private readonly Func _isSealedDungeonCell; + private readonly Action? _diagnostic; + + public LiveEntityWorldOriginCoordinator( + LiveWorldOriginState origin, + StreamingController streaming, + GpuWorldState worldState, + WorldRevealCoordinator worldReveal, + Func playerGuid, + Func isSealedDungeonCell, + Action? diagnostic = null) + { + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming)); + _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); + _worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)); + _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _isSealedDungeonCell = isSealedDungeonCell + ?? throw new ArgumentNullException(nameof(isSealedDungeonCell)); + _diagnostic = diagnostic; + } + + public bool IsKnown => _origin.IsKnown; + + public LiveEntityOriginInitialization TryInitialize( + WorldSession.EntitySpawn spawn) + { + if (spawn.Guid != _playerGuid() + || spawn.Position is not { } position) + { + return new(_origin.IsKnown, Array.Empty()); + } + + int lbX = (int)((position.LandblockId >> 24) & 0xFFu); + int lbY = (int)((position.LandblockId >> 16) & 0xFFu); + int oldCenterX = _origin.CenterX; + int oldCenterY = _origin.CenterY; + if (!_origin.TryInitialize(lbX, lbY)) + return new(true, Array.Empty()); + + if (lbX != oldCenterX || lbY != oldCenterY) + { + _diagnostic?.Invoke( + $"live: first player position — recentering streaming from ({oldCenterX},{oldCenterY}) " + + $"to ({lbX},{lbY}) @0x{position.LandblockId:X8}"); + } + + _streaming.InitializeKnownLoginCenter( + lbX, + lbY, + isSealedDungeon: _isSealedDungeonCell(position.LandblockId)); + _worldReveal.Begin(WorldRevealKind.Login); + + return new( + true, + _worldState.LoadedLandblockIds.ToArray()); + } +} diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index e544e380..0e8f33a2 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -258,7 +258,32 @@ public sealed class LiveEntityRecord internal ulong VectorAuthorityVersion { get; private set; } internal ulong VelocityAuthorityVersion { get; private set; } internal ulong MovementAuthorityVersion { get; private set; } + /// + /// Advances for every accepted CreateObject affecting this incarnation, + /// including a fresher same-INSTANCE_TS retransmit. App integration uses + /// it to distinguish two transactions that intentionally share identity. + /// + internal ulong CreateIntegrationVersion { get; private set; } = 1UL; public bool WorldSpawnPublished { get; internal set; } + /// + /// True only after the incarnation's projection, collision/animation + /// components, effect owner, state presentation, and pending-effect replay + /// have crossed the initial ready barrier. A non-null + /// alone is not sufficient: failures after + /// resource registration retain the same projection for exact retry. + /// + public bool InitialHydrationCompleted { get; internal set; } + /// + /// A fresher same-incarnation CreateObject interrupted projection after + /// logical ownership had already been established. The retained entity + /// must replay the newest canonical appearance/current snapshot/animation + /// before ordinary rebucketing can resume. This obligation deliberately + /// survives a failed recovery attempt. + /// + internal bool CreateProjectionSynchronizationPending { get; set; } + internal bool ProjectionHydrationInProgress { get; set; } + internal ulong ProjectionHydrationCreateVersion { get; set; } + internal bool ProjectionHydrationRetryRequested { get; set; } public LiveEntityProjectionKind ProjectionKind { get; internal set; } internal bool RuntimeComponentsTeardownCompleted { get; set; } internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; } @@ -320,6 +345,7 @@ public sealed class LiveEntityRecord VectorAuthorityVersion++; VelocityAuthorityVersion++; MovementAuthorityVersion++; + CreateIntegrationVersion++; } internal void SetChildNoDraw(bool noDraw) @@ -2031,6 +2057,111 @@ public sealed class LiveEntityRuntime return true; } + internal bool TryMarkInitialHydrationCompleted( + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (!IsCurrentRecord(expectedRecord) + || expectedRecord.CreateIntegrationVersion != expectedCreateIntegrationVersion + || expectedRecord.WorldEntity is null + || !expectedRecord.ResourcesRegistered) + { + return false; + } + + expectedRecord.InitialHydrationCompleted = true; + return true; + } + + internal bool TryMarkCreateProjectionSynchronizationPending( + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (!IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return false; + } + + expectedRecord.CreateProjectionSynchronizationPending = true; + return true; + } + + internal bool TryCompleteCreateProjectionSynchronization( + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (!IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return false; + } + + expectedRecord.CreateProjectionSynchronizationPending = false; + return true; + } + + internal bool TryBeginProjectionHydration( + LiveEntityRecord expectedRecord, + ulong? expectedCreateIntegrationVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (!IsCurrentRecord(expectedRecord) + || (expectedCreateIntegrationVersion is { } expected + && expectedRecord.CreateIntegrationVersion != expected)) + { + return false; + } + + if (expectedRecord.ProjectionHydrationInProgress) + { + if (expectedRecord.CreateIntegrationVersion + != expectedRecord.ProjectionHydrationCreateVersion) + { + expectedRecord.ProjectionHydrationRetryRequested = true; + } + return false; + } + + expectedRecord.ProjectionHydrationInProgress = true; + expectedRecord.ProjectionHydrationCreateVersion = + expectedRecord.CreateIntegrationVersion; + expectedRecord.ProjectionHydrationRetryRequested = false; + return true; + } + + internal bool EndProjectionHydration(LiveEntityRecord expectedRecord) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + bool retry = IsCurrentRecord(expectedRecord) + && (expectedRecord.ProjectionHydrationRetryRequested + || expectedRecord.CreateIntegrationVersion + != expectedRecord.ProjectionHydrationCreateVersion); + if (retry) + { + // Persist the exact replay obligation at the point version drift + // is observed. The stale projection body may be unwinding through + // an exception and therefore never reach the controller's next + // retry-loop iteration. + expectedRecord.CreateProjectionSynchronizationPending = true; + } + expectedRecord.ProjectionHydrationInProgress = false; + expectedRecord.ProjectionHydrationCreateVersion = 0UL; + expectedRecord.ProjectionHydrationRetryRequested = false; + return retry; + } + + internal bool IsCurrentCreateIntegration( + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion) => + IsCurrentRecord(expectedRecord) + && expectedRecord.CreateIntegrationVersion == expectedCreateIntegrationVersion; + public void Clear() { if (_isClearing || _isRegisteringResources) @@ -2586,6 +2717,11 @@ public sealed class LiveEntityRuntime record.IsSpatiallyProjected = false; record.IsSpatiallyVisible = false; record.WorldSpawnPublished = false; + record.InitialHydrationCompleted = false; + record.CreateProjectionSynchronizationPending = false; + record.ProjectionHydrationInProgress = false; + record.ProjectionHydrationCreateVersion = 0UL; + record.ProjectionHydrationRetryRequested = false; record.WorldEntity = null; } finally diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index b204bb5c..17ba4e5b 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -101,16 +101,31 @@ public static class ObjectTableWiring /// owning runtime accepts its physics generation. Internal for a /// socket-free conformance test of the subscription body. /// - public static void ApplyEntitySpawn( + public static bool ApplyEntitySpawn( ClientObjectTable table, WorldSession.EntitySpawn spawn, - bool replaceGeneration = false) + bool replaceGeneration = false, + Func? accepting = null) { + ArgumentNullException.ThrowIfNull(table); + if (accepting?.Invoke() == false) + return false; + WeenieData data = ToWeenieData(spawn); if (replaceGeneration) - table.ReplaceGeneration(data, spawn.InstanceSequence); + { + return table.ReplaceGeneration( + data, + spawn.InstanceSequence, + accepting) is not null; + } else table.Ingest(data); + + // Ingest publishes ObjectAdded/ObjectUpdated synchronously. A nested + // replacement wins; report the invalidated outer application so its + // remaining CreateObject tail cannot run. + return accepting?.Invoke() != false; } /// diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 0093e68b..3a5c3d87 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -576,7 +576,10 @@ public sealed class ClientObjectTable /// Atomically replaces the retained qualities for a newer server object /// incarnation while publishing a generation-specific teardown event. /// - public ClientObject ReplaceGeneration(WeenieData data, ushort generation) + public ClientObject? ReplaceGeneration( + WeenieData data, + ushort generation, + Func? canInstallReplacement = null) { RemoveCore( data.Guid, @@ -584,6 +587,13 @@ public sealed class ClientObjectTable generation, notifyObjectRemoved: true); + // Removal observers may synchronously accept a newer CreateObject for + // this GUID. The outer generation must not resume and merge its stale + // qualities into that replacement. The authoritative live-identity + // owner supplies the exact-incarnation predicate. + if (canInstallReplacement?.Invoke() == false) + return null; + return Ingest(data); } diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs new file mode 100644 index 00000000..6ceb0d5c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs @@ -0,0 +1,439 @@ +using System.Numerics; +using AcDream.App.Rendering; +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.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.App.Tests.Rendering; + +public sealed class LiveEntityCreateSupersessionRecoveryTests +{ + [Fact] + public void Recovery_RepublishesAppearanceThenCurrentSnapshotThenReplacesAnimation() + { + var operations = new List(); + var resources = new CountingResources(); + var runtime = Runtime(resources); + LiveEntityRecord record = RegisterAndMaterialize(runtime); + ulong version = record.CreateIntegrationVersion; + ulong installedAnimationVersion = 0; + + bool recovered = LiveEntityCreateSupersessionRecovery.TryApply( + runtime, + record, + version, + captureAppearance: () => + { + operations.Add("capture"); + return new LiveEntityAppearanceUpdateState(record.WorldEntity!, null); + }, + publishAppearance: _ => + { + operations.Add("appearance"); + return true; + }, + publishCurrentSnapshot: _ => operations.Add("current"), + synchronizeAnimation: _ => + { + operations.Add("animation"); + installedAnimationVersion = version; + }); + + Assert.True(recovered); + Assert.Equal(["capture", "appearance", "current", "animation"], operations); + Assert.Equal(version, installedAnimationVersion); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + } + + [Fact] + public void Recovery_CreateVersionAdvanceDuringAppearance_StopsLaterOwners() + { + var operations = new List(); + var resources = new CountingResources(); + var runtime = Runtime(resources); + LiveEntityRecord record = RegisterAndMaterialize(runtime); + ulong staleVersion = record.CreateIntegrationVersion; + + bool recovered = LiveEntityCreateSupersessionRecovery.TryApply( + runtime, + record, + staleVersion, + captureAppearance: () => + { + operations.Add("capture"); + return new LiveEntityAppearanceUpdateState(record.WorldEntity!, null); + }, + publishAppearance: _ => + { + operations.Add("appearance"); + runtime.RegisterLiveEntity(Spawn() with { Name = "fresher" }); + return true; + }, + publishCurrentSnapshot: _ => operations.Add("current"), + synchronizeAnimation: _ => operations.Add("animation")); + + Assert.False(recovered); + Assert.Equal(["capture", "appearance"], operations); + Assert.Same(record, AssertRecord(runtime)); + Assert.Equal(staleVersion + 1, record.CreateIntegrationVersion); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Recovery_FailureAtEachPublicationStage_CanReplayOnRetainedOwner( + int failingStage) + { + var resources = new CountingResources(); + var runtime = Runtime(resources); + LiveEntityRecord record = RegisterAndMaterialize(runtime); + ulong version = record.CreateIntegrationVersion; + ulong appearanceVersion = 1; + ulong currentVersion = 1; + ulong animationVersion = 1; + bool fail = true; + void FailOnce(int stage) + { + if (fail && failingStage == stage) + { + fail = false; + throw new InvalidOperationException($"fixture stage {stage} failure"); + } + } + + bool Apply() => LiveEntityCreateSupersessionRecovery.TryApply( + runtime, + record, + version, + captureAppearance: () => + new LiveEntityAppearanceUpdateState(record.WorldEntity!, null), + publishAppearance: _ => + { + appearanceVersion = version; + FailOnce(0); + return true; + }, + publishCurrentSnapshot: _ => + { + currentVersion = version; + FailOnce(1); + }, + synchronizeAnimation: _ => + { + animationVersion = version; + FailOnce(2); + }); + + Assert.Throws(() => Apply()); + Assert.True(Apply()); + + Assert.Equal(version, appearanceVersion); + Assert.Equal(version, currentVersion); + Assert.Equal(version, animationVersion); + Assert.Same(record, AssertRecord(runtime)); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + } + + [Fact] + public void CompletedOwner_PreservesSequencerSinkAndPendingQueue() + { + var runtime = Runtime(new CountingResources()); + LiveEntityRecord record = RegisterAndMaterialize(runtime); + record.InitialHydrationCompleted = true; + (Setup setup, MotionTable table, Loader loader) = MotionAssets(); + AnimationSequencer sequencer = SpawnMotionInitializer.Create( + setup, + table, + loader, + Wire(Ready)); + var animation = AnimationState(record.WorldEntity!, setup, sequencer); + var body = MotionBody(); + var interpreter = new MotionInterpreter(body); + var sink = new MotionTableDispatchSink(sequencer); + interpreter.DefaultSink = sink; + sequencer.MotionDoneTarget = interpreter.MotionDone; + interpreter.DoMotion(Walk, new MovementParameters()); + int pendingBefore = sequencer.Manager.PendingAnimations.Count(); + + bool reinitialized = LiveEntityCreateAnimationSynchronization + .TrySynchronizeInterruptedInitialOwner( + record, + animation, + canonicalAnimation: null, + canonicalLowFrame: 0, + canonicalHighFrame: 0, + canonicalFramerate: 0f, + motionTable: table, + wireState: Wire(Ready)); + + Assert.False(reinitialized); + Assert.Same(sequencer, animation.Sequencer); + Assert.Equal(pendingBefore, sequencer.Manager.PendingAnimations.Count()); + Assert.True(interpreter.MotionsPending()); + Assert.True(sink.ApplyMotion(Ready, 1f)); + Assert.Equal(Ready, sequencer.CurrentMotion); + } + + [Fact] + public void CompletedLegacyOwner_PreservesAnimationAndCurrentPhase() + { + var runtime = Runtime(new CountingResources()); + LiveEntityRecord record = RegisterAndMaterialize(runtime); + record.InitialHydrationCompleted = true; + var setup = new Setup(); + var retainedAnimation = new Animation(); + var canonicalAnimation = new Animation(); + var state = new LiveEntityAnimationState + { + Entity = record.WorldEntity!, + Setup = setup, + Animation = retainedAnimation, + LowFrame = 2, + HighFrame = 11, + Framerate = 12f, + Scale = 1f, + PartTemplate = [], + PartAvailability = [], + CurrFrame = 7.25f, + Sequencer = null, + }; + + bool synchronized = LiveEntityCreateAnimationSynchronization + .TrySynchronizeInterruptedInitialOwner( + record, + state, + canonicalAnimation, + canonicalLowFrame: 0, + canonicalHighFrame: 4, + canonicalFramerate: 30f, + motionTable: null, + wireState: null); + + Assert.False(synchronized); + Assert.Same(retainedAnimation, state.Animation); + Assert.Equal(2, state.LowFrame); + Assert.Equal(11, state.HighFrame); + Assert.Equal(12f, state.Framerate); + Assert.Equal(7.25f, state.CurrFrame); + } + + [Fact] + public void InterruptedInitialOwner_ReinitializesSameSequencerAndDrainsPairedQueues() + { + var runtime = Runtime(new CountingResources()); + LiveEntityRecord record = RegisterAndMaterialize(runtime); + Assert.False(record.InitialHydrationCompleted); + (Setup setup, MotionTable table, Loader loader) = MotionAssets(); + AnimationSequencer sequencer = SpawnMotionInitializer.Create( + setup, + table, + loader, + Wire(Ready)); + var animation = AnimationState(record.WorldEntity!, setup, sequencer); + var body = MotionBody(); + var interpreter = new MotionInterpreter(body); + var sink = new MotionTableDispatchSink(sequencer); + interpreter.DefaultSink = sink; + sequencer.MotionDoneTarget = interpreter.MotionDone; + interpreter.DoMotion(Walk, new MovementParameters()); + Assert.True(interpreter.MotionsPending()); + Assert.NotEmpty(sequencer.Manager.PendingAnimations); + + bool reinitialized = LiveEntityCreateAnimationSynchronization + .TrySynchronizeInterruptedInitialOwner( + record, + animation, + canonicalAnimation: null, + canonicalLowFrame: 0, + canonicalHighFrame: 0, + canonicalFramerate: 0f, + motionTable: table, + wireState: Wire(Ready)); + + Assert.True(reinitialized); + Assert.Same(sequencer, animation.Sequencer); + Assert.False(interpreter.MotionsPending()); + Assert.Empty(sequencer.Manager.PendingAnimations); + Assert.Equal(Ready, sequencer.CurrentMotion); + Assert.True(sink.ApplyMotion(Walk, 1f)); + Assert.Equal(Walk, sequencer.CurrentMotion); + } + + private const uint Guid = 0x70000061u; + private const uint Cell = 0x01010001u; + private const uint NonCombat = 0x8000003Du; + private const uint Ready = 0x41000003u; + private const uint Walk = 0x45000005u; + private const uint ReadyAnimation = 0x03000001u; + private const uint WalkAnimation = 0x03000002u; + + private static LiveEntityRuntime Runtime(CountingResources resources) + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty())); + return new LiveEntityRuntime( + spatial, + resources, + new DelegateLiveEntityRuntimeComponentLifecycle(_ => { })); + } + + private static LiveEntityRecord RegisterAndMaterialize(LiveEntityRuntime runtime) + { + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn()).Record!; + runtime.MaterializeLiveEntity( + Guid, + Cell, + id => new WorldEntity + { + Id = id, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [], + ParentCellId = Cell, + }); + return record; + } + + private static LiveEntityRecord AssertRecord(LiveEntityRuntime runtime) + { + Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + return record; + } + + private static WorldSession.EntitySpawn Spawn() => new( + Guid, + new CreateObject.ServerPosition( + Cell, + 0f, + 0f, + 0f, + 1f, + 0f, + 0f, + 0f), + 0x02000001u, + [], + [], + [], + BasePaletteId: null, + ObjScale: null, + Name: "supersession fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: 1); + + private static LiveEntityAnimationState AnimationState( + WorldEntity entity, + Setup setup, + AnimationSequencer sequencer) => new() + { + Entity = entity, + Setup = setup, + Animation = new Animation(), + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = 1f, + PartTemplate = [], + PartAvailability = [], + Sequencer = sequencer, + }; + + private static PhysicsBody MotionBody() => new() + { + InWorld = true, + State = PhysicsStateFlags.ReportCollisions, + TransientState = TransientStateFlags.Contact + | TransientStateFlags.OnWalkable + | TransientStateFlags.Active, + }; + + private static CreateObject.ServerMotionState Wire(uint command) => new( + Stance: (ushort)(NonCombat & 0xFFFFu), + ForwardCommand: (ushort)(command & 0xFFFFu)); + + private static (Setup Setup, MotionTable Table, Loader Loader) MotionAssets() + { + var setup = new Setup(); + setup.Parts.Add(0x01000001u); + setup.DefaultScale.Add(Vector3.One); + var loader = new Loader(); + loader.Add(ReadyAnimation, AnimationWithFrames(4)); + loader.Add(WalkAnimation, AnimationWithFrames(6)); + var table = new MotionTable + { + DefaultStyle = (DRWMotionCommand)NonCombat, + }; + table.StyleDefaults[(DRWMotionCommand)NonCombat] = + (DRWMotionCommand)Ready; + table.Cycles[(int)((NonCombat << 16) | (Ready & 0xFFFFFFu))] = + Motion(ReadyAnimation); + table.Cycles[(int)((NonCombat << 16) | (Walk & 0xFFFFFFu))] = + Motion(WalkAnimation); + return (setup, table, loader); + } + + private static Animation AnimationWithFrames(int count) + { + var animation = new Animation(); + for (int i = 0; i < count; i++) + { + var frame = new AnimationFrame(1); + frame.Frames.Add(new Frame + { + Origin = Vector3.Zero, + Orientation = Quaternion.Identity, + }); + animation.PartFrames.Add(frame); + } + return animation; + } + + private static MotionData Motion(uint animationId) + { + var data = new MotionData(); + QualifiedDataId qualified = animationId; + data.Anims.Add(new AnimData + { + AnimId = qualified, + LowFrame = 0, + HighFrame = -1, + Framerate = 30f, + }); + return data; + } + + private sealed class Loader : IAnimationLoader + { + private readonly Dictionary _animations = []; + public void Add(uint id, Animation animation) => _animations[id] = animation; + public Animation? LoadAnimation(uint id) => + _animations.GetValueOrDefault(id); + } + + private sealed class CountingResources : ILiveEntityResourceLifecycle + { + public int RegisterCount { get; private set; } + public int UnregisterCount { get; private set; } + public void Register(WorldEntity entity) => RegisterCount++; + public void Unregister(WorldEntity entity) => UnregisterCount++; + } +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 98b177b1..6aecc398 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -38,6 +38,7 @@ public sealed class RuntimeOptionsTests // Default-on: RetailCloseDegrades is true unless explicitly disabled. Assert.True(opts.RetailCloseDegrades); Assert.False(opts.DumpSceneryZ); + Assert.False(opts.DumpClothing); Assert.Null(opts.LegacyStreamRadius); Assert.False(opts.UiProbeDump); Assert.Null(opts.UiProbeScript); @@ -190,6 +191,7 @@ public sealed class RuntimeOptionsTests ["ACDREAM_NO_AUDIO"] = "1", ["ACDREAM_ENABLE_SKY_PES"] = "1", ["ACDREAM_DUMP_SCENERY_Z"] = "1", + ["ACDREAM_DUMP_CLOTHING"] = "1", })); Assert.True(allOn.DevTools); Assert.True(allOn.UncappedRendering); @@ -197,6 +199,7 @@ public sealed class RuntimeOptionsTests Assert.True(allOn.NoAudio); Assert.True(allOn.EnableSkyPesDebug); Assert.True(allOn.DumpSceneryZ); + Assert.True(allOn.DumpClothing); // Any non-"1" value leaves them off, matching the // string.Equals(env, "1", StringComparison.Ordinal) check. @@ -208,6 +211,7 @@ public sealed class RuntimeOptionsTests ["ACDREAM_NO_AUDIO"] = "2", ["ACDREAM_ENABLE_SKY_PES"] = "on", ["ACDREAM_DUMP_SCENERY_Z"] = " 1", + ["ACDREAM_DUMP_CLOTHING"] = "true", })); Assert.False(anyOther.DevTools); Assert.False(anyOther.UncappedRendering); @@ -215,6 +219,7 @@ public sealed class RuntimeOptionsTests Assert.False(anyOther.NoAudio); Assert.False(anyOther.EnableSkyPesDebug); Assert.False(anyOther.DumpSceneryZ); + Assert.False(anyOther.DumpClothing); } [Fact] diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs index 7cf0d0c6..354a888e 100644 --- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -17,6 +17,11 @@ public sealed class GameWindowLiveEntityCompositionTests [InlineData("LeaveWorldLiveEntityRuntimeComponents")] [InlineData("WithdrawLiveEntityWorldProjection")] [InlineData("RebindAnimatedEntityForAppearance")] + [InlineData("OnLiveEntitySpawned")] + [InlineData("OnLiveEntitySpawnedLocked")] + [InlineData("RehydrateServerEntitiesForLandblock")] + [InlineData("TryInitializeLiveCenter")] + [InlineData("CompleteLiveEntityReady")] public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName) { Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation)); @@ -36,6 +41,8 @@ public sealed class GameWindowLiveEntityCompositionTests [InlineData(typeof(LiveEntityProjectionWithdrawalController))] [InlineData(typeof(LocalPlayerShadowState))] [InlineData(typeof(LiveEntityAppearanceBinding))] + [InlineData(typeof(LiveEntityHydrationController))] + [InlineData(typeof(DatLiveEntityProjectionMaterializer))] public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType) { foreach (FieldInfo field in helperType.GetFields( diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs new file mode 100644 index 00000000..9cff0e04 --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -0,0 +1,1413 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Tests.World; + +public sealed class LiveEntityHydrationControllerTests +{ + [Fact] + public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization() + { + using var fixture = new Fixture(originKnown: true); + WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1); + fixture.Materializer.BeforeMaterialize = (_, _) => + Assert.NotNull(fixture.Objects.Get(Guid)); + + fixture.Controller.OnCreate(spawn); + + Assert.Equal( + ["timestamps:1", "relationship:1", "materialize:LogicalRegistration:1", "ready"], + fixture.Operations); + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + Assert.NotNull(record.WorldEntity); + Assert.True(record.InitialHydrationCompleted); + Assert.Equal(1, fixture.Resources.RegisterCount); + } + + [Fact] + public void UnknownOrigin_DefersFirstMaterializationUntilLandblockRecovery() + { + using var fixture = new Fixture(originKnown: false); + + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + Assert.Null(record.WorldEntity); + Assert.Equal(0, fixture.Resources.RegisterCount); + Assert.Empty(fixture.Materializer.Calls); + + fixture.Origin.IsKnownValue = true; + fixture.Controller.OnLandblockLoaded(Cell); + + Assert.NotNull(record.WorldEntity); + Assert.Single(fixture.Materializer.Calls); + Assert.Equal(LiveProjectionPurpose.SpatialRecovery, fixture.Materializer.Calls[0].Purpose); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void InitialPlayerOriginRecovery_DoesNotReenterSameHydrationTransaction() + { + using var fixture = new Fixture(originKnown: true, playerGuid: Guid); + fixture.Origin.AlreadyLoadedLandblocks = [Cell]; + + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + + Assert.True(fixture.Record.InitialHydrationCompleted); + Assert.Single(fixture.Materializer.Calls); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void LoadedCallback_RebucketsMaterializedIdentityWithoutReactivation() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity entity = record.WorldEntity!; + + fixture.Controller.OnLandblockLoaded(Cell); + + Assert.Same(entity, record.WorldEntity); + Assert.Single(fixture.Materializer.Calls); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void AppearanceAfterDeferredProjection_FirstMaterializesAndPublishesReady() + { + using var fixture = new Fixture(originKnown: true); + fixture.Materializer.SkipMaterializationCount = 1; + WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1); + fixture.Controller.OnCreate(spawn); + LiveEntityRecord record = fixture.Record; + Assert.Null(record.WorldEntity); + + Assert.True(fixture.Controller.ApplyAppearance( + record, + record.Snapshot, + appearanceUpdate: null)); + + Assert.NotNull(record.WorldEntity); + Assert.Equal(2, fixture.Materializer.Calls.Count); + Assert.Equal(LiveProjectionPurpose.SpatialRecovery, fixture.Materializer.Calls[1].Purpose); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void SameOlderNewerAndWrappedGenerations_RouteOrReplaceExactlyOnce() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 0xFFFF, PositionSequence: 1)); + LiveEntityRecord first = fixture.Record; + + fixture.Controller.OnCreate(Spawn(Generation: 0xFFFF, PositionSequence: 2)); + Assert.Equal(1, fixture.Network.ApplyCount); + Assert.Same(first, fixture.Record); + + fixture.Controller.OnCreate(Spawn(Generation: 0xFFFE, PositionSequence: 3)); + Assert.Equal(1, fixture.Network.ApplyCount); + Assert.Same(first, fixture.Record); + + fixture.Controller.OnCreate(Spawn(Generation: 0, PositionSequence: 4)); + + Assert.NotSame(first, fixture.Record); + Assert.Equal((ushort)0, fixture.Record.Generation); + Assert.Equal(2, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Resources.UnregisterCount); + Assert.Equal(2, fixture.Ready.PublishCount); + } + + [Fact] + public void RelationshipCallback_ReplacesGuidAndSupersedesOuterProjection() + { + using var fixture = new Fixture(originKnown: true); + bool replaced = false; + fixture.Relationships.OnSpawnAction = spawn => + { + if (!replaced && spawn.InstanceSequence == 1) + { + replaced = true; + fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 2)); + } + }; + + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + + Assert.Equal((ushort)2, fixture.Record.Generation); + Assert.Single(fixture.Materializer.Calls); + Assert.Equal((ushort)2, fixture.Materializer.Calls[0].Generation); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void RegistrationFailure_RollsBackAndLandblockRecoveryRetriesOnce() + { + var resources = new FailingOnceResources(); + using var fixture = new Fixture(originKnown: true, resources); + + Assert.Throws(() => + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1))); + + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + Assert.Null(record.WorldEntity); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + + fixture.Controller.OnLandblockLoaded(Cell); + + Assert.NotNull(record.WorldEntity); + Assert.Equal(2, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void SameGenerationRetransmit_RecoversAfterRegistrationTombstoneConverges() + { + var resources = new FailingRegisterAndRollbackOnceResources(); + using var fixture = new Fixture(originKnown: true, resources); + WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1); + + Assert.Throws(() => fixture.Controller.OnCreate(spawn)); + Assert.Equal(0, fixture.Runtime.Count); + Assert.Equal(1, fixture.Runtime.PendingTeardownCount); + Assert.Equal(1, fixture.Runtime.RetryPendingTeardowns()); + + fixture.Controller.OnCreate(spawn); + + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord recovered)); + Assert.NotNull(recovered.WorldEntity); + Assert.True(recovered.InitialHydrationCompleted); + Assert.Equal(2, resources.RegisterCount); + Assert.Equal(2, resources.UnregisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void PriorGenerationCleanupFailure_ReportsAfterReplacementIsFullyInstalled() + { + var resources = new FailingUnregisterOnceResources(); + using var fixture = new Fixture(originKnown: true, resources); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + + Assert.Throws(() => fixture.Controller.OnCreate(Spawn( + Generation: 2, + PositionSequence: 2, + Name: "replacement"))); + + Assert.Equal((ushort)2, fixture.Record.Generation); + Assert.True(fixture.Record.InitialHydrationCompleted); + Assert.Equal("replacement", fixture.Objects.Get(Guid)!.Name); + Assert.Equal(1, fixture.Runtime.PendingTeardownCount); + Assert.Equal(1, fixture.Runtime.RetryPendingTeardowns()); + Assert.Equal(0, fixture.Runtime.PendingTeardownCount); + } + + [Fact] + public void PartialProjection_IsRetriedInsteadOfMistakenForCompletedHydration() + { + using var fixture = new Fixture(originKnown: true, playerGuid: Guid); + bool failOnce = true; + fixture.Materializer.AfterMaterialize = (_, _) => + { + if (!failOnce) + return; + failOnce = false; + throw new InvalidOperationException("fixture post-registration failure"); + }; + + Assert.Throws(() => + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1))); + + LiveEntityRecord record = fixture.Record; + WorldEntity partial = Assert.IsType(record.WorldEntity); + Assert.False(record.InitialHydrationCompleted); + + // Local-player records ordinarily do not rebucket from streaming + // callbacks; an incomplete initial transaction must still recover. + fixture.Controller.OnLandblockLoaded(Cell); + + Assert.Same(partial, record.WorldEntity); + Assert.True(record.InitialHydrationCompleted); + Assert.Equal(2, fixture.Materializer.Calls.Count); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void FailedReadyBarrier_RetriesSameProjectionOnRetransmit() + { + using var fixture = new Fixture(originKnown: true); + fixture.Ready.FailPublishCount = 1; + + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity partial = Assert.IsType(record.WorldEntity); + Assert.False(record.InitialHydrationCompleted); + + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 2)); + + Assert.Same(partial, record.WorldEntity); + Assert.True(record.InitialHydrationCompleted); + Assert.Equal(2, fixture.Materializer.Calls.Count); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(2, fixture.Ready.PublishCount); + } + + [Fact] + public void TimestampCallbackReplacement_StopsOuterObjectAndProjectionTail() + { + using var fixture = new Fixture(originKnown: true); + bool replaced = false; + fixture.PublishTimestampsAction = (_, timestamps) => + { + if (replaced || timestamps.Instance != 1) + return; + replaced = true; + fixture.Controller.OnCreate(Spawn( + Generation: 2, + PositionSequence: 2, + Name: "generation 2")); + }; + + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 1, + Name: "generation 1")); + + Assert.Equal((ushort)2, fixture.Record.Generation); + Assert.Equal("generation 2", fixture.Objects.Get(Guid)!.Name); + Assert.Single(fixture.Materializer.Calls); + Assert.Equal((ushort)2, fixture.Materializer.Calls[0].Generation); + } + + [Fact] + public void TimestampCallbackFresherSameGeneration_StopsOuterCreateVersion() + { + using var fixture = new Fixture(originKnown: true); + bool refreshed = false; + fixture.PublishTimestampsAction = (_, timestamps) => + { + if (refreshed) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "same generation newer")); + }; + + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 1, + Name: "same generation older")); + + 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]); + } + + [Fact] + public void ObjectRemovalCallbackNewerGeneration_CannotBeOverwrittenByOuterCreate() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 1, + Name: "generation 1")); + bool replaced = false; + fixture.Objects.ObjectRemovalClassified += removal => + { + if (replaced + || removal.Reason is not ClientObjectRemovalReason.GenerationReplacement) + { + return; + } + + replaced = true; + fixture.Controller.OnCreate(Spawn( + Generation: 3, + PositionSequence: 3, + Name: "generation 3")); + }; + + fixture.Controller.OnCreate(Spawn( + Generation: 2, + PositionSequence: 2, + Name: "generation 2")); + + Assert.Equal((ushort)3, fixture.Record.Generation); + Assert.Equal("generation 3", fixture.Objects.Get(Guid)!.Name); + Assert.Equal((ushort)3, fixture.Materializer.Calls[^1].Generation); + Assert.DoesNotContain( + fixture.Materializer.Calls, + call => call.Generation == 2); + } + + [Fact] + public void ObjectRemovalCallbackFresherSameGeneration_WinsOuterReplacement() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn( + Generation: 0, + PositionSequence: 1, + Name: "generation zero")); + bool refreshed = false; + fixture.Objects.ObjectRemovalClassified += removal => + { + if (refreshed + || removal.Reason is not ClientObjectRemovalReason.GenerationReplacement) + { + return; + } + + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 3, + Name: "same generation newer")); + }; + + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "same generation older")); + + 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]); + Assert.DoesNotContain( + (ushort)2, + fixture.Materializer.PositionSequences.Skip(1)); + } + + [Fact] + public void MaterializerCallbackFresherSameGeneration_ReplaysCanonicalHydrationOnce() + { + using var fixture = new Fixture(originKnown: true); + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "same generation newer")); + }; + + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 1, + Name: "same generation older")); + + Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); + Assert.Equal([1, 2], fixture.Materializer.PositionSequences); + Assert.Equal( + LiveProjectionPurpose.CreateSupersessionRecovery, + fixture.Materializer.Calls[^1].Purpose); + Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + Assert.True(fixture.Record.InitialHydrationCompleted); + } + + [Fact] + public void ReadyCallbackFresherSameGeneration_ReplaysCanonicalHydrationBeforeCompletion() + { + using var fixture = new Fixture(originKnown: true); + bool refreshed = false; + fixture.Ready.BeforePublish = _ => + { + if (refreshed) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "same generation newer")); + }; + + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 1, + Name: "same generation older")); + + Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); + Assert.Equal([1, 2], fixture.Materializer.PositionSequences); + Assert.Equal( + LiveProjectionPurpose.CreateSupersessionRecovery, + fixture.Materializer.Calls[^1].Purpose); + Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(2, fixture.Ready.PublishCount); + Assert.True(fixture.Record.InitialHydrationCompleted); + } + + [Fact] + public void CompletedSpatialRecovery_DetectsCreateVersionDriftWithoutNestedHydrationRequest() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + ulong stalePositionAuthority = record.PositionAuthorityVersion; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "same generation newer")); + }; + + Assert.False(fixture.Controller.RecoverProjection( + record, + stalePositionAuthority, + record.Snapshot)); + + Assert.True(record.InitialHydrationCompleted); + Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); + Assert.Equal([1, 1, 2], fixture.Materializer.PositionSequences); + Assert.Equal( + LiveProjectionPurpose.CreateSupersessionRecovery, + fixture.Materializer.Calls[^1].Purpose); + Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FailedCompletedSupersession_RemainsPendingUntilExactRetry( + bool retryFromLandblock) + { + using var fixture = new Fixture( + originKnown: true, + playerGuid: retryFromLandblock ? Guid : 0u); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity retained = record.WorldEntity!; + ulong positionAuthority = record.PositionAuthorityVersion; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "recovery v2")); + }; + fixture.Materializer.ThrowAfterMaterializePurposeOnce = + LiveProjectionPurpose.CreateSupersessionRecovery; + + Assert.Throws(() => + fixture.Controller.RecoverProjection( + record, + positionAuthority, + record.Snapshot)); + + Assert.True(record.InitialHydrationCompleted); + Assert.True(record.CreateProjectionSynchronizationPending); + Assert.Same(retained, record.WorldEntity); + Assert.Equal(1, fixture.Resources.RegisterCount); + + fixture.Materializer.BeforeMaterialize = null; + if (retryFromLandblock) + { + fixture.Controller.OnLandblockLoaded(Cell); + } + else + { + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 3, + Name: "recovery v3")); + } + + Assert.False(record.CreateProjectionSynchronizationPending); + Assert.Same(retained, record.WorldEntity); + Assert.Equal( + retryFromLandblock ? 2UL : 3UL, + fixture.Materializer.InstalledCreateIntegrationVersion); + Assert.Equal( + LiveProjectionPurpose.CreateSupersessionRecovery, + fixture.Materializer.Calls[^1].Purpose); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(0, fixture.Resources.UnregisterCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void StaleProjectionExceptionAfterCreateDrift_PersistsExactRetryObligation( + bool retryFromLandblock) + { + using var fixture = new Fixture( + originKnown: true, + playerGuid: retryFromLandblock ? Guid : 0u); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity retained = record.WorldEntity!; + ulong positionAuthority = record.PositionAuthorityVersion; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "drift v2")); + throw new InvalidOperationException("fixture stale projection failure"); + }; + + Assert.Throws(() => + fixture.Controller.RecoverProjection( + record, + positionAuthority, + record.Snapshot)); + + Assert.True(record.InitialHydrationCompleted); + Assert.True(record.CreateProjectionSynchronizationPending); + Assert.Same(retained, record.WorldEntity); + + fixture.Materializer.BeforeMaterialize = null; + if (retryFromLandblock) + { + fixture.Controller.OnLandblockLoaded(Cell); + } + else + { + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 3, + Name: "drift v3")); + } + + Assert.False(record.CreateProjectionSynchronizationPending); + Assert.Same(retained, record.WorldEntity); + Assert.Equal( + LiveProjectionPurpose.CreateSupersessionRecovery, + fixture.Materializer.Calls[^1].Purpose); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(0, fixture.Resources.UnregisterCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CompletedSupersessionReadyFailure_RetriesWholeCommitBoundary( + bool retryFromLandblock) + { + using var fixture = new Fixture( + originKnown: true, + playerGuid: retryFromLandblock ? Guid : 0u); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity retained = record.WorldEntity!; + ulong positionAuthority = record.PositionAuthorityVersion; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "ready v2")); + }; + fixture.Ready.FailPublishCount = 1; + + Assert.False(fixture.Controller.RecoverProjection( + record, + positionAuthority, + record.Snapshot)); + + Assert.True(record.CreateProjectionSynchronizationPending); + fixture.Materializer.BeforeMaterialize = null; + if (retryFromLandblock) + { + fixture.Controller.OnLandblockLoaded(Cell); + } + else + { + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 3, + Name: "ready v3")); + } + + Assert.False(record.CreateProjectionSynchronizationPending); + Assert.Same(retained, record.WorldEntity); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(0, fixture.Resources.UnregisterCount); + Assert.True(fixture.Ready.PublishCount >= 3); + } + + [Fact] + public void PickupSupersession_CompletesCelllessWithoutDatMaterialization() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + ulong positionAuthority = record.PositionAuthorityVersion; + fixture.Network.ApplyAction = events => + { + Assert.NotNull(events.Pickup); + Assert.True(fixture.Runtime.TryApplyPickup(events.Pickup!.Value, out _)); + Assert.True(fixture.Runtime.WithdrawLiveEntityProjectionToCellless(Guid)); + }; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 2, + parentGuid: null)); + }; + + Assert.False(fixture.Controller.RecoverProjection( + record, + positionAuthority, + record.Snapshot)); + + Assert.False(record.CreateProjectionSynchronizationPending); + Assert.Equal(0u, record.FullCellId); + Assert.False(record.IsSpatiallyProjected); + Assert.DoesNotContain( + fixture.Materializer.Calls, + call => call.Purpose is LiveProjectionPurpose.CreateSupersessionRecovery); + Assert.Equal(1, fixture.Resources.RegisterCount); + } + + [Fact] + public void PickupSupersedesInitialHydration_WithoutInventingRenderOwner() + { + using var fixture = new Fixture(originKnown: true); + fixture.Network.ApplyAction = events => + { + Assert.NotNull(events.Pickup); + Assert.True(fixture.Runtime.TryApplyPickup(events.Pickup!.Value, out _)); + }; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 2, + parentGuid: null)); + }; + + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + + LiveEntityRecord record = fixture.Record; + Assert.False(record.CreateProjectionSynchronizationPending); + Assert.False(record.InitialHydrationCompleted); + Assert.Null(record.WorldEntity); + Assert.Equal(0, fixture.Resources.RegisterCount); + + fixture.Materializer.BeforeMaterialize = null; + fixture.Network.ApplyAction = events => + { + if (events.Position is { } position) + { + fixture.Runtime.TryApplyPosition( + position, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _); + } + }; + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 3)); + + Assert.True(record.InitialHydrationCompleted); + Assert.NotNull(record.WorldEntity); + Assert.Equal(1, fixture.Resources.RegisterCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ParentSupersession_CompletesAtExactAttachedReadyBoundary( + bool failFirstReadyAttempt) + { + const uint parentGuid = 0x70000002u; + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity retained = record.WorldEntity!; + ulong positionAuthority = record.PositionAuthorityVersion; + fixture.Ready.FailPublishCount = failFirstReadyAttempt ? 2 : 0; + CreateParentUpdate? pendingParent = null; + fixture.Network.ApplyAction = events => + { + Assert.NotNull(events.Parent); + Assert.True(fixture.Runtime.TryApplyCreateParent( + events.Parent!.Value, + out _)); + pendingParent = events.Parent.Value; + }; + fixture.Relationships.OnSpawnAction = _ => + { + if (pendingParent is not { } parent + || !record.CreateProjectionSynchronizationPending) + { + return; + } + + Assert.True(fixture.Runtime.CommitStagedParent( + new ParentAttachmentRelation( + parent.ParentGuid, + parent.ChildGuid, + parent.ParentLocation, + parent.PlacementId, + ParentInstanceSequence: 0, + parent.ChildPositionSequence), + out _)); + ulong positionVersion = record.PositionAuthorityVersion; + Assert.True(fixture.Runtime.CommitAcceptedParentCellless( + record, + positionVersion)); + Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(record)); + WorldEntity? attached = fixture.Runtime.MaterializeLiveEntity( + Guid, + Cell, + _ => throw new InvalidOperationException("retained identity expected"), + LiveEntityProjectionKind.Attached); + Assert.Same(retained, attached); + fixture.Controller.OnEntityReady( + new LiveEntityReadyCandidate( + record, + record.CreateIntegrationVersion)); + }; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 2, + parentGuid)); + }; + + Assert.False(fixture.Controller.RecoverProjection( + record, + positionAuthority, + record.Snapshot)); + + if (failFirstReadyAttempt) + { + Assert.True(record.CreateProjectionSynchronizationPending); + fixture.Materializer.BeforeMaterialize = null; + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 3, + parentGuid)); + } + + Assert.False(record.CreateProjectionSynchronizationPending); + Assert.Equal(LiveEntityProjectionKind.Attached, record.ProjectionKind); + Assert.Same(retained, record.WorldEntity); + Assert.DoesNotContain( + fixture.Materializer.Calls, + call => call.Purpose is LiveProjectionPurpose.CreateSupersessionRecovery); + Assert.Equal(1, fixture.Resources.RegisterCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void InitialParentReadyFailure_RetriesCelllessReadyBoundary( + bool retryFromLandblock) + { + const uint parentGuid = 0x70000002u; + using var fixture = new Fixture( + originKnown: true, + playerGuid: retryFromLandblock ? Guid : 0u); + fixture.Ready.FailPublishCount = 2; + fixture.Network.ApplyAction = events => + { + if (events.Parent is { } parent) + fixture.Runtime.TryApplyCreateParent(parent, out _); + }; + fixture.Relationships.OnSpawnAction = _ => + { + LiveEntityRecord record = fixture.Record; + if (record.WorldEntity is not null) + return; + + WorldEntity? attached = fixture.Runtime.MaterializeLiveEntity( + Guid, + Cell, + id => new WorldEntity + { + Id = id, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [], + ParentCellId = Cell, + }, + LiveEntityProjectionKind.Attached); + Assert.NotNull(attached); + fixture.Controller.OnEntityReady(new LiveEntityReadyCandidate( + record, + record.CreateIntegrationVersion)); + }; + + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 1, + parentGuid)); + + LiveEntityRecord initial = fixture.Record; + Assert.False(initial.InitialHydrationCompleted); + Assert.Equal(LiveEntityProjectionKind.Attached, initial.ProjectionKind); + Assert.Equal(1, fixture.Resources.RegisterCount); + + if (retryFromLandblock) + { + fixture.Controller.OnLandblockLoaded(Cell); + } + else + { + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 2, + parentGuid)); + } + + Assert.True(initial.InitialHydrationCompleted); + Assert.False(initial.CreateProjectionSynchronizationPending); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(0, fixture.Resources.UnregisterCount); + } + + [Theory] + [InlineData(0, 1, 0, 0)] + [InlineData(1, 1, 1, 0)] + [InlineData(2, 1, 1, 1)] + public void ReadyPublisher_RevalidatesExactRecordBetweenEveryStage( + int replacementStage, + int expectedPrepare, + int expectedPresent, + int expectedReplay) + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord expected = fixture.Record; + int prepare = 0; + int present = 0; + int replay = 0; + bool replaced = false; + bool Stage(int stage) + { + if (!replaced && replacementStage == stage) + { + replaced = true; + fixture.Runtime.RegisterLiveEntity( + Spawn(Generation: 1, PositionSequence: 2)); + } + return true; + } + + var publisher = new LiveEntityReadyPublisher( + fixture.Runtime, + _ => { prepare++; return Stage(0); }, + _ => { present++; return Stage(1); }, + _ => { replay++; return Stage(2); }); + + Assert.False(publisher.Publish(expected)); + Assert.Equal(expectedPrepare, prepare); + Assert.Equal(expectedPresent, present); + Assert.Equal(expectedReplay, replay); + Assert.Same(expected, fixture.Record); + Assert.Equal(2UL, fixture.Record.CreateIntegrationVersion); + } + + [Fact] + public void EquippedChildReadyCandidate_RejectsVersionAdvancedByPoseCallback() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + 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)); + bool published = false; + + Assert.False(EquippedChildRenderController.PublishEntityReadyExact( + fixture.Runtime, + record, + capturedCreateIntegrationVersion, + entity, + _ => published = true)); + Assert.False(published); + } + + [Fact] + public void PositionAuthorityGuard_PreventsStaleRecoveryAfterNestedUpdate() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + fixture.Runtime.WithdrawLiveEntityProjection(Guid); + ulong staleVersion = record.PositionAuthorityVersion; + + Assert.True(fixture.Runtime.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + Guid, + new CreateObject.ServerPosition(Cell, 12f, 10f, 5f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: 0, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out WorldSession.EntitySpawn accepted, + out _)); + + Assert.False(fixture.Controller.RecoverProjection( + record, + staleVersion, + accepted)); + Assert.Empty(fixture.Runtime.MaterializedWorldEntities); + } + + [Fact] + public void DeferredNetworkSink_IsSingleAssignmentAndFailsBeforeBind() + { + var deferred = new DeferredLiveEntityNetworkUpdateSink(); + SameGenerationCreateObjectEvents events = default; + + Assert.Throws(() => + deferred.ApplySameGeneration(events)); + + var first = new RecordingNetworkSink(); + deferred.Bind(first); + deferred.ApplySameGeneration(events); + + Assert.Equal(1, first.ApplyCount); + Assert.Throws(() => + deferred.Bind(new RecordingNetworkSink())); + } + + private const uint Guid = 0x70000001u; + private const uint Cell = 0x01010001u; + + private sealed class Fixture : IDisposable + { + public readonly List Operations = []; + public readonly ClientObjectTable Objects = new(); + public readonly RecordingResources Resources; + public readonly LiveEntityRuntime Runtime; + public readonly RecordingMaterializer Materializer; + public readonly RecordingRelationships Relationships; + public readonly RecordingReadyPublisher Ready; + public readonly RecordingOrigin Origin; + public readonly RecordingNetworkSink Network = new(); + public readonly LiveEntityHydrationController Controller; + + public Action? PublishTimestampsAction { get; set; } + + public Fixture( + bool originKnown, + RecordingResources? resources = null, + uint playerGuid = 0u) + { + Resources = resources ?? new RecordingResources(); + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty())); + Runtime = new LiveEntityRuntime( + spatial, + Resources, + new DelegateLiveEntityRuntimeComponentLifecycle(_ => { })); + Materializer = new RecordingMaterializer(Runtime, Operations); + Relationships = new RecordingRelationships(Operations); + Ready = new RecordingReadyPublisher(Operations); + Origin = new RecordingOrigin(originKnown); + Network.ApplyAction = events => + { + if (events.Position is not { } position) + return; + + Runtime.TryApplyPosition( + position, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _); + }; + Controller = new LiveEntityHydrationController( + Runtime, + Objects, + new object(), + Materializer, + Relationships, + Ready, + Origin, + Network, + (guid, timestamps) => + { + Operations.Add($"timestamps:{timestamps.Instance}"); + PublishTimestampsAction?.Invoke(guid, timestamps); + }, + () => playerGuid); + } + + public LiveEntityRecord Record + { + get + { + Assert.True(Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + return record; + } + } + + public void Dispose() + { + try + { + Runtime.Clear(); + } + catch + { + // Individual failure-path tests assert the relevant exception. + } + } + } + + private class RecordingResources : ILiveEntityResourceLifecycle + { + public int RegisterCount { get; protected set; } + public int UnregisterCount { get; protected set; } + public virtual void Register(WorldEntity entity) => RegisterCount++; + public virtual void Unregister(WorldEntity entity) => UnregisterCount++; + } + + private sealed class FailingOnceResources : RecordingResources + { + private bool _fail = true; + + public override void Register(WorldEntity entity) + { + base.Register(entity); + if (_fail) + { + _fail = false; + throw new InvalidOperationException("fixture registration failure"); + } + } + } + + private sealed class FailingRegisterAndRollbackOnceResources : RecordingResources + { + private bool _failRegister = true; + private bool _failRollback = true; + + public override void Register(WorldEntity entity) + { + base.Register(entity); + if (_failRegister) + { + _failRegister = false; + throw new InvalidOperationException("fixture registration failure"); + } + } + + public override void Unregister(WorldEntity entity) + { + base.Unregister(entity); + if (_failRollback) + { + _failRollback = false; + throw new InvalidOperationException("fixture rollback failure"); + } + } + } + + private sealed class FailingUnregisterOnceResources : RecordingResources + { + private bool _fail = true; + + public override void Unregister(WorldEntity entity) + { + base.Unregister(entity); + if (_fail) + { + _fail = false; + throw new InvalidOperationException("fixture generation cleanup failure"); + } + } + } + + private sealed class RecordingMaterializer( + LiveEntityRuntime runtime, + List operations) : ILiveEntityProjectionMaterializer + { + public readonly List<(LiveProjectionPurpose Purpose, ushort Generation)> Calls = []; + public readonly List PositionSequences = []; + public ulong? InstalledCreateIntegrationVersion { get; private set; } + public Action? BeforeMaterialize { get; set; } + public Action? AfterMaterialize { get; set; } + public LiveProjectionPurpose? ThrowAfterMaterializePurposeOnce { get; set; } + public int SkipMaterializationCount { get; set; } + + public bool TryMaterialize( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn canonicalSpawn, + LiveProjectionPurpose purpose, + ulong expectedCreateIntegrationVersion, + AcDream.App.Rendering.LiveEntityAppearanceUpdateState? appearanceUpdate = null) + { + Calls.Add((purpose, expectedRecord.Generation)); + PositionSequences.Add(canonicalSpawn.PositionSequence); + operations.Add($"materialize:{purpose}:{expectedRecord.Generation}"); + BeforeMaterialize?.Invoke(expectedRecord, canonicalSpawn); + if (!runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return false; + } + if (InstalledCreateIntegrationVersion is null + || purpose is LiveProjectionPurpose.CreateSupersessionRecovery) + { + InstalledCreateIntegrationVersion = expectedCreateIntegrationVersion; + } + if (SkipMaterializationCount > 0) + { + SkipMaterializationCount--; + return false; + } + if (canonicalSpawn.Position is not { } position + || canonicalSpawn.SetupTableId is null) + { + return false; + } + + WorldEntity? entity = runtime.MaterializeLiveEntity( + canonicalSpawn.Guid, + 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, + }); + if (ThrowAfterMaterializePurposeOnce == purpose) + { + ThrowAfterMaterializePurposeOnce = null; + throw new InvalidOperationException( + "fixture supersession projection failure"); + } + AfterMaterialize?.Invoke(expectedRecord, canonicalSpawn); + return entity is not null + && runtime.IsCurrentRecord(expectedRecord); + } + + public void ResetSessionState() { } + } + + private sealed class RecordingRelationships(List operations) + : ILiveEntitySpawnRelationshipSink + { + public Action? OnSpawnAction { get; set; } + public void OnSpawn(WorldSession.EntitySpawn spawn) + { + operations.Add($"relationship:{spawn.InstanceSequence}"); + OnSpawnAction?.Invoke(spawn); + } + } + + private sealed class RecordingReadyPublisher(List operations) + : ILiveEntityReadyPublisher + { + public int PublishCount { get; private set; } + public int FailPublishCount { get; set; } + public Action? BeforePublish { get; set; } + public bool Publish(LiveEntityRecord expectedRecord) + { + PublishCount++; + operations.Add("ready"); + BeforePublish?.Invoke(expectedRecord); + if (FailPublishCount > 0) + { + FailPublishCount--; + return false; + } + return true; + } + } + + private sealed class RecordingOrigin(bool isKnown) : ILiveEntityWorldOriginCoordinator + { + public bool IsKnownValue { get; set; } = isKnown; + public IReadOnlyList AlreadyLoadedLandblocks { get; set; } = []; + public bool IsKnown => IsKnownValue; + public LiveEntityOriginInitialization TryInitialize(WorldSession.EntitySpawn spawn) => + new(IsKnownValue, AlreadyLoadedLandblocks); + } + + private sealed class RecordingNetworkSink : ILiveEntityNetworkUpdateSink + { + public int ApplyCount { get; private set; } + public Action? ApplyAction { get; set; } + public void ApplySameGeneration(SameGenerationCreateObjectEvents events) + { + ApplyCount++; + ApplyAction?.Invoke(events); + } + } + + private static WorldSession.EntitySpawn Spawn( + ushort Generation, + ushort PositionSequence, + string Name = "fixture") + { + var position = new CreateObject.ServerPosition( + Cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + PositionSequence, 1, 1, 1, 0, 1, 0, 1, Generation); + 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, + Name, + (uint)ItemType.Creature, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: Generation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: PositionSequence, + Physics: physics); + } + + private static WorldSession.EntitySpawn CelllessSpawn( + ushort PositionSequence, + uint? parentGuid) + { + var timestamps = new PhysicsTimestamps( + PositionSequence, 1, 1, 1, 0, 1, 0, 1, 1); + PhysicsAttachment? parent = parentGuid is { } guid + ? new PhysicsAttachment(guid, LocationId: 1u) + : null; + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: null, + Movement: null, + AnimationFrame: parentGuid is null ? null : 1u, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: parent, + 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: parentGuid is null ? "pickup" : "parented", + ItemType: (uint)ItemType.Creature, + MotionState: null, + MotionTableId: 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: PositionSequence, + ParentGuid: parentGuid, + ParentLocation: parentGuid is null ? null : 1u, + PlacementId: parentGuid is null ? null : 1u, + Physics: physics); + } +} diff --git a/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs b/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs new file mode 100644 index 00000000..aadcd00e --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs @@ -0,0 +1,117 @@ +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.World; + +namespace AcDream.App.Tests.World; + +public sealed class LiveEntityWorldOriginCoordinatorTests +{ + [Fact] + public void LocalPlayerPosition_InitializesOnceAndReturnsResidentRecoverySet() + { + const uint playerGuid = 0x50000001u; + var origin = new LiveWorldOriginState(); + origin.SetPlaceholder(10, 20); + var world = new GpuWorldState(); + world.AddLandblock(EmptyLandblock(0x0101FFFFu)); + world.AddLandblock(EmptyLandblock(0x0202FFFFu)); + var coordinator = new LiveEntityWorldOriginCoordinator( + origin, + Streaming(world), + world, + Reveal(), + () => playerGuid, + _ => false); + + LiveEntityOriginInitialization ignored = coordinator.TryInitialize( + Spawn(0x50000002u, 0x09090001u)); + Assert.False(ignored.IsKnown); + + LiveEntityOriginInitialization initialized = coordinator.TryInitialize( + Spawn(playerGuid, 0x03040001u)); + + Assert.True(initialized.IsKnown); + Assert.True(origin.IsKnown); + Assert.Equal(3, origin.CenterX); + Assert.Equal(4, origin.CenterY); + Assert.Equal( + [0x0101FFFFu, 0x0202FFFFu], + initialized.AlreadyLoadedLandblocks.Order().ToArray()); + + LiveEntityOriginInitialization repeated = coordinator.TryInitialize( + Spawn(playerGuid, 0x07080001u)); + Assert.Empty(repeated.AlreadyLoadedLandblocks); + Assert.Equal(3, origin.CenterX); + Assert.Equal(4, origin.CenterY); + } + + [Fact] + public void Reset_AllowsNextSessionPlayerPositionToOwnOrigin() + { + const uint playerGuid = 0x50000001u; + var origin = new LiveWorldOriginState(); + var world = new GpuWorldState(); + var coordinator = new LiveEntityWorldOriginCoordinator( + origin, + Streaming(world), + world, + Reveal(), + () => playerGuid, + _ => false); + + Assert.True(coordinator.TryInitialize(Spawn(playerGuid, 0x03040001u)).IsKnown); + origin.Reset(); + Assert.False(coordinator.IsKnown); + + Assert.True(coordinator.TryInitialize(Spawn(playerGuid, 0x07080001u)).IsKnown); + Assert.Equal(7, origin.CenterX); + Assert.Equal(8, origin.CenterY); + } + + private static StreamingController Streaming(GpuWorldState world) => new( + (_, _, _) => { }, + (_, _) => { }, + _ => Array.Empty(), + (_, _) => { }, + world, + nearRadius: 1, + farRadius: 2); + + private static WorldRevealCoordinator Reveal() => new( + (_, _) => true, + _ => true, + (_, _) => true, + () => true, + (_, _) => { }, + () => { }, + _ => false); + + private static LoadedLandblock EmptyLandblock(uint id) => new( + id, + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty()); + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new( + Guid: guid, + Position: new CreateObject.ServerPosition( + cell, + 12f, + 24f, + 6f, + 1f, + 0f, + 0f, + 0f), + SetupTableId: 0x02000001u, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: "origin fixture", + ItemType: null, + MotionState: null, + MotionTableId: null); +} diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs index 56e83ea3..9bc6a0e7 100644 --- a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs @@ -258,4 +258,43 @@ public sealed class ObjectTableWiringTests Assert.Equal("Refreshed Name", table.Get(guid)!.Name); Assert.Equal(456, table.Get(guid)!.Properties.Ints[123u]); } + + [Fact] + public void GenerationReplacement_InvalidatedByRemovalObserver_DoesNotInstallOuterQualities() + { + const uint guid = 0x80000500u; + var table = new ClientObjectTable(); + table.Ingest(MinimalWeenie(guid)); + bool accepting = true; + table.ObjectRemovalClassified += removal => + { + if (removal.Reason is not ClientObjectRemovalReason.GenerationReplacement) + return; + + table.Ingest(MinimalWeenie(guid) with { Name = "Nested Newer" }); + accepting = false; + }; + var outer = new WorldSession.EntitySpawn( + Guid: guid, + Position: null, + SetupTableId: null, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: "Outer Older", + ItemType: null, + MotionState: null, + MotionTableId: null); + + bool applied = ObjectTableWiring.ApplyEntitySpawn( + table, + outer, + replaceGeneration: true, + accepting: () => accepting); + + Assert.False(applied); + Assert.Equal("Nested Newer", table.Get(guid)!.Name); + } } From 40352d5a7a658f7021dc2a6357a59bf6d533f62a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 17:33:22 +0200 Subject: [PATCH 46/98] docs(plan): record live hydration checkpoint --- ...ewindow-slice-4-live-entity-integration.md | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md index 635b7ca0..18035299 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md @@ -22,8 +22,10 @@ them inside a mechanical extraction. replacement, default-pose resolution, local-shadow ownership, and recursive retained projection withdrawal passed three clean reviews plus 6,799 Release tests / 5 skipped on 2026-07-21. -- [ ] C — hydration create/materialize/landblock-recovery ownership and direct - CreateObject/streaming cutover. +- [x] C — hydration create/materialize/landblock-recovery ownership and direct + CreateObject/streaming cutover. Landed in `d10c5f2d`; 6,854 Release tests + passed / 5 skipped after three clean retail, architecture, and adversarial + re-reviews on 2026-07-21. - [ ] D — ObjDesc/Parent/Pickup/ready/withdraw ownership and exact retained identity across leave-world/re-entry. - [ ] E — Delete/prune and retryable exact-incarnation teardown ownership. @@ -268,6 +270,20 @@ clean. failure/retry; no duplicate mesh/script/effect activation. - Full Release build/test; review cycle. +Completed in `d10c5f2d`. `LiveEntityHydrationController` now owns the exact +CreateObject registration transaction, world-origin bootstrap, pending +deferral, first DAT projection materialization, landblock recovery, and the +ready/PES replay boundary. Same-generation CreateObject supersession carries +a monotonic integration version and a persistent retry obligation, so an +older reentrant materialization cannot publish or erase the newer canonical +snapshot. Parent and pickup creates complete through their retail cell-less +paths, while interrupted initial hydration reuses the same animation +sequencer and completed owners retain their exact phase and callback queues. +Session CreateObject and streaming callbacks route directly to the extracted +owner; the old GameWindow bodies are gone. Release build completed with zero +warnings and errors; 6,854 tests passed with five intentional skips; retail, +architecture, and adversarial re-reviews were clean. + ### D — appearance, parenting, pickup, withdrawal - Move ObjDesc, Parent, Pickup, child-ready, collision registration, and From fe5514967cf564a9d6e591e7a2c77ebef3ddd91b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 18:10:07 +0200 Subject: [PATCH 47/98] refactor(world): extract live appearance and parenting Move ObjDesc, Parent, Pickup, child-ready, and retained projection recovery into LiveEntityHydrationController while preserving retail timestamp and leave-world semantics. Pin ready publication to exact projection authority and restore attached descendant chains synchronously. --- ...-07-13-retail-projectile-vfx-pseudocode.md | 7 + .../EquippedChildRenderController.cs | 81 ++- src/AcDream.App/Rendering/GameWindow.cs | 123 +---- .../World/LiveEntityHydrationController.cs | 315 ++++++++++-- .../World/LiveEntityHydrationPorts.cs | 62 ++- src/AcDream.App/World/LiveEntityRuntime.cs | 95 +++- .../EquippedChildProjectionWithdrawalTests.cs | 128 +++++ .../GameWindowLiveEntityCompositionTests.cs | 4 + .../LiveEntityHydrationControllerTests.cs | 482 +++++++++++++++++- 9 files changed, 1098 insertions(+), 199 deletions(-) diff --git a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md index e0eedb10..3ad0d64f 100644 --- a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md +++ b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md @@ -204,6 +204,13 @@ on ParentEvent(parent, child): strictly advance the child's Position timestamp apply parent + placement without creating a second position counter +on CreateObject with Parent but no AnimationFrame field: + PhysicsDesc construction initializes animframe_id to 0 + PhysicsDesc::UnPack explicitly leaves animframe_id at 0 when absent + set_description installs placement 0 before the parent relation is realized + therefore treat absent wire AnimationFrame as placement 0, not as a + missing relation + on PickupEvent(object): require the exact live Instance strictly advance the shared Position timestamp diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index 4da28c83..bf270828 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -98,9 +98,12 @@ public sealed class EquippedChildRenderController : IDisposable lock (_datLock) { if (spawn.ParentGuid is { } parentGuid and not 0 - && spawn.ParentLocation is { } parentLocation - && spawn.PlacementId is { } placementId) + && spawn.ParentLocation is { } parentLocation) { + // PhysicsDesc::PhysicsDesc / UnPack (0x0051D4D0 / + // 0x0051DDD0) default an absent AnimationFrame to zero. + // Parent remains a complete relation in that case. + uint placementId = spawn.PlacementId ?? 0u; Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation( parentGuid, spawn.Guid, @@ -118,6 +121,51 @@ public sealed class EquippedChildRenderController : IDisposable } } + /// + /// Retail SmartBox::UpdateVisualDesc @ 0x00451F40 replaces an + /// attached child's visual description without requiring a world + /// Position. Re-realize the last accepted relation from the newest + /// canonical snapshot while retaining the logical entity. + /// + internal bool TryApplyAttachedAppearance( + LiveEntityRecord expectedRecord, + ulong expectedObjDescAuthorityVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + lock (_datLock) + { + if (!_liveEntities.IsCurrentObjDescAuthority( + expectedRecord, + expectedObjDescAuthorityVersion) + || expectedRecord.ProjectionKind is not + LiveEntityProjectionKind.Attached + || !expectedRecord.IsSpatiallyProjected + || expectedRecord.WorldEntity is not { } expectedEntity + || !Relations.RestoreLastAccepted(expectedRecord.ServerGuid)) + { + return false; + } + + bool projected = ResolveAndTryRealize(expectedRecord.ServerGuid); + if (projected) + { + // WithdrawPriorProjection removes the complete attached + // subtree and retains each descendant relation for recovery. + // The ObjDesc transaction is not published until that same + // subtree is synchronously whole again. + RetryWaitingDescendants(expectedRecord.ServerGuid); + } + return projected + && _liveEntities.IsCurrentObjDescAuthority( + expectedRecord, + expectedObjDescAuthorityVersion) + && expectedRecord.ProjectionKind is + LiveEntityProjectionKind.Attached + && expectedRecord.IsSpatiallyProjected + && ReferenceEquals(expectedRecord.WorldEntity, expectedEntity); + } + } + /// /// Completes attachment projection after a root world entity has been /// registered. Relation acceptance intentionally happens before root @@ -448,46 +496,31 @@ public sealed class EquippedChildRenderController : IDisposable // CPhysicsObj::set_hidden (0x00514C60). _liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true); } - ulong readyCreateIntegrationVersion = childRecord.CreateIntegrationVersion; PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose); Console.WriteLine( $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " + $"location={parentLocation} placement={placement}"); Relations.MarkProjected(pending, candidateKind); + LiveEntityReadyCandidate readyCandidate = + LiveEntityReadyCandidate.Capture(childRecord); ProjectionPoseReady?.Invoke(childGuid); return PublishEntityReadyExact( _liveEntities, - childRecord, - readyCreateIntegrationVersion, - entity, + readyCandidate, EntityReady); } internal static bool PublishEntityReadyExact( LiveEntityRuntime runtime, - LiveEntityRecord expectedRecord, - ulong expectedCreateIntegrationVersion, - WorldEntity expectedEntity, + LiveEntityReadyCandidate candidate, Action? publish) { ArgumentNullException.ThrowIfNull(runtime); - ArgumentNullException.ThrowIfNull(expectedRecord); - ArgumentNullException.ThrowIfNull(expectedEntity); - if (!runtime.IsCurrentCreateIntegration( - expectedRecord, - expectedCreateIntegrationVersion) - || !ReferenceEquals(expectedRecord.WorldEntity, expectedEntity)) - { + if (!candidate.IsCurrent(runtime)) return false; - } - publish?.Invoke(new LiveEntityReadyCandidate( - expectedRecord, - expectedCreateIntegrationVersion)); - return runtime.IsCurrentCreateIntegration( - expectedRecord, - expectedCreateIntegrationVersion) - && ReferenceEquals(expectedRecord.WorldEntity, expectedEntity); + publish?.Invoke(candidate); + return candidate.IsCurrent(runtime); } private void PublishChildPose( diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e4d299cb..7bc131b8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -749,8 +749,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// guid. Captured before the renderability gate so no-position inventory / /// parented children retain Setup and parent metadata; hydration rereads /// this canonical snapshot after every relationship callback. - /// reuses the cached position/setup/motion - /// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals. + /// reuses the cached + /// position/setup/motion fields when a 0xF625 ObjDescEvent carries only + /// updated visuals. /// private IReadOnlyDictionary LastSpawns => _liveEntities?.Snapshots ?? EmptyLiveSpawnMap; @@ -1750,6 +1751,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext item, container, placement, amount), requestExternalContainer: guid => _externalContainers.RequestOpen(guid)); + AcDream.App.World.DeferredLiveEntityParentAcceptance? parentAcceptance = null; + // Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1). if (_options.RetailUi) { @@ -2270,13 +2273,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _lightingSink!, setupId => _dats!.Get(setupId)); + parentAcceptance = new AcDream.App.World.DeferredLiveEntityParentAcceptance(); _equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController( _dats!, _datLock, Objects, _liveEntities, _effectPoses, - TryAcceptParentForRender, + parentAcceptance.TryAccept, (childRecord, positionVersion, projectionVersion) => _liveEntityProjectionWithdrawal!.WithdrawExact( childRecord, @@ -2567,8 +2571,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Objects, _datLock, projectionMaterializer, - new AcDream.App.World.DelegateLiveEntitySpawnRelationshipSink( - _equippedChildRenderer.OnSpawn), + new AcDream.App.World.LiveEntityRelationshipProjection( + _equippedChildRenderer), new AcDream.App.World.LiveEntityReadyPublisher( _liveEntities, _entityEffects!, @@ -2578,11 +2582,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext PublishLocalPhysicsTimestamps, () => _playerServerGuid, _options.DumpLiveSpawns ? Console.WriteLine : null); + parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection); networkUpdateBridge.Bind( new AcDream.App.World.DelegateLiveEntityNetworkUpdateSink( RouteSameGenerationCreateObject)); _equippedChildRenderer.EntityReady += candidate => _liveEntityHydration.OnEntityReady(candidate); + _liveEntityHydration.AppearanceApplied += guid => + { + if (guid == _playerServerGuid) + _paperdollDollDirty = true; + }; // Phase 4.7: optional live-mode startup. Connect to the ACE server, // enter the world as the first character on the account, and stream @@ -2823,7 +2833,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Deleted: deletion => _inboundEntityEvents.Run( this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)), PickedUp: pickup => _inboundEntityEvents.Run( - this, pickup, static (window, value) => window.OnLiveEntityPickedUp(value)), + _liveEntityHydration!, pickup, + static (hydration, value) => hydration.OnPickup(value)), MotionUpdated: motion => _inboundEntityEvents.Run( this, motion, static (window, value) => window.OnLiveMotionUpdated(value)), PositionUpdated: position => _inboundEntityEvents.Run( @@ -2833,11 +2844,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext StateUpdated: state => _inboundEntityEvents.Run( this, state, static (window, value) => window.OnLiveStateUpdated(value)), ParentUpdated: parent => _inboundEntityEvents.Run( - this, parent, static (window, value) => window.OnLiveParentUpdated(value)), + _liveEntityHydration!, parent, + static (hydration, value) => hydration.OnParent(value)), TeleportStarted: teleport => _inboundEntityEvents.Run( this, teleport, static (window, value) => window.OnTeleportStarted(value)), AppearanceUpdated: appearance => _inboundEntityEvents.Run( - this, appearance, static (window, value) => window.OnLiveAppearanceUpdated(value)), + _liveEntityHydration!, appearance, + static (hydration, value) => hydration.OnAppearance(value)), PlayPhysicsScript: script => _inboundEntityEvents.Run( this, script, static (window, value) => window.OnPlayScriptReceived(value)), PlayPhysicsScriptType: message => _inboundEntityEvents.Run( @@ -3037,46 +3050,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext candidate.Generation)); } - /// - /// Server broadcast a 0xF625 ObjDescEvent — a creature/player's - /// appearance changed (equip / unequip / tailoring / recipe result / - /// character option toggle). The wire payload only carries the new - /// ModelData (palette + texture + animpart changes), not position or - /// motion, so we splice it onto the cached spawn and rehydrate only the - /// visual projection. Retail SmartBox::UpdateVisualDesc calls - /// CPhysicsObj::DoObjDescChangesFromDefault on the existing object; - /// accordingly, entity identity and all animation/movement/physics state - /// survive while MeshRefs, palette, and part overrides are replaced. - /// - private void OnLiveAppearanceUpdated(AcDream.Core.Net.Messages.ObjDescEvent.Parsed update) - { - if (!_liveEntities!.TryApplyObjDesc(update, out var newSpawn)) - { - // Server can broadcast ObjDescEvent before we've seen a - // CreateObject for this guid (race on landblock entry, or - // if the entity is in a state we couldn't render). Drop — - // when CreateObject lands, ACE includes the same ModelData - // body inside it, so the appearance won't be lost. - return; - } - - if (_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record)) - { - LiveEntityAppearanceUpdateState? appearanceState = - LiveEntityAppearanceBinding.Capture(_liveEntities, update.Guid); - _liveEntityHydration!.ApplyAppearance( - record, - newSpawn, - appearanceState); - } - - // Slice 2: flag the paperdoll doll to re-clone the player's newly - // mutated appearance on the next doll pass (the C# analog of - // RedressCreature). The rebuild is deferred until the doll is visible. - if (update.Guid == _playerServerGuid) - _paperdollDollDirty = true; - } - /// /// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) + /// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette / @@ -3198,20 +3171,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _entityEffects?.OnLiveEntityDescriptionChanged(refresh.Appearance.Guid); } - OnLiveAppearanceUpdated(refresh.Appearance); + _liveEntityHydration!.OnAppearance(refresh.Appearance); - if (refresh.Parent is { } parent - && _liveEntities!.TryApplyCreateParent(parent, out _)) - { - _equippedChildRenderer?.OnCreateParentAccepted(parent); - } + if (refresh.Parent is { } parent) + _liveEntityHydration.OnCreateParentAccepted(parent); else if (refresh.Position is { } position) { OnLivePositionUpdated(position); } else if (refresh.Pickup is { } pickup) { - OnLiveEntityPickedUp(pickup); + _liveEntityHydration.OnPickup(pickup); } if (refresh.Movement is { } movement) @@ -3220,49 +3190,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext OnLiveVectorUpdated(refresh.Vector); } - /// - /// Retail PickupEvent is a POSITION_TS update, not object destruction. - /// It leaves the world projection while retaining the weenie, generation, - /// and every other timestamp for later inventory/parent events. - /// - private void OnLiveEntityPickedUp(AcDream.Core.Net.Messages.PickupEvent.Parsed pickup) - { - if (!_liveEntities!.TryApplyPickup(pickup, out _)) - return; - - AcDream.App.Rendering.ChildUnparentDisposition childDisposition = - _equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid) - ?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached; - bool removed = childDisposition switch - { - AcDream.App.Rendering.ChildUnparentDisposition.Completed => true, - AcDream.App.Rendering.ChildUnparentDisposition.NotAttached => true, - _ => false, - }; - - if (removed - && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - { - Console.WriteLine( - $"live: pickup guid=0x{pickup.Guid:X8} instSeq={pickup.InstanceSequence} posSeq={pickup.PositionSequence}"); - } - } - - /// - /// ParentEvent may precede either CreateObject. The focused child renderer - /// retains it; its realization callback performs the canonical timestamp - /// acceptance immediately before changing the projection. - /// - private void OnLiveParentUpdated(AcDream.Core.Net.Messages.ParentEvent.Parsed update) - { - _equippedChildRenderer?.OnParentEvent(update); - } - - private bool TryAcceptParentForRender(AcDream.Core.Net.Messages.ParentEvent.Parsed update) - { - return _liveEntities!.TryApplyParent(update, out _); - } - private void PublishLocalPhysicsTimestamps( uint guid, AcDream.App.World.AcceptedPhysicsTimestamps timestamps) diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index d361a7fb..a1d06811 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -1,6 +1,8 @@ using AcDream.App.Rendering; using AcDream.Core.Items; using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.World; namespace AcDream.App.World; @@ -29,19 +31,55 @@ internal interface ILiveEntityProjectionMaterializer void ResetSessionState(); } -internal interface ILiveEntitySpawnRelationshipSink +internal interface ILiveEntityRelationshipProjection { void OnSpawn(WorldSession.EntitySpawn spawn); + void OnParent(ParentEvent.Parsed update); + void OnCreateParentAccepted(CreateParentUpdate update); + ChildUnparentDisposition OnChildBecameUnparented(uint childGuid); + bool TryApplyAttachedAppearance( + LiveEntityRecord record, + ulong objDescAuthorityVersion); } internal interface ILiveEntityReadyPublisher { - bool Publish(LiveEntityRecord expectedRecord); + bool Publish(LiveEntityReadyCandidate candidate); } internal readonly record struct LiveEntityReadyCandidate( LiveEntityRecord Record, - ulong CreateIntegrationVersion); + ulong CreateIntegrationVersion, + ulong PositionAuthorityVersion, + ulong ProjectionMutationVersion, + ulong ObjDescAuthorityVersion, + LiveEntityProjectionKind ProjectionKind, + bool IsSpatiallyProjected, + WorldEntity? WorldEntity) +{ + internal static LiveEntityReadyCandidate Capture(LiveEntityRecord record) + { + ArgumentNullException.ThrowIfNull(record); + return new( + record, + record.CreateIntegrationVersion, + record.PositionAuthorityVersion, + record.ProjectionMutationVersion, + record.ObjDescAuthorityVersion, + record.ProjectionKind, + record.IsSpatiallyProjected, + record.WorldEntity); + } + + internal bool IsCurrent(LiveEntityRuntime runtime) => + runtime.IsCurrentCreateIntegration(Record, CreateIntegrationVersion) + && Record.PositionAuthorityVersion == PositionAuthorityVersion + && Record.ProjectionMutationVersion == ProjectionMutationVersion + && Record.ObjDescAuthorityVersion == ObjDescAuthorityVersion + && Record.ProjectionKind == ProjectionKind + && Record.IsSpatiallyProjected == IsSpatiallyProjected + && ReferenceEquals(Record.WorldEntity, WorldEntity); +} internal interface ILiveEntityNetworkUpdateSink { @@ -104,7 +142,7 @@ internal sealed class LiveEntityHydrationController private readonly ClientObjectTable _objects; private readonly object _datLock; private readonly ILiveEntityProjectionMaterializer _materializer; - private readonly ILiveEntitySpawnRelationshipSink _relationships; + private readonly ILiveEntityRelationshipProjection _relationships; private readonly ILiveEntityReadyPublisher _ready; private readonly ILiveEntityWorldOriginCoordinator _origin; private readonly ILiveEntityNetworkUpdateSink _networkUpdates; @@ -117,7 +155,7 @@ internal sealed class LiveEntityHydrationController ClientObjectTable objects, object datLock, ILiveEntityProjectionMaterializer materializer, - ILiveEntitySpawnRelationshipSink relationships, + ILiveEntityRelationshipProjection relationships, ILiveEntityReadyPublisher ready, ILiveEntityWorldOriginCoordinator origin, ILiveEntityNetworkUpdateSink networkUpdates, @@ -139,6 +177,8 @@ internal sealed class LiveEntityHydrationController _diagnostic = diagnostic; } + internal event Action? AppearanceApplied; + public void OnCreate(WorldSession.EntitySpawn spawn) { // DatCollection uses one mutable reader cursor shared with streaming. @@ -228,6 +268,14 @@ internal sealed class LiveEntityHydrationController expectedCreateIntegrationVersion: createIntegrationVersion); } + + if (_runtime.IsCurrentRecord(record) + && record.AppearanceProjectionSynchronizationPending) + { + SynchronizeAppearance( + record, + record.ObjDescAuthorityVersion); + } } } catch (Exception applyFailure) @@ -248,6 +296,68 @@ internal sealed class LiveEntityHydrationController } } + /// + /// Applies retail SmartBox::UpdateVisualDesc @ 0x00451F40 to the + /// retained object. Identity, animation owner, physics, effects, and cell + /// membership survive; only the DAT-backed visual projection and its + /// derived collision payload are replaced. + /// + public bool OnAppearance(ObjDescEvent.Parsed update) + { + lock (_datLock) + { + if (!_runtime.TryApplyObjDesc(update, out _) + || !_runtime.TryGetRecord(update.Guid, out LiveEntityRecord record)) + { + return false; + } + + return SynchronizeAppearance( + record, + record.ObjDescAuthorityVersion); + } + } + + /// + /// Retail DoPickupEvent @ 0x00452240: consume the shared Position + /// timestamp, unparent, and leave world without destroying the logical + /// object or any incarnation-scoped owner. + /// + public bool OnPickup(PickupEvent.Parsed update) + { + if (!_runtime.TryApplyPickup(update, out _)) + return false; + + ChildUnparentDisposition disposition = + _relationships.OnChildBecameUnparented(update.Guid); + bool accepted = disposition is not ChildUnparentDisposition.Superseded; + if (accepted) + { + _diagnostic?.Invoke( + $"live: pickup guid=0x{update.Guid:X8} instSeq={update.InstanceSequence} posSeq={update.PositionSequence}"); + } + return accepted; + } + + /// + /// Parent events may precede either CreateObject. The relationship owner + /// retains wire order and invokes + /// only when both exact incarnations are addressable. + /// + public void OnParent(ParentEvent.Parsed update) => + _relationships.OnParent(update); + + internal bool TryAcceptParentForProjection(ParentEvent.Parsed update) => + _runtime.TryApplyParent(update, out _); + + internal bool OnCreateParentAccepted(CreateParentUpdate update) + { + if (!_runtime.TryApplyCreateParent(update, out _)) + return false; + _relationships.OnCreateParentAccepted(update); + return true; + } + /// /// Reprojects retained live objects after their landblock is loaded. An /// fully hydrated record only rebuckets. A retained partial projection @@ -263,7 +373,8 @@ internal sealed class LiveEntityHydrationController LiveEntityRecord[] records = _runtime.Records .Where(record => (record.ServerGuid != _playerGuid() || !record.InitialHydrationCompleted - || record.CreateProjectionSynchronizationPending) + || record.CreateProjectionSynchronizationPending + || record.AppearanceProjectionSynchronizationPending) && record.ProjectionCellId != 0 && ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical && record.Snapshot.SetupTableId is not null) @@ -279,6 +390,9 @@ internal sealed class LiveEntityHydrationController if (!_runtime.IsCurrentRecord(record)) continue; + ulong projectedObjDescVersion = record.ObjDescAuthorityVersion; + bool projectedCanonicalAppearance = false; + if (record.CreateProjectionSynchronizationPending) { if (ProjectExact( @@ -287,6 +401,7 @@ internal sealed class LiveEntityHydrationController LiveProjectionPurpose.CreateSupersessionRecovery)) { projected++; + projectedCanonicalAppearance = true; } } else if (record.WorldEntity is not null @@ -303,6 +418,24 @@ internal sealed class LiveEntityHydrationController record, record.Snapshot, LiveProjectionPurpose.SpatialRecovery)) + { + projected++; + projectedCanonicalAppearance = true; + } + + if (projectedCanonicalAppearance + && record.AppearanceProjectionSynchronizationPending) + { + CompleteAppearanceProjectionSynchronization( + record, + projectedObjDescVersion); + } + + if (_runtime.IsCurrentRecord(record) + && record.AppearanceProjectionSynchronizationPending + && SynchronizeAppearance( + record, + record.ObjDescAuthorityVersion)) { projected++; } @@ -353,7 +486,8 @@ internal sealed class LiveEntityHydrationController return false; } - return ProjectExact( + ulong projectedObjDescVersion = expectedRecord.ObjDescAuthorityVersion; + bool projected = ProjectExact( expectedRecord, acceptedSpawn, expectedRecord.CreateProjectionSynchronizationPending @@ -362,49 +496,128 @@ internal sealed class LiveEntityHydrationController && _runtime.IsCurrentPositionAuthority( expectedRecord, positionAuthorityVersion); - } - } + if (!projected) + return false; - public bool ApplyAppearance( - LiveEntityRecord expectedRecord, - WorldSession.EntitySpawn acceptedSpawn, - LiveEntityAppearanceUpdateState? appearanceUpdate) - { - ArgumentNullException.ThrowIfNull(expectedRecord); - lock (_datLock) - { - return ProjectExact( + if (expectedRecord.AppearanceProjectionSynchronizationPending) + { + if (_runtime.IsCurrentObjDescAuthority( + expectedRecord, + projectedObjDescVersion)) + { + CompleteAppearanceProjectionSynchronization( + expectedRecord, + projectedObjDescVersion); + } + else if (!SynchronizeAppearance( + expectedRecord, + expectedRecord.ObjDescAuthorityVersion)) + { + return false; + } + } + + return _runtime.IsCurrentPositionAuthority( expectedRecord, - acceptedSpawn, - appearanceUpdate is null - ? LiveProjectionPurpose.SpatialRecovery - : LiveProjectionPurpose.AppearanceMutation, - appearanceUpdate); + positionAuthorityVersion); } } public bool OnEntityReady(LiveEntityReadyCandidate candidate) { - if (!PublishReady( - candidate.Record, - candidate.CreateIntegrationVersion)) + if (!PublishReady(candidate)) { return false; } - return !candidate.Record.CreateProjectionSynchronizationPending + if (candidate.Record.AppearanceProjectionSynchronizationPending + && !candidate.Record.AppearanceHydrationInProgress) + { + CompleteAppearanceProjectionSynchronization( + candidate.Record, + candidate.ObjDescAuthorityVersion); + } + + return candidate.IsCurrent(_runtime) + && (!candidate.Record.CreateProjectionSynchronizationPending || _runtime.TryCompleteCreateProjectionSynchronization( candidate.Record, - candidate.CreateIntegrationVersion); + candidate.CreateIntegrationVersion)); } public void ResetSessionState() => _materializer.ResetSessionState(); + private bool SynchronizeAppearance( + LiveEntityRecord expectedRecord, + ulong expectedObjDescAuthorityVersion) + { + while (true) + { + if (!_runtime.TryBeginAppearanceHydration( + expectedRecord, + expectedObjDescAuthorityVersion)) + { + return false; + } + + bool retry; + bool published; + try + { + if (expectedRecord.ProjectionKind is + LiveEntityProjectionKind.Attached) + { + published = _relationships.TryApplyAttachedAppearance( + expectedRecord, + expectedObjDescAuthorityVersion); + } + else + { + LiveEntityAppearanceUpdateState? appearanceState = + LiveEntityAppearanceBinding.Capture( + _runtime, + expectedRecord.ServerGuid); + if (appearanceState is null) + { + published = ProjectExact( + expectedRecord, + expectedRecord.Snapshot, + LiveProjectionPurpose.SpatialRecovery); + } + else + { + published = _materializer.TryMaterialize( + expectedRecord, + expectedRecord.Snapshot, + LiveProjectionPurpose.AppearanceMutation, + expectedRecord.CreateIntegrationVersion, + appearanceState); + } + } + } + finally + { + retry = _runtime.EndAppearanceHydration(expectedRecord); + } + + if (retry && _runtime.IsCurrentRecord(expectedRecord)) + { + expectedObjDescAuthorityVersion = + expectedRecord.ObjDescAuthorityVersion; + continue; + } + + return published + && CompleteAppearanceProjectionSynchronization( + expectedRecord, + expectedObjDescAuthorityVersion); + } + } + private bool ProjectExact( LiveEntityRecord expectedRecord, WorldSession.EntitySpawn acceptedSpawn, LiveProjectionPurpose purpose, - LiveEntityAppearanceUpdateState? appearanceUpdate = null, ulong? expectedCreateIntegrationVersion = null) { while (true) @@ -433,7 +646,6 @@ internal sealed class LiveEntityHydrationController expectedRecord, acceptedSpawn, purpose, - appearanceUpdate, createIntegrationVersion); } finally @@ -449,7 +661,6 @@ internal sealed class LiveEntityHydrationController // newest canonical snapshot; logical resources remain registered. acceptedSpawn = expectedRecord.Snapshot; purpose = LiveProjectionPurpose.CreateSupersessionRecovery; - appearanceUpdate = null; expectedCreateIntegrationVersion = expectedRecord.CreateIntegrationVersion; } @@ -459,7 +670,6 @@ internal sealed class LiveEntityHydrationController LiveEntityRecord expectedRecord, WorldSession.EntitySpawn acceptedSpawn, LiveProjectionPurpose purpose, - LiveEntityAppearanceUpdateState? appearanceUpdate, ulong createIntegrationVersion) { _relationships.OnSpawn(acceptedSpawn); @@ -550,8 +760,7 @@ internal sealed class LiveEntityHydrationController expectedRecord, expectedRecord.Snapshot, purpose, - createIntegrationVersion, - appearanceUpdate); + createIntegrationVersion); if (!materialized || !_runtime.IsCurrentCreateIntegration( expectedRecord, @@ -579,22 +788,46 @@ internal sealed class LiveEntityHydrationController ulong expectedCreateIntegrationVersion) { if (!_runtime.IsCurrentCreateIntegration( - expectedRecord, - expectedCreateIntegrationVersion) - || !_ready.Publish(expectedRecord) - || !_runtime.IsCurrentCreateIntegration( expectedRecord, expectedCreateIntegrationVersion)) { return false; } + return PublishReady(LiveEntityReadyCandidate.Capture(expectedRecord)); + } + + private bool PublishReady(LiveEntityReadyCandidate candidate) + { + LiveEntityRecord expectedRecord = candidate.Record; + if (!candidate.IsCurrent(_runtime) + || !_ready.Publish(candidate) + || !candidate.IsCurrent(_runtime)) + { + return false; + } + return _runtime.TryMarkInitialHydrationCompleted( expectedRecord, - expectedCreateIntegrationVersion) - && _runtime.IsCurrentCreateIntegration( + candidate.CreateIntegrationVersion) + && candidate.IsCurrent(_runtime); + } + + private bool CompleteAppearanceProjectionSynchronization( + LiveEntityRecord expectedRecord, + ulong expectedObjDescAuthorityVersion) + { + if (!_runtime.TryCompleteAppearanceProjectionSynchronization( expectedRecord, - expectedCreateIntegrationVersion); + expectedObjDescAuthorityVersion)) + { + return false; + } + + AppearanceApplied?.Invoke(expectedRecord.ServerGuid); + return _runtime.IsCurrentObjDescAuthority( + expectedRecord, + expectedObjDescAuthorityVersion); } private void InitializeOriginAndRecoverLoaded( diff --git a/src/AcDream.App/World/LiveEntityHydrationPorts.cs b/src/AcDream.App/World/LiveEntityHydrationPorts.cs index be5f9007..b6584cbc 100644 --- a/src/AcDream.App/World/LiveEntityHydrationPorts.cs +++ b/src/AcDream.App/World/LiveEntityHydrationPorts.cs @@ -1,16 +1,48 @@ +using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; using AcDream.App.Streaming; using AcDream.Core.Net; +using AcDream.Core.Net.Messages; namespace AcDream.App.World; -internal sealed class DelegateLiveEntitySpawnRelationshipSink( - Action onSpawn) : ILiveEntitySpawnRelationshipSink +internal sealed class LiveEntityRelationshipProjection( + EquippedChildRenderController children) : ILiveEntityRelationshipProjection { - private readonly Action _onSpawn = - onSpawn ?? throw new ArgumentNullException(nameof(onSpawn)); + private readonly EquippedChildRenderController _children = + children ?? throw new ArgumentNullException(nameof(children)); - public void OnSpawn(WorldSession.EntitySpawn spawn) => _onSpawn(spawn); + public void OnSpawn(WorldSession.EntitySpawn spawn) => _children.OnSpawn(spawn); + public void OnParent(ParentEvent.Parsed update) => _children.OnParentEvent(update); + public void OnCreateParentAccepted(CreateParentUpdate update) => + _children.OnCreateParentAccepted(update); + public ChildUnparentDisposition OnChildBecameUnparented(uint childGuid) => + _children.OnChildBecameUnparented(childGuid); + public bool TryApplyAttachedAppearance( + LiveEntityRecord record, + ulong objDescAuthorityVersion) => + _children.TryApplyAttachedAppearance(record, objDescAuthorityVersion); +} + +/// +/// Fail-fast construction seam for the retail ParentEvent timestamp gate. +/// The equipped-child renderer can be built before hydration, but no live +/// session may resolve a relation until hydration binds this once. +/// +internal sealed class DeferredLiveEntityParentAcceptance +{ + private Func? _accept; + + public void Bind(Func accept) + { + ArgumentNullException.ThrowIfNull(accept); + if (Interlocked.CompareExchange(ref _accept, accept, null) is not null) + throw new InvalidOperationException("Live parent acceptance is already bound."); + } + + public bool TryAccept(ParentEvent.Parsed update) => + (_accept ?? throw new InvalidOperationException( + "Live parent acceptance must be bound before a session starts."))(update); } internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher @@ -49,31 +81,23 @@ internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher ?? throw new ArgumentNullException(nameof(replayEffects)); } - public bool Publish(LiveEntityRecord expectedRecord) + public bool Publish(LiveEntityReadyCandidate candidate) { + LiveEntityRecord expectedRecord = candidate.Record; ArgumentNullException.ThrowIfNull(expectedRecord); - ulong createIntegrationVersion = expectedRecord.CreateIntegrationVersion; - if (!_runtime.IsCurrentCreateIntegration( - expectedRecord, - createIntegrationVersion) + if (!candidate.IsCurrent(_runtime) || !_prepareEffects(expectedRecord) - || !_runtime.IsCurrentCreateIntegration( - expectedRecord, - createIntegrationVersion)) + || !candidate.IsCurrent(_runtime)) return false; if (!_presentState(expectedRecord) - || !_runtime.IsCurrentCreateIntegration( - expectedRecord, - createIntegrationVersion)) + || !candidate.IsCurrent(_runtime)) { return false; } return _replayEffects(expectedRecord) - && _runtime.IsCurrentCreateIntegration( - expectedRecord, - createIntegrationVersion); + && candidate.IsCurrent(_runtime); } } diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 0e8f33a2..2b0386ff 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -201,6 +201,7 @@ public sealed class LiveEntityRecord ? 0UL : 1UL; MovementAuthorityVersion = 1UL; + ObjDescAuthorityVersion = 1UL; FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState; ApplyRawPhysicsState(RawPhysicsState); } @@ -258,6 +259,7 @@ public sealed class LiveEntityRecord internal ulong VectorAuthorityVersion { get; private set; } internal ulong VelocityAuthorityVersion { get; private set; } internal ulong MovementAuthorityVersion { get; private set; } + internal ulong ObjDescAuthorityVersion { get; private set; } /// /// Advances for every accepted CreateObject affecting this incarnation, /// including a fresher same-INSTANCE_TS retransmit. App integration uses @@ -284,6 +286,15 @@ public sealed class LiveEntityRecord internal bool ProjectionHydrationInProgress { get; set; } internal ulong ProjectionHydrationCreateVersion { get; set; } internal bool ProjectionHydrationRetryRequested { get; set; } + /// + /// A renderer mesh transaction can be re-entered by a newer accepted + /// ObjDesc. Keep that independent projection obligation on the exact + /// incarnation until the newest visual description publishes. + /// + internal bool AppearanceProjectionSynchronizationPending { get; set; } + internal bool AppearanceHydrationInProgress { get; set; } + internal ulong AppearanceHydrationVersion { get; set; } + internal bool AppearanceHydrationRetryRequested { get; set; } public LiveEntityProjectionKind ProjectionKind { get; internal set; } internal bool RuntimeComponentsTeardownCompleted { get; set; } internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; } @@ -338,6 +349,14 @@ public sealed class LiveEntityRecord VelocityAuthorityVersion++; } + internal void AdvanceObjDescAuthority() + { + ObjDescAuthorityVersion++; + AppearanceProjectionSynchronizationPending = true; + if (AppearanceHydrationInProgress) + AppearanceHydrationRetryRequested = true; + } + internal void AdvanceCreateAuthority() { PositionAuthorityVersion++; @@ -345,6 +364,7 @@ public sealed class LiveEntityRecord VectorAuthorityVersion++; VelocityAuthorityVersion++; MovementAuthorityVersion++; + ObjDescAuthorityVersion++; CreateIntegrationVersion++; } @@ -1547,7 +1567,12 @@ public sealed class LiveEntityRuntime public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted) { bool applied = _inbound.TryApplyObjDesc(update, out accepted); - if (applied) RefreshRecord(update.Guid, accepted); + if (applied) + { + RefreshRecord(update.Guid, accepted); + if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record)) + record.AdvanceObjDescAuthority(); + } return applied; } @@ -1805,6 +1830,13 @@ public sealed class LiveEntityRuntime && ReferenceEquals(current, record) && current.MovementAuthorityVersion == authorityVersion; + internal bool IsCurrentObjDescAuthority( + LiveEntityRecord record, + ulong authorityVersion) => + _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + && ReferenceEquals(current, record) + && current.ObjDescAuthorityVersion == authorityVersion; + /// /// Copies the canonical loaded root-object workset used by retail /// CPhysics::UseTime. The record reference is the incarnation token; @@ -2156,6 +2188,63 @@ public sealed class LiveEntityRuntime return retry; } + internal bool TryBeginAppearanceHydration( + LiveEntityRecord expectedRecord, + ulong expectedObjDescAuthorityVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (!expectedRecord.AppearanceProjectionSynchronizationPending + || !IsCurrentObjDescAuthority( + expectedRecord, + expectedObjDescAuthorityVersion)) + { + return false; + } + + expectedRecord.AppearanceProjectionSynchronizationPending = true; + if (expectedRecord.AppearanceHydrationInProgress) + { + expectedRecord.AppearanceHydrationRetryRequested = true; + return false; + } + + expectedRecord.AppearanceHydrationInProgress = true; + expectedRecord.AppearanceHydrationVersion = + expectedObjDescAuthorityVersion; + expectedRecord.AppearanceHydrationRetryRequested = false; + return true; + } + + internal bool EndAppearanceHydration(LiveEntityRecord expectedRecord) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + bool retry = IsCurrentRecord(expectedRecord) + && (expectedRecord.AppearanceHydrationRetryRequested + || expectedRecord.ObjDescAuthorityVersion + != expectedRecord.AppearanceHydrationVersion); + expectedRecord.AppearanceHydrationInProgress = false; + expectedRecord.AppearanceHydrationVersion = 0UL; + expectedRecord.AppearanceHydrationRetryRequested = false; + return retry; + } + + internal bool TryCompleteAppearanceProjectionSynchronization( + LiveEntityRecord expectedRecord, + ulong expectedObjDescAuthorityVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (!expectedRecord.AppearanceProjectionSynchronizationPending + || !IsCurrentObjDescAuthority( + expectedRecord, + expectedObjDescAuthorityVersion)) + { + return false; + } + + expectedRecord.AppearanceProjectionSynchronizationPending = false; + return true; + } + internal bool IsCurrentCreateIntegration( LiveEntityRecord expectedRecord, ulong expectedCreateIntegrationVersion) => @@ -2722,6 +2811,10 @@ public sealed class LiveEntityRuntime record.ProjectionHydrationInProgress = false; record.ProjectionHydrationCreateVersion = 0UL; record.ProjectionHydrationRetryRequested = false; + record.AppearanceProjectionSynchronizationPending = false; + record.AppearanceHydrationInProgress = false; + record.AppearanceHydrationVersion = 0UL; + record.AppearanceHydrationRetryRequested = false; record.WorldEntity = null; } finally diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs index 5f1bf6e3..024114e8 100644 --- a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs @@ -433,6 +433,134 @@ public sealed class EquippedChildProjectionWithdrawalTests Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind); } + [Fact] + public void SpawnParentWithoutAnimationFrame_UsesRetailPlacementZero() + { + using var fixture = new ControllerFixture((_, _, _) => + new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null)); + LiveEntityRecord parent = fixture.Spawn(0x70000270u, generation: 1); + WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData( + 0x70000271u, + generation: 1) with + { + Position = null, + ParentGuid = parent.ServerGuid, + ParentLocation = 0, + PlacementId = null, + PositionSequence = 0, + }; + LiveEntityRecord child = fixture.Live.RegisterLiveEntity(childSpawn).Record!; + + fixture.Controller.OnSpawn(childSpawn); + + var expected = new ParentAttachmentRelation( + parent.ServerGuid, + child.ServerGuid, + ParentLocation: 0, + PlacementId: 0, + ParentInstanceSequence: 0, + ChildPositionSequence: 0); + Assert.True(fixture.Live.ParentAttachments.IsCommitted(expected)); + fixture.Poses.Publish(parent.WorldEntity!, Array.Empty()); + fixture.Controller.OnPosePublished(parent.ServerGuid); + Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind); + Assert.NotNull(child.WorldEntity); + } + + [Fact] + public void ObjDesc_ReprojectsAttachedChildWithoutWorldPositionOrIdentityChange() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x70000272u, generation: 1); + WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData( + 0x70000273u, + generation: 1) with + { + Position = null, + ParentGuid = parent.ServerGuid, + ParentLocation = 0, + PlacementId = 0, + PositionSequence = 0, + }; + LiveEntityRecord child = fixture.Live.RegisterLiveEntity(childSpawn).Record!; + fixture.Controller.OnSpawn(childSpawn); + fixture.Poses.Publish(parent.WorldEntity!, Array.Empty()); + fixture.Controller.OnPosePublished(parent.ServerGuid); + WorldEntity entity = child.WorldEntity!; + Assert.Null(entity.PaletteOverride); + WorldSession.EntitySpawn grandchildSpawn = ControllerFixture.SpawnData( + 0x70000274u, + generation: 1) with + { + Position = null, + ParentGuid = child.ServerGuid, + ParentLocation = 0, + PlacementId = 0, + PositionSequence = 0, + }; + LiveEntityRecord grandchild = fixture.Live.RegisterLiveEntity( + grandchildSpawn).Record!; + fixture.Controller.OnSpawn(grandchildSpawn); + WorldEntity grandchildEntity = grandchild.WorldEntity!; + Assert.Equal( + LiveEntityProjectionKind.Attached, + grandchild.ProjectionKind); + + Assert.True(fixture.Live.TryApplyObjDesc( + new ObjDescEvent.Parsed( + child.ServerGuid, + new CreateObject.ModelData( + BasePaletteId: 0x04000022u, + [new CreateObject.SubPaletteSwap( + 0x0F000033u, + Offset: 4, + Length: 8)], + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 1), + out _)); + Assert.Null(child.Snapshot.Position); + + Assert.True(fixture.Controller.TryApplyAttachedAppearance( + child, + child.ObjDescAuthorityVersion)); + + Assert.Same(entity, child.WorldEntity); + Assert.True(child.IsSpatiallyProjected); + Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind); + Assert.Equal((uint)0x04000022u, child.Snapshot.BasePaletteId); + Assert.NotNull(entity.PaletteOverride); + Assert.Equal(0x04000022u, entity.PaletteOverride!.BasePaletteId); + PaletteOverride.SubPaletteRange range = + Assert.Single(entity.PaletteOverride.SubPalettes); + Assert.Equal(0x0F000033u, range.SubPaletteId); + Assert.Equal((byte)4, range.Offset); + Assert.Equal((byte)8, range.Length); + Assert.Same(grandchildEntity, grandchild.WorldEntity); + Assert.True(grandchild.IsSpatiallyProjected); + Assert.Equal( + LiveEntityProjectionKind.Attached, + grandchild.ProjectionKind); + } + } + [Fact] public void ChildWithoutSetup_StillCommitsLogicalParenting() { diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs index 354a888e..d16db0a7 100644 --- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -22,6 +22,10 @@ public sealed class GameWindowLiveEntityCompositionTests [InlineData("RehydrateServerEntitiesForLandblock")] [InlineData("TryInitializeLiveCenter")] [InlineData("CompleteLiveEntityReady")] + [InlineData("OnLiveAppearanceUpdated")] + [InlineData("OnLiveEntityPickedUp")] + [InlineData("OnLiveParentUpdated")] + [InlineData("TryAcceptParentForRender")] public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName) { Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation)); diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index 9cff0e04..0ff1c3a5 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -12,6 +12,25 @@ namespace AcDream.App.Tests.World; public sealed class LiveEntityHydrationControllerTests { + [Fact] + public void ParentAcceptanceBridge_IsFailFastAndSingleAssignment() + { + var bridge = new DeferredLiveEntityParentAcceptance(); + var update = new ParentEvent.Parsed(1, 2, 3, 4, 5, 6); + Assert.Throws(() => bridge.TryAccept(update)); + + ParentEvent.Parsed accepted = default; + bridge.Bind(value => + { + accepted = value; + return true; + }); + + Assert.True(bridge.TryAccept(update)); + Assert.Equal(update, accepted); + Assert.Throws(() => bridge.Bind(_ => false)); + } + [Fact] public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization() { @@ -93,10 +112,7 @@ public sealed class LiveEntityHydrationControllerTests LiveEntityRecord record = fixture.Record; Assert.Null(record.WorldEntity); - Assert.True(fixture.Controller.ApplyAppearance( - record, - record.Snapshot, - appearanceUpdate: null)); + Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u))); Assert.NotNull(record.WorldEntity); Assert.Equal(2, fixture.Materializer.Calls.Count); @@ -105,6 +121,324 @@ public sealed class LiveEntityHydrationControllerTests Assert.Equal(1, fixture.Ready.PublishCount); } + [Fact] + public void AppearanceUpdate_ReplacesOnlyProjectionAndPreservesExactOwners() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity entity = record.WorldEntity!; + int applied = 0; + fixture.Controller.AppearanceApplied += _ => applied++; + + Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u))); + + Assert.Same(record, fixture.Record); + Assert.Same(entity, record.WorldEntity); + Assert.Equal((uint)0x04000022u, record.Snapshot.BasePaletteId); + Assert.False(record.AppearanceProjectionSynchronizationPending); + Assert.Equal(LiveProjectionPurpose.AppearanceMutation, fixture.Materializer.Calls[^1].Purpose); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(0, fixture.Resources.UnregisterCount); + Assert.Equal(1, applied); + } + + [Fact] + public void NestedAppearanceUpdate_ReplaysNewestCanonicalSnapshotAfterOuterPublication() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + int applied = 0; + fixture.Controller.AppearanceApplied += _ => applied++; + bool nested = false; + bool nestedResult = true; + fixture.Materializer.BeforeMaterialize = (_, _) => + { + if (fixture.Materializer.Calls[^1].Purpose is not LiveProjectionPurpose.AppearanceMutation + || nested) + { + return; + } + + nested = true; + nestedResult = fixture.Controller.OnAppearance(ObjDesc(3, 0x04000033u)); + }; + + Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u))); + + Assert.False(nestedResult); + Assert.Equal((uint)0x04000033u, fixture.Record.Snapshot.BasePaletteId); + Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending); + Assert.Equal( + 2, + fixture.Materializer.Calls.Count(call => + call.Purpose is LiveProjectionPurpose.AppearanceMutation)); + Assert.Equal(1, applied); + } + + [Fact] + public void AppearancePublicationFailure_RetainsObligationForLandblockRetry() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + int applied = 0; + fixture.Controller.AppearanceApplied += _ => applied++; + fixture.Materializer.ThrowAfterMaterializePurposeOnce = + LiveProjectionPurpose.AppearanceMutation; + + Assert.Throws(() => + fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u))); + Assert.True(fixture.Record.AppearanceProjectionSynchronizationPending); + Assert.Equal(0, applied); + + fixture.Controller.OnLandblockLoaded(Cell); + + Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending); + Assert.Equal((uint)0x04000022u, fixture.Record.Snapshot.BasePaletteId); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, applied); + } + + [Fact] + public void AttachedAppearance_UsesRelationshipProjectionWithoutWorldPosition() + { + const uint parentGuid = 0x70000002u; + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity entity = record.WorldEntity!; + Assert.True(fixture.Runtime.TryApplyCreateParent( + new CreateParentUpdate(Guid, parentGuid, 1, 0, 1, 2), + out _)); + Assert.True(fixture.Runtime.CommitStagedParent( + new ParentAttachmentRelation( + parentGuid, + Guid, + ParentLocation: 1, + PlacementId: 0, + ParentInstanceSequence: 0, + ChildPositionSequence: 2), + out _)); + record.ProjectionKind = LiveEntityProjectionKind.Attached; + record.FullCellId = 0u; + int materializerCalls = fixture.Materializer.Calls.Count; + int relationshipCalls = 0; + int applied = 0; + fixture.Relationships.ApplyAttachedAppearance = (expected, version) => + { + relationshipCalls++; + Assert.Same(record, expected); + Assert.True(fixture.Runtime.IsCurrentObjDescAuthority(expected, version)); + Assert.Null(expected.Snapshot.Position); + Assert.Equal((uint)0x04000022u, expected.Snapshot.BasePaletteId); + return true; + }; + fixture.Controller.AppearanceApplied += _ => applied++; + + Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u))); + + Assert.Same(entity, record.WorldEntity); + Assert.Equal(LiveEntityProjectionKind.Attached, record.ProjectionKind); + Assert.False(record.AppearanceProjectionSynchronizationPending); + Assert.Equal(materializerCalls, fixture.Materializer.Calls.Count); + Assert.Equal(1, relationshipCalls); + Assert.Equal(1, applied); + } + + [Theory] + [InlineData(1)] + [InlineData(0)] + public void StaleOrEqualAppearance_HasNoProjectionOrNotificationSideEffects( + ushort sequence) + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + int calls = fixture.Materializer.Calls.Count; + int applied = 0; + fixture.Controller.AppearanceApplied += _ => applied++; + + Assert.False(fixture.Controller.OnAppearance( + ObjDesc(sequence, 0x04000022u))); + + Assert.Equal(calls, fixture.Materializer.Calls.Count); + Assert.Equal(0, applied); + Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending); + Assert.Null(fixture.Record.Snapshot.BasePaletteId); + } + + [Fact] + public void AppearanceSequenceWrap_PublishesExactlyOnce() + { + using var fixture = new Fixture(originKnown: true); + WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1); + spawn = spawn with + { + Physics = spawn.Physics!.Value with + { + Timestamps = spawn.Physics.Value.Timestamps with + { + ObjDesc = 0xFFFF, + }, + }, + }; + fixture.Controller.OnCreate(spawn); + int applied = 0; + fixture.Controller.AppearanceApplied += _ => applied++; + + Assert.True(fixture.Controller.OnAppearance(ObjDesc(0, 0x04000022u))); + + Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending); + Assert.Equal((uint)0x04000022u, fixture.Record.Snapshot.BasePaletteId); + Assert.Equal(1, applied); + } + + [Fact] + public void FailedAppearance_IsClearedAtGenerationReplacementAndCannotLeak() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord retired = fixture.Record; + fixture.Materializer.SkipMaterializationCount = 1; + Assert.False(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u))); + Assert.True(retired.AppearanceProjectionSynchronizationPending); + + fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1)); + LiveEntityRecord replacement = fixture.Record; + fixture.Controller.OnLandblockLoaded(Cell); + + Assert.NotSame(retired, replacement); + Assert.False(retired.AppearanceProjectionSynchronizationPending); + Assert.False(retired.AppearanceHydrationInProgress); + Assert.False(replacement.AppearanceProjectionSynchronizationPending); + Assert.Null(replacement.Snapshot.BasePaletteId); + } + + [Fact] + public void Pickup_LeavesWorldWithoutEndingLogicalOrResourceIdentity() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity entity = record.WorldEntity!; + fixture.Relationships.OnUnparentAction = _ => + fixture.Runtime.WithdrawLiveEntityProjection(record) + ? ChildUnparentDisposition.Completed + : ChildUnparentDisposition.Superseded; + + Assert.True(fixture.Controller.OnPickup( + new PickupEvent.Parsed(Guid, 1, 2))); + + Assert.Same(record, fixture.Record); + Assert.Same(entity, record.WorldEntity); + Assert.Equal(0u, record.FullCellId); + Assert.False(record.IsSpatiallyProjected); + Assert.True(record.ResourcesRegistered); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(0, fixture.Resources.UnregisterCount); + Assert.Contains($"unparent:{Guid:X8}", fixture.Operations); + } + + [Fact] + public void PositionAfterPickup_ReentersWithSameEntityBodyAndResources() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity entity = record.WorldEntity!; + var body = new PhysicsBody(); + record.PhysicsBody = body; + fixture.Relationships.OnUnparentAction = _ => + fixture.Runtime.WithdrawLiveEntityProjection(record) + ? ChildUnparentDisposition.Completed + : ChildUnparentDisposition.Superseded; + Assert.True(fixture.Controller.OnPickup( + new PickupEvent.Parsed(Guid, 1, 2))); + int applied = 0; + fixture.Controller.AppearanceApplied += _ => applied++; + Assert.False(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u))); + Assert.True(record.AppearanceProjectionSynchronizationPending); + Assert.Equal(0, applied); + + Assert.True(fixture.Runtime.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + Guid, + new CreateObject.ServerPosition( + Cell, 14f, 12f, 6f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: 0, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 3, + TeleportSequence: 0, + ForcePositionSequence: 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out WorldSession.EntitySpawn accepted, + out _)); + ulong positionAuthority = record.PositionAuthorityVersion; + + Assert.True(fixture.Controller.RecoverProjection( + record, + positionAuthority, + accepted)); + + Assert.Same(entity, record.WorldEntity); + Assert.Same(body, record.PhysicsBody); + Assert.True(record.IsSpatiallyProjected); + Assert.False(record.AppearanceProjectionSynchronizationPending); + Assert.Equal((uint)0x04000022u, record.Snapshot.BasePaletteId); + Assert.Equal(1, applied); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(0, fixture.Resources.UnregisterCount); + } + + [Fact] + public void ParentEvent_IsRetainedByRelationshipOwnerAndAcceptedThroughHydrationGate() + { + using var fixture = new Fixture(originKnown: true); + const uint parentGuid = 0x70000002u; + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + fixture.Runtime.RegisterLiveEntity( + Spawn(Generation: 4, PositionSequence: 1) with { Guid = parentGuid }); + bool accepted = false; + fixture.Relationships.OnParentAction = update => + accepted = fixture.Controller.TryAcceptParentForProjection(update); + var update = new ParentEvent.Parsed( + parentGuid, + Guid, + ParentLocation: 1, + PlacementId: 0, + ParentInstanceSequence: 4, + ChildPositionSequence: 2); + + fixture.Controller.OnParent(update); + + Assert.True(accepted); + Assert.Equal((ushort)2, fixture.Record.Snapshot.PositionSequence); + Assert.Contains($"parent:{Guid:X8}", fixture.Operations); + } + + [Fact] + public void SameGenerationCreateParent_AdvancesSharedPositionAndPublishesRelation() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + var update = new CreateParentUpdate( + Guid, + ParentGuid: 0x70000002u, + ParentLocation: 3, + PlacementId: 0, + ChildInstanceSequence: 1, + ChildPositionSequence: 2); + + Assert.True(fixture.Controller.OnCreateParentAccepted(update)); + + Assert.Equal((ushort)2, fixture.Record.Snapshot.PositionSequence); + Assert.Contains($"create-parent:{Guid:X8}", fixture.Operations); + } + [Fact] public void SameOlderNewerAndWrappedGenerations_RouteOrReplaceExactlyOnce() { @@ -805,9 +1139,7 @@ public sealed class LiveEntityHydrationControllerTests LiveEntityProjectionKind.Attached); Assert.Same(retained, attached); fixture.Controller.OnEntityReady( - new LiveEntityReadyCandidate( - record, - record.CreateIntegrationVersion)); + LiveEntityReadyCandidate.Capture(record)); }; bool refreshed = false; fixture.Materializer.BeforeMaterialize = (_, spawn) => @@ -880,9 +1212,8 @@ public sealed class LiveEntityHydrationControllerTests }, LiveEntityProjectionKind.Attached); Assert.NotNull(attached); - fixture.Controller.OnEntityReady(new LiveEntityReadyCandidate( - record, - record.CreateIntegrationVersion)); + fixture.Controller.OnEntityReady( + LiveEntityReadyCandidate.Capture(record)); }; fixture.Controller.OnCreate(CelllessSpawn( @@ -945,7 +1276,8 @@ public sealed class LiveEntityHydrationControllerTests _ => { present++; return Stage(1); }, _ => { replay++; return Stage(2); }); - Assert.False(publisher.Publish(expected)); + Assert.False(publisher.Publish( + LiveEntityReadyCandidate.Capture(expected))); Assert.Equal(expectedPrepare, prepare); Assert.Equal(expectedPresent, present); Assert.Equal(expectedReplay, replay); @@ -953,6 +1285,52 @@ public sealed class LiveEntityHydrationControllerTests Assert.Equal(2UL, fixture.Record.CreateIntegrationVersion); } + [Theory] + [InlineData(0, 1, 0, 0)] + [InlineData(1, 1, 1, 0)] + [InlineData(2, 1, 1, 1)] + public void ReadyPublisher_RejectsSameIdentityProjectionChangeBetweenStages( + int withdrawalStage, + int expectedPrepare, + int expectedPresent, + int expectedReplay) + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord expected = fixture.Record; + LiveEntityReadyCandidate candidate = + LiveEntityReadyCandidate.Capture(expected); + int prepare = 0; + int present = 0; + int replay = 0; + bool withdrawn = false; + bool Stage(int stage) + { + if (!withdrawn && withdrawalStage == stage) + { + withdrawn = true; + Assert.True(fixture.Runtime.TryApplyPickup( + new PickupEvent.Parsed(Guid, 1, 2), + out _)); + Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(expected)); + } + return true; + } + + var publisher = new LiveEntityReadyPublisher( + fixture.Runtime, + _ => { prepare++; return Stage(0); }, + _ => { present++; return Stage(1); }, + _ => { replay++; return Stage(2); }); + + Assert.False(publisher.Publish(candidate)); + Assert.Equal(expectedPrepare, prepare); + Assert.Equal(expectedPresent, present); + Assert.Equal(expectedReplay, replay); + Assert.Same(expected, fixture.Record); + Assert.False(expected.IsSpatiallyProjected); + } + [Fact] public void EquippedChildReadyCandidate_RejectsVersionAdvancedByPoseCallback() { @@ -970,9 +1348,35 @@ public sealed class LiveEntityHydrationControllerTests Assert.False(EquippedChildRenderController.PublishEntityReadyExact( fixture.Runtime, - record, - capturedCreateIntegrationVersion, - entity, + LiveEntityReadyCandidate.Capture(record) with + { + CreateIntegrationVersion = capturedCreateIntegrationVersion, + WorldEntity = entity, + }, + _ => published = true)); + Assert.False(published); + } + + [Fact] + public void EquippedChildReadyCandidate_RejectsPickupWithdrawalByPoseCallback() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + LiveEntityReadyCandidate candidate = + LiveEntityReadyCandidate.Capture(record); + + // Models ProjectionPoseReady accepting Pickup and withdrawing the + // retained projection before EntityReady is emitted. + Assert.True(fixture.Runtime.TryApplyPickup( + new PickupEvent.Parsed(Guid, 1, 2), + out _)); + Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(record)); + bool published = false; + + Assert.False(EquippedChildRenderController.PublishEntityReadyExact( + fixture.Runtime, + candidate, _ => published = true)); Assert.False(published); } @@ -1256,14 +1660,46 @@ public sealed class LiveEntityHydrationControllerTests } private sealed class RecordingRelationships(List operations) - : ILiveEntitySpawnRelationshipSink + : ILiveEntityRelationshipProjection { public Action? OnSpawnAction { get; set; } + public Action? OnParentAction { get; set; } + public Action? OnCreateParentAction { get; set; } + public Func? OnUnparentAction { get; set; } + public Func? ApplyAttachedAppearance { get; set; } public void OnSpawn(WorldSession.EntitySpawn spawn) { operations.Add($"relationship:{spawn.InstanceSequence}"); OnSpawnAction?.Invoke(spawn); } + + public void OnParent(ParentEvent.Parsed update) + { + operations.Add($"parent:{update.ChildGuid:X8}"); + OnParentAction?.Invoke(update); + } + + public void OnCreateParentAccepted(CreateParentUpdate update) + { + operations.Add($"create-parent:{update.ChildGuid:X8}"); + OnCreateParentAction?.Invoke(update); + } + + public ChildUnparentDisposition OnChildBecameUnparented(uint childGuid) + { + operations.Add($"unparent:{childGuid:X8}"); + return OnUnparentAction?.Invoke(childGuid) + ?? ChildUnparentDisposition.Completed; + } + + public bool TryApplyAttachedAppearance( + LiveEntityRecord record, + ulong objDescAuthorityVersion) + { + operations.Add($"attached-appearance:{record.ServerGuid:X8}"); + return ApplyAttachedAppearance?.Invoke(record, objDescAuthorityVersion) + ?? false; + } } private sealed class RecordingReadyPublisher(List operations) @@ -1272,8 +1708,9 @@ public sealed class LiveEntityHydrationControllerTests public int PublishCount { get; private set; } public int FailPublishCount { get; set; } public Action? BeforePublish { get; set; } - public bool Publish(LiveEntityRecord expectedRecord) + public bool Publish(LiveEntityReadyCandidate candidate) { + LiveEntityRecord expectedRecord = candidate.Record; PublishCount++; operations.Add("ready"); BeforePublish?.Invoke(expectedRecord); @@ -1357,6 +1794,19 @@ public sealed class LiveEntityHydrationControllerTests Physics: physics); } + private static ObjDescEvent.Parsed ObjDesc( + ushort sequence, + uint basePaletteId) => + new( + Guid, + new CreateObject.ModelData( + basePaletteId, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: sequence); + private static WorldSession.EntitySpawn CelllessSpawn( ushort PositionSequence, uint? parentGuid) From 4427cfb2c716dfbea304597060c58a1396d10b0c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 18:11:09 +0200 Subject: [PATCH 48/98] docs(plan): record live relationship checkpoint --- docs/architecture/code-structure.md | 8 +++++++- ...ewindow-slice-4-live-entity-integration.md | 20 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index cecfe279..533d7793 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -523,7 +523,13 @@ physics-host seams. `69a2ca0c` extracted appearance/collision/default-pose and projection-withdrawal mechanics, including exact recursive `leave_world` subtree handling and retained retry ownership. Three independent reviews were clean and the Release suite passes 6,799 tests with five intentional skips. -Checkpoint C (CreateObject and first materialization ownership) is active. +Checkpoints C and D are complete. `d10c5f2d` moved CreateObject, first +materialization, origin bootstrap, and landblock recovery into +`LiveEntityHydrationController`; `fe551496` moved ObjDesc, Parent, Pickup, +child-ready, and exact leave-world/re-entry routing. Appearance and attached +subtree recovery now use independent authority transactions and preserve the +same logical/runtime identities. Checkpoint E (Delete/prune and retryable +exact-incarnation teardown ownership) is active. #### Slice 5 — extract landblock presentation diff --git a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md index 18035299..bf587f65 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md @@ -26,8 +26,10 @@ them inside a mechanical extraction. CreateObject/streaming cutover. Landed in `d10c5f2d`; 6,854 Release tests passed / 5 skipped after three clean retail, architecture, and adversarial re-reviews on 2026-07-21. -- [ ] D — ObjDesc/Parent/Pickup/ready/withdraw ownership and exact retained - identity across leave-world/re-entry. +- [x] D — ObjDesc/Parent/Pickup/ready/withdraw ownership and exact retained + identity across leave-world/re-entry. Landed in `fe551496`; 6,877 Release + tests passed / 5 skipped after three clean retail, architecture, and + adversarial re-reviews on 2026-07-21. - [ ] E — Delete/prune and retryable exact-incarnation teardown ownership. - [ ] F — Position/Vector/State/Movement controller, same-generation CreateObject tail, independent authority gates, and direct inbound cutover. @@ -294,6 +296,20 @@ architecture, and adversarial re-reviews were clean. - Bind child callbacks directly and remove their GameWindow façades. - Full Release build/test; review cycle. +Completed in `fe551496`. `LiveEntityHydrationController` now owns accepted +ObjDesc, Parent, Pickup, child-ready, and exact leave-world/re-entry routing. +ObjDesc carries an independent authority/retry transaction and reports success +only when the current visual projection is published; delayed landblock or +Position recovery therefore refreshes the player paperdoll exactly once. +Attached objects re-realize through the equipped-child owner without requiring +a world Position, retain their local identity/resources, and restore their +complete descendant chain synchronously. Ready publication pins the exact +create, Position, ObjDesc, projection-mutation, projection-kind, spatial, and +entity authorities across every callback. The retail absent AnimationFrame +default is placement zero. Release build completed with zero warnings and +errors; 6,877 tests passed with five intentional skips; retail, architecture, +and adversarial re-reviews were clean. + ### E — delete and exact teardown - Move Delete/prune, teardown-plan construction, leave-world component cleanup, From f38822c490cde7b5c3fa3fe54f92e01b09cfb2a1 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 18:25:59 +0200 Subject: [PATCH 49/98] refactor(world): extract live entity teardown --- .../retail-divergence-register.md | 2 +- .../RemoteMovementObservationTracker.cs | 27 +++ src/AcDream.App/Rendering/GameWindow.cs | 197 ++++-------------- .../World/LiveEntityHydrationController.cs | 44 ++++ .../LiveEntityRuntimeTeardownController.cs | 167 +++++++++++++++ .../GameWindowLiveEntityCompositionTests.cs | 5 + .../LiveEntityHydrationControllerTests.cs | 167 ++++++++++++++- ...iveEntityRuntimeTeardownControllerTests.cs | 115 ++++++++++ 8 files changed, 565 insertions(+), 159 deletions(-) create mode 100644 src/AcDream.App/Physics/RemoteMovementObservationTracker.cs create mode 100644 src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs create mode 100644 tests/AcDream.App.Tests/World/LiveEntityRuntimeTeardownControllerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8258839f..dce12142 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -171,7 +171,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-65 | **PickupEvent (0xF74A) no longer evicts the weenie from `ClientObjectTable`** — only `DeleteObject (0xF747)` evicts, matching the retail `object_table`-vs-`weenie_object_table` split. An item another player picks up near you (only ever gets `PickupEvent`, never `DeleteObject`) lingers as a data-only entry (`ContainerId 0`, not in any view) until teleport/relog `Clear()`. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (EntityDeleted handler); `src/AcDream.Core.Net/WorldSession.cs` (PickupEvent branch) | The weenie entry for nearby pickups is a harmless data ghost — no UI shows it (no container, not wielded). Retail evicts it from `weenie_object_table` when the object fully leaves interest range via `DeleteObject`; acdream defers to teleport/relog clear. | Slight memory growth in long sessions if many world items are picked up by other players near you; item data ghosts cannot cause functional issues because no UI queries `ContainerId 0`. | `CACObjectMaint::DeleteObject` / `SmartBox::HandleDeleteObject`; ACE `Player_Inventory.cs TryDequipObjectWithNetworking` | | ~~AP-66~~ | **RETIRED 2026-07-13 — authored paperdoll empty-slot presentation.** The earlier “no silhouettes” conclusion inspected the ItemList elements' own media but missed `UIElement_ItemList::InternalCreateItem`, which clones a distinct `UIElement_UIItem` catalog prototype for each location. All 21 supported jewelry, weapon, ammo, shield, clothing, cloak, trinket, and armor lists now resolve their exact `ItemSlot_Empty` surface from live DAT; `PostInit` confirms non-armor lists remain visible while the nine armor lists toggle with Slots. | `src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs`; `ItemListCellTemplate.cs`; `PaperdollController.cs` | — | — | `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`; `PostInit @ 0x004A5360`; `UIElement_ItemList::InternalCreateItem @ 0x004E3570`; `LayoutDesc 0x21000037` | | AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` | -| AP-69 | acdream now preserves one accepted live record across rebucketing and ports retail's 25-second leave-visibility destruction lifecycle. Spatially resident records cancel expiry; otherwise the ACE compatibility boundary uses holtburger's conservative 384-unit distance envelope and retains attached/container/wielder/parent-owned objects. Expiry routes through the exact generation-safe F747 teardown. DIVERGENCE: the fallback does not yet derive visibility from retail/ACE ObjCell PVS (`SeenOutside` plus `VisibleCells`), and trade/container preview retention has no separate lifecycle flag. | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/World/LiveEntityLivenessController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs`; `GameWindow.OnLiveEntityPruned` | Prevents stale portal destinations from accumulating animation/effect/render owners while retaining loaded long-view objects and canonical identity | A nonresident object outside 384 units that remains visible through an unusual long EnvCell PVS could expire early; a future preview-only object with no parent/container ownership could also expire. Replace the compatibility predicate when exact ObjCell PVS and preview lifetimes are available | `CPhysicsObj::prepare_to_leave_visibility` 0x00511F40; `CPhysicsObj::prepare_to_enter_world` 0x00511FA0; `CObjectMaint::AddObjectToBeDestroyed` 0x00508F70; `CObjectMaint::UseTime` 0x005089B0; `docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md` | +| AP-69 | acdream now preserves one accepted live record across rebucketing and ports retail's 25-second leave-visibility destruction lifecycle. Spatially resident records cancel expiry; otherwise the ACE compatibility boundary uses holtburger's conservative 384-unit distance envelope and retains attached/container/wielder/parent-owned objects. Expiry routes through the exact generation-safe F747 teardown. DIVERGENCE: the fallback does not yet derive visibility from retail/ACE ObjCell PVS (`SeenOutside` plus `VisibleCells`), and trade/container preview retention has no separate lifecycle flag. | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/World/LiveEntityLivenessController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs`; `LiveEntityHydrationController.OnPrune` | Prevents stale portal destinations from accumulating animation/effect/render owners while retaining loaded long-view objects and canonical identity | A nonresident object outside 384 units that remains visible through an unusual long EnvCell PVS could expire early; a future preview-only object with no parent/container ownership could also expire. Replace the compatibility predicate when exact ObjCell PVS and preview lifetimes are available | `CPhysicsObj::prepare_to_leave_visibility` 0x00511F40; `CPhysicsObj::prepare_to_enter_world` 0x00511FA0; `CObjectMaint::AddObjectToBeDestroyed` 0x00508F70; `CObjectMaint::UseTime` 0x005089B0; `docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md` | | AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 | | AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 | | AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now visible; only unmapped wording deviates, and those lines retain the raw code. Retire by porting the String-table lookup (#202). | An unmapped WeenieError shows a generic line instead of retail's exact sentence | retail String-table error lookup; ACE `WeenieError.cs` values | diff --git a/src/AcDream.App/Physics/RemoteMovementObservationTracker.cs b/src/AcDream.App/Physics/RemoteMovementObservationTracker.cs new file mode 100644 index 00000000..bbfca18f --- /dev/null +++ b/src/AcDream.App/Physics/RemoteMovementObservationTracker.cs @@ -0,0 +1,27 @@ +using System.Numerics; + +namespace AcDream.App.Physics; + +/// +/// Session-scoped observation timestamps used to infer when a remote +/// locomotion cycle has stopped. Canonical live identity remains in +/// LiveEntityRuntime; this owner contains only derived observer state. +/// +internal sealed class RemoteMovementObservationTracker +{ + private readonly Dictionary _lastMove = new(); + + internal (Vector3 Pos, DateTime Time) this[uint serverGuid] + { + set => _lastMove[serverGuid] = value; + } + + internal bool TryGetValue( + uint serverGuid, + out (Vector3 Pos, DateTime Time) observation) => + _lastMove.TryGetValue(serverGuid, out observation); + + internal bool Remove(uint serverGuid) => _lastMove.Remove(serverGuid); + + internal void Clear() => _lastMove.Clear(); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7bc131b8..5a088d91 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -372,8 +372,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // ACE Player_Tick.cs line 368: the client never sends "released forward" // MoveToState, so the server never broadcasts an explicit stop. Observer // must infer it from position deltas. - private readonly Dictionary - _remoteLastMove = new(); + private readonly AcDream.App.Physics.RemoteMovementObservationTracker + _remoteMovementObservations = new(); /// /// Per-remote-entity movement state for smoothing between server @@ -397,8 +397,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// keyed by server guid. Retail keeps these stamps on CPhysicsObj /// (update_times); seeded from CreateObject's PhysicsDesc timestamp /// block during , consulted at the - /// top of , dropped with the entity in - /// . + /// top of , and dropped by the live + /// entity teardown owner. /// private AcDream.App.World.LiveEntityRuntime? _liveEntities; private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness; @@ -2064,6 +2064,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // N.5 mandatory path: spawn adapters + dispatcher always construct. // _wbMeshAdapter, _meshShader, _textureCache, and _bindlessSupport are // all guaranteed non-null here (startup throws above if any are missing). + var liveEntityComponentLifecycle = + new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle(); { var wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter!); // Sequencer factory: look up Setup + MotionTable from dats and build @@ -2212,8 +2214,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _worldState, AdvanceLandblockPresentationRetirement); - var liveEntityComponentLifecycle = - new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle(); _liveEntities = new AcDream.App.World.LiveEntityRuntime( _worldState, new AcDream.App.World.CompositeLiveEntityResourceLifecycle( @@ -2224,9 +2224,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext entityScriptActivator.OnCreate, entityScriptActivator.OnRemove)), liveEntityComponentLifecycle); - liveEntityComponentLifecycle.Bind( - new AcDream.App.World.DelegateLiveEntityRuntimeComponentLifecycle( - TearDownLiveEntityRuntimeComponents)); _liveEntities.ProjectionVisibilityChanged += (record, visible) => { if (record.WorldEntity is { } entity) @@ -2237,10 +2234,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext if (record.WorldEntity is { } entity) _particleSink!.SetEntityPresentationVisible(entity.Id, visible); }; - _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController( - _liveEntities, - () => _playerServerGuid, - OnLiveEntityPruned); _projectileController = new AcDream.App.Physics.ProjectileController( _liveEntities, _physicsEngine, @@ -2566,6 +2559,24 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext () => _playerServerGuid, IsSealedDungeonCell, Console.WriteLine); + var liveEntityTeardown = + new AcDream.App.World.LiveEntityRuntimeTeardownController( + _liveEntities, + _liveEntityPresentation!, + _entityEffects!, + _remoteTeleportController!, + _selectionInteractions, + _selection, + _animatedEntities, + _remoteMovementObservations, + _translucencyFades, + _liveEntityProjectionWithdrawal!, + _equippedChildRenderer!, + _physicsEngine.ShadowObjects, + _liveEntityLights!, + _classificationCache, + () => _playerServerGuid); + liveEntityComponentLifecycle.Bind(liveEntityTeardown); _liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController( _liveEntities, Objects, @@ -2579,9 +2590,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _liveEntityPresentation!), originCoordinator, networkUpdateBridge, + liveEntityTeardown, PublishLocalPhysicsTimestamps, () => _playerServerGuid, _options.DumpLiveSpawns ? Console.WriteLine : null); + _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController( + _liveEntities, + () => _playerServerGuid, + candidate => _liveEntityHydration.OnPrune(candidate)); parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection); networkUpdateBridge.Bind( new AcDream.App.World.DelegateLiveEntityNetworkUpdateSink( @@ -2691,7 +2707,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext NetworkEffects = () => _entityEffects?.ClearNetworkState(), AnimationHookFrames = () => _animationHookFrames?.Clear(), LivePresentation = () => _liveEntityPresentation?.Clear(), - RemoteMovementDiagnostics = _remoteLastMove.Clear, + RemoteMovementDiagnostics = _remoteMovementObservations.Clear, }); private void ResetSessionMouseCapture() @@ -2831,7 +2847,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext spawn, static (hydration, value) => hydration.OnCreate(value)), Deleted: deletion => _inboundEntityEvents.Run( - this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)), + _liveEntityHydration!, deletion, + static (hydration, value) => hydration.OnDelete(value)), PickedUp: pickup => _inboundEntityEvents.Run( _liveEntityHydration!, pickup, static (hydration, value) => hydration.OnPickup(value)), @@ -3015,41 +3032,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext block.Height, heightTable, landblockX, landblockY, localX, localY); } - private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete) - { - // A Delete can arrive before CreateObject, in which case no instance - // timestamp owner exists and its mixed pending effect FIFO must be - // discarded directly. For a known record, cleanup belongs after the - // generation gate: a stale Delete must not cancel the current owner. - if (_liveEntities?.TryGetRecord(delete.Guid, out _) != true) - _entityEffects?.ForgetUnknownOwner(delete.Guid); - bool removed = _liveEntities!.UnregisterLiveEntity( - delete, - isLocalPlayer: delete.Guid == _playerServerGuid, - beforeTeardown: () => - { - AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete); - }); - - if (removed - && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - { - Console.WriteLine( - $"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); - } - } - - private void OnLiveEntityPruned(AcDream.App.World.LiveEntityPruneCandidate candidate) - { - // ACE retains KnownObjects across normal teleports and consequently - // does not issue an F747 when an old region leaves client visibility. - // Route the 25-second client liveness expiry through the exact same - // generation gate and symmetric teardown as a wire ObjectDelete. - OnLiveEntityDeleted(new AcDream.Core.Net.Messages.DeleteObject.Parsed( - candidate.ServerGuid, - candidate.Generation)); - } - /// /// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) + /// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette / @@ -3556,105 +3538,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext hiddenEntityHost.NotifyHidden(); } - private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record) - { - record.RuntimeComponentTeardownPlan ??= CreateLiveEntityRuntimeTeardownPlan(record); - record.RuntimeComponentTeardownPlan.Advance(); - } - - private AcDream.App.World.LiveEntityTeardownPlan CreateLiveEntityRuntimeTeardownPlan( - LiveEntityRecord record) - { - uint serverGuid = record.ServerGuid; - var incarnation = new AcDream.App.World.LiveEntityIncarnationCleanup( - record, - guid => _liveEntities?.TryGetRecord(guid, out LiveEntityRecord current) == true - ? current - : null); - var cleanups = new List - { - () => _liveEntityPresentation?.Forget(record), - () => _entityEffects?.OnLiveEntityUnregistered(record), - () => _remoteTeleportController?.Forget(record), - () => - { - bool replacementExists = - _liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord current) == true - && !ReferenceEquals(current, record); - if (_selectionInteractions is { } interactions) - { - interactions.OnEntityRemoved(record, replacementExists); - } - else if (!replacementExists && _selection.SelectedObjectId == serverGuid) - { - _selection.Clear( - AcDream.Core.Selection.SelectionChangeSource.System, - AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved); - } - }, - }; - - if (record.WorldEntity is not { } existingEntity) - { - return new AcDream.App.World.LiveEntityTeardownPlan(cleanups); - } - - // R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains - // independently (r3-port-plan §4): the manager's (each pending - // animation fires MotionDone(success:false) → the bound interp pops - // in step) THEN the interp's own (flushes any remainder — - // MovementManager::HandleExitWorld 0x00524350, the R5-V5 facade - // relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30). - if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone)) - cleanups.Add(() => aeGone.Sequencer?.Manager.HandleExitWorld()); - RemoteMotion? rmGone = record.RemoteMotionRuntime as RemoteMotion; - if (rmGone is not null) - cleanups.Add(rmGone.Movement.HandleExitWorld); - // R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this - // entity that it left the world (TargetManager::NotifyVoyeurOfEvent - // ExitWorld) — a watcher moving-to/stuck-to this entity drops the - // moveto/stick. This is the ONLY clean-up for a watcher whose target - // already sent an Ok (once status != Undefined the 10 s staleness - // timeout never fires), so it must run before the host is pruned. - EntityPhysicsHost? ephGone = record.PhysicsHost as EntityPhysicsHost; - if (ephGone is not null) - { - // R5-V3 (#171): retail exit_world (0x00514e60) order — the - // departing entity first tears down its OWN stick - // (PositionManager::UnStick @0x00514e88 — drops its voyeur - // subscription on whatever it was stuck to) and its own target - // subscription (TargetManager::ClearTarget @0x00514e97 — same - // for a plain mid-chase moveto), THEN NotifyVoyeurOfEvent - // (ExitWorld) tells the entities watching IT. Without the first - // two, a despawning attacker leaves a dead voyeur entry on its - // target until send-failure pruning. - cleanups.Add(ephGone.PositionManager.UnStick); - cleanups.Add(ephGone.ClearTarget); - cleanups.Add(ephGone.NotifyExitWorld); - } - cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id)); - cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id)); - - // Dead-reckon state is keyed by SERVER guid (not local id) so we - // clear using the same guid the next spawn/update would use. - // LiveEntityRuntime removes the canonical remote-motion index by - // captured reference after this App teardown plan converges. Never use - // the GUID-only dictionary view here: it may already expose a newer - // incarnation. - cleanups.Add(() => incarnation.RunIfNoReplacement( - () => _remoteLastMove.Remove(serverGuid))); - cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188 - cleanups.Add(() => _liveEntityProjectionWithdrawal!.LeaveWorld( - record, - _playerServerGuid)); - cleanups.Add(() => _equippedChildRenderer?.OnLogicalTeardown(record)); - // Logical teardown ends the retained shadow payload too. The weaker - // pickup/parent path stops after Suspend so re-entry can restore it. - cleanups.Add(() => _physicsEngine.ShadowObjects.Deregister(existingEntity.Id)); - cleanups.Add(() => _liveEntityLights?.Forget(existingEntity.Id)); - return new AcDream.App.World.LiveEntityTeardownPlan(cleanups); - } - /// /// R4-V5: shared retail unpack_movement type-6..9 routing /// (0x00524440 cases 6/7/8/9, decomp §2f) — one body for remotes @@ -4149,9 +4032,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext } // CRITICAL: when we enter a locomotion cycle (Walk/Run/etc), - // stamp the _remoteLastMove timestamp to "now". Without this, + // stamp the remote observation timestamp to "now". Without this, // the stop-detection loop in TickAnimations sees the previous - // _remoteLastMove timestamp (set by the last UpdatePosition, + // observation timestamp (set by the last UpdatePosition, // often >300ms ago during idle) and fires the stop signal // IMMEDIATELY — flipping the sequencer straight back to Ready. // The visible symptom was "remote char never animates; just @@ -4172,8 +4055,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // window from this transition. Without this, the entity // starts its run animation and is instantly interrupted. var refreshedTime = System.DateTime.UtcNow; - if (_remoteLastMove.TryGetValue(update.Guid, out var prev)) - _remoteLastMove[update.Guid] = (prev.Pos, refreshedTime); + if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev)) + _remoteMovementObservations[update.Guid] = (prev.Pos, refreshedTime); if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr)) dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds; } @@ -4936,16 +4819,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext if (update.Guid != _playerServerGuid) { var now = System.DateTime.UtcNow; - if (_remoteLastMove.TryGetValue(update.Guid, out var prev)) + if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev)) { float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos); if (moveDist > 0.05f) - _remoteLastMove[update.Guid] = (worldPos, now); + _remoteMovementObservations[update.Guid] = (worldPos, now); // else: leave old entry so "Time" = last real movement time } else { - _remoteLastMove[update.Guid] = (worldPos, now); + _remoteMovementObservations[update.Guid] = (worldPos, now); } // Retail-faithful hard-snap on UpdatePosition. @@ -7519,7 +7402,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // after that callback stack has unwound on the same update thread. try { - _liveEntities?.RetryPendingTeardowns(); + _liveEntityHydration?.RetryPendingTeardowns(); } catch (AggregateException error) { diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index a1d06811..1c20b5b7 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -146,6 +146,7 @@ internal sealed class LiveEntityHydrationController private readonly ILiveEntityReadyPublisher _ready; private readonly ILiveEntityWorldOriginCoordinator _origin; private readonly ILiveEntityNetworkUpdateSink _networkUpdates; + private readonly ILiveEntityTeardownCoordinator _teardown; private readonly Action _publishTimestamps; private readonly Func _playerGuid; private readonly Action? _diagnostic; @@ -159,6 +160,7 @@ internal sealed class LiveEntityHydrationController ILiveEntityReadyPublisher ready, ILiveEntityWorldOriginCoordinator origin, ILiveEntityNetworkUpdateSink networkUpdates, + ILiveEntityTeardownCoordinator teardown, Action publishTimestamps, Func playerGuid, Action? diagnostic = null) @@ -171,6 +173,7 @@ internal sealed class LiveEntityHydrationController _ready = ready ?? throw new ArgumentNullException(nameof(ready)); _origin = origin ?? throw new ArgumentNullException(nameof(origin)); _networkUpdates = networkUpdates ?? throw new ArgumentNullException(nameof(networkUpdates)); + _teardown = teardown ?? throw new ArgumentNullException(nameof(teardown)); _publishTimestamps = publishTimestamps ?? throw new ArgumentNullException(nameof(publishTimestamps)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -339,6 +342,47 @@ internal sealed class LiveEntityHydrationController return accepted; } + /// + /// Retail SmartBox::HandleDeleteObject @ 0x00451EA0 rejects the + /// player, requires the exact Instance timestamp, then performs one + /// symmetric logical teardown. An unknown owner only loses queued + /// pre-create effect packets. + /// + public bool OnDelete(DeleteObject.Parsed delete) + { + // SmartBox::HandleDeleteObject rejects the player before resolving an + // object or touching any queued owner state. A pre-Create player + // Delete must therefore be side-effect-free too. + if (delete.Guid == _playerGuid()) + return false; + + if (!_runtime.TryGetRecord(delete.Guid, out _)) + _teardown.ForgetUnknownOwner(delete.Guid); + + bool removed = _runtime.UnregisterLiveEntity( + delete, + isLocalPlayer: false, + beforeTeardown: () => + ObjectTableWiring.ApplyEntityDelete(_objects, delete)); + if (removed) + { + _diagnostic?.Invoke( + $"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); + } + return removed; + } + + /// + /// Retail's 25-second visibility expiry converges through the same exact + /// generation gate and teardown transaction as an authoritative F747. + /// + public bool OnPrune(LiveEntityPruneCandidate candidate) => + OnDelete(new DeleteObject.Parsed( + candidate.ServerGuid, + candidate.Generation)); + + public int RetryPendingTeardowns() => _runtime.RetryPendingTeardowns(); + /// /// Parent events may precede either CreateObject. The relationship owner /// retains wire order and invokes diff --git a/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs b/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs new file mode 100644 index 00000000..48bd8cb3 --- /dev/null +++ b/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs @@ -0,0 +1,167 @@ +using AcDream.App.Interaction; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; +using AcDream.Core.Selection; + +namespace AcDream.App.World; + +internal interface ILiveEntityTeardownCoordinator + : ILiveEntityRuntimeComponentLifecycle +{ + void ForgetUnknownOwner(uint serverGuid); +} + +/// +/// Owns the retryable App-component half of retail +/// CObjectMaint::DeleteObject @ 0x00508460. Each successful cleanup is +/// committed on the exact record tombstone, so a later retry never repeats an +/// ExitWorld notification or removes a same-GUID replacement. +/// +internal sealed class LiveEntityRuntimeTeardownController + : ILiveEntityTeardownCoordinator +{ + private readonly LiveEntityRuntime? _runtime; + private readonly LiveEntityPresentationController? _presentation; + private readonly EntityEffectController? _effects; + private readonly RemoteTeleportController? _remoteTeleport; + private readonly SelectionInteractionController? _interactions; + private readonly SelectionState? _selection; + private readonly LiveEntityAnimationRuntimeView? _animations; + private readonly RemoteMovementObservationTracker? _remoteMovementObservations; + private readonly TranslucencyFadeManager? _translucencyFades; + private readonly LiveEntityProjectionWithdrawalController? _projectionWithdrawal; + private readonly EquippedChildRenderController? _children; + private readonly ShadowObjectRegistry? _shadows; + private readonly LiveEntityLightController? _lights; + private readonly EntityClassificationCache? _classification; + private readonly Func? _playerGuid; + private readonly Func _createPlan; + private readonly Action _forgetUnknownOwner; + + public LiveEntityRuntimeTeardownController( + LiveEntityRuntime runtime, + LiveEntityPresentationController presentation, + EntityEffectController effects, + RemoteTeleportController remoteTeleport, + SelectionInteractionController? interactions, + SelectionState selection, + LiveEntityAnimationRuntimeView animations, + RemoteMovementObservationTracker remoteMovementObservations, + TranslucencyFadeManager translucencyFades, + LiveEntityProjectionWithdrawalController projectionWithdrawal, + EquippedChildRenderController children, + ShadowObjectRegistry shadows, + LiveEntityLightController lights, + EntityClassificationCache classification, + Func playerGuid) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _presentation = presentation ?? throw new ArgumentNullException(nameof(presentation)); + _effects = effects ?? throw new ArgumentNullException(nameof(effects)); + _remoteTeleport = remoteTeleport ?? throw new ArgumentNullException(nameof(remoteTeleport)); + _interactions = interactions; + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _animations = animations ?? throw new ArgumentNullException(nameof(animations)); + _remoteMovementObservations = remoteMovementObservations + ?? throw new ArgumentNullException(nameof(remoteMovementObservations)); + _translucencyFades = translucencyFades + ?? throw new ArgumentNullException(nameof(translucencyFades)); + _projectionWithdrawal = projectionWithdrawal + ?? throw new ArgumentNullException(nameof(projectionWithdrawal)); + _children = children ?? throw new ArgumentNullException(nameof(children)); + _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); + _lights = lights ?? throw new ArgumentNullException(nameof(lights)); + _classification = classification + ?? throw new ArgumentNullException(nameof(classification)); + _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _createPlan = CreatePlan; + _forgetUnknownOwner = effects.ForgetUnknownOwner; + } + + internal LiveEntityRuntimeTeardownController( + Func createPlan, + Action forgetUnknownOwner) + { + _createPlan = createPlan ?? throw new ArgumentNullException(nameof(createPlan)); + _forgetUnknownOwner = forgetUnknownOwner + ?? throw new ArgumentNullException(nameof(forgetUnknownOwner)); + } + + public void TearDown(LiveEntityRecord record) + { + ArgumentNullException.ThrowIfNull(record); + record.RuntimeComponentTeardownPlan ??= _createPlan(record); + record.RuntimeComponentTeardownPlan.Advance(); + } + + public void ForgetUnknownOwner(uint serverGuid) => + _forgetUnknownOwner(serverGuid); + + private LiveEntityTeardownPlan CreatePlan(LiveEntityRecord record) + { + LiveEntityRuntime runtime = _runtime!; + uint serverGuid = record.ServerGuid; + var incarnation = new LiveEntityIncarnationCleanup( + record, + guid => runtime.TryGetRecord(guid, out LiveEntityRecord current) + ? current + : null); + var cleanups = new List + { + () => _presentation!.Forget(record), + () => _effects!.OnLiveEntityUnregistered(record), + () => _remoteTeleport!.Forget(record), + () => + { + bool replacementExists = + runtime.TryGetRecord(serverGuid, out LiveEntityRecord current) + && !ReferenceEquals(current, record); + if (_interactions is { } interactions) + { + interactions.OnEntityRemoved(record, replacementExists); + } + else if (!replacementExists + && _selection!.SelectedObjectId == serverGuid) + { + _selection.Clear( + SelectionChangeSource.System, + SelectionChangeReason.SelectedObjectRemoved); + } + }, + }; + + if (record.WorldEntity is not { } existingEntity) + return new LiveEntityTeardownPlan(cleanups); + + // CPhysicsObj::exit_world @ 0x00514E60 drains the PartArray and + // MovementManager before unsticking/clearing the object's target and + // notifying its voyeurs. Preserve the two independent motion drains. + if (_animations!.TryGetValue(existingEntity.Id, out LiveEntityAnimationState animation)) + cleanups.Add(() => animation.Sequencer?.Manager.HandleExitWorld()); + if (record.RemoteMotionRuntime is RemoteMotion remoteMotion) + cleanups.Add(remoteMotion.Movement.HandleExitWorld); + if (record.PhysicsHost is EntityPhysicsHost physicsHost) + { + cleanups.Add(physicsHost.PositionManager.UnStick); + cleanups.Add(physicsHost.ClearTarget); + cleanups.Add(physicsHost.NotifyExitWorld); + } + + cleanups.Add(() => _animations.Remove(existingEntity.Id)); + cleanups.Add(() => _classification!.InvalidateEntity(existingEntity.Id)); + cleanups.Add(() => incarnation.RunIfNoReplacement( + () => _remoteMovementObservations!.Remove(serverGuid))); + cleanups.Add(() => _translucencyFades!.ClearEntity(existingEntity.Id)); + cleanups.Add(() => _projectionWithdrawal!.LeaveWorld( + record, + _playerGuid!())); + cleanups.Add(() => _children!.OnLogicalTeardown(record)); + cleanups.Add(() => _shadows!.Deregister(existingEntity.Id)); + cleanups.Add(() => _lights!.Forget(existingEntity.Id)); + return new LiveEntityTeardownPlan(cleanups); + } +} diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs index d16db0a7..96503f07 100644 --- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -26,6 +26,10 @@ public sealed class GameWindowLiveEntityCompositionTests [InlineData("OnLiveEntityPickedUp")] [InlineData("OnLiveParentUpdated")] [InlineData("TryAcceptParentForRender")] + [InlineData("OnLiveEntityDeleted")] + [InlineData("OnLiveEntityPruned")] + [InlineData("TearDownLiveEntityRuntimeComponents")] + [InlineData("CreateLiveEntityRuntimeTeardownPlan")] public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName) { Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation)); @@ -47,6 +51,7 @@ public sealed class GameWindowLiveEntityCompositionTests [InlineData(typeof(LiveEntityAppearanceBinding))] [InlineData(typeof(LiveEntityHydrationController))] [InlineData(typeof(DatLiveEntityProjectionMaterializer))] + [InlineData(typeof(LiveEntityRuntimeTeardownController))] public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType) { foreach (FieldInfo field in helperType.GetFields( diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index 0ff1c3a5..ba308732 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -550,6 +550,152 @@ public sealed class LiveEntityHydrationControllerTests Assert.Equal(0, fixture.Runtime.PendingTeardownCount); } + [Fact] + public void UnknownDelete_ForgetsOnlyPendingOwnerState() + { + using var fixture = new Fixture(originKnown: true); + + Assert.False(fixture.Controller.OnDelete( + new DeleteObject.Parsed(Guid, InstanceSequence: 1))); + + Assert.Equal([Guid], fixture.Teardown.ForgottenUnknownOwners); + Assert.Equal(0, fixture.Teardown.TearDownCount); + Assert.Equal(0, fixture.Runtime.Count); + } + + [Fact] + public void StaleDelete_DoesNotEndRemoteLiveIncarnation() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1)); + LiveEntityRecord current = fixture.Record; + + Assert.False(fixture.Controller.OnDelete( + new DeleteObject.Parsed(Guid, InstanceSequence: 1))); + + Assert.Same(current, fixture.Record); + Assert.NotNull(fixture.Objects.Get(Guid)); + Assert.Equal(0, fixture.Teardown.TearDownCount); + Assert.Empty(fixture.Teardown.ForgottenUnknownOwners); + } + + [Fact] + public void LocalPlayerDelete_IsSideEffectFreeBeforeAndAfterCreate() + { + using var fixture = new Fixture(originKnown: true, playerGuid: Guid); + + Assert.False(fixture.Controller.OnDelete( + new DeleteObject.Parsed(Guid, InstanceSequence: 1))); + Assert.Empty(fixture.Teardown.ForgottenUnknownOwners); + + fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1)); + LiveEntityRecord current = fixture.Record; + + Assert.False(fixture.Controller.OnDelete( + new DeleteObject.Parsed(Guid, InstanceSequence: 2))); + + Assert.Same(current, fixture.Record); + Assert.NotNull(fixture.Objects.Get(Guid)); + Assert.Equal(0, fixture.Teardown.TearDownCount); + Assert.Empty(fixture.Teardown.ForgottenUnknownOwners); + } + + [Fact] + public void ExactDelete_RemovesObjectAndTearsDownOnce() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + + Assert.True(fixture.Controller.OnDelete( + new DeleteObject.Parsed(Guid, InstanceSequence: 1))); + + Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); + Assert.Null(fixture.Objects.Get(Guid)); + Assert.Equal(1, fixture.Teardown.TearDownCount); + Assert.Equal(0, fixture.Runtime.PendingTeardownCount); + } + + [Fact] + public void Prune_UsesTheSameExactDeleteTransaction() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + + Assert.True(fixture.Controller.OnPrune( + new LiveEntityPruneCandidate(Guid, Generation: 1))); + + Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); + Assert.Null(fixture.Objects.Get(Guid)); + Assert.Equal(1, fixture.Teardown.TearDownCount); + } + + [Fact] + public void Delete_DrainsIndependentFailuresAndRetryDoesNotReplayObjectRemoval() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + int objectRemovedCalls = 0; + fixture.Objects.ObjectRemoved += _ => + { + objectRemovedCalls++; + throw new InvalidOperationException("fixture object-table observer failure"); + }; + bool failTeardown = true; + fixture.Teardown.TearDownAction = _ => + { + if (!failTeardown) + return; + failTeardown = false; + throw new InvalidOperationException("fixture component teardown failure"); + }; + + AggregateException error = Assert.Throws(() => + fixture.Controller.OnDelete( + new DeleteObject.Parsed(Guid, InstanceSequence: 1))); + + Assert.Contains(error.Flatten().InnerExceptions, + exception => exception.Message.Contains("object-table observer", StringComparison.Ordinal)); + Assert.Contains(error.Flatten().InnerExceptions, + exception => exception.Message.Contains("component teardown", StringComparison.Ordinal)); + Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); + Assert.Null(fixture.Objects.Get(Guid)); + Assert.Equal(1, objectRemovedCalls); + Assert.Equal(1, fixture.Runtime.PendingTeardownCount); + + Assert.Equal(1, fixture.Controller.RetryPendingTeardowns()); + Assert.Equal(0, fixture.Runtime.PendingTeardownCount); + Assert.Equal(1, objectRemovedCalls); + Assert.Equal(2, fixture.Teardown.TearDownCount); + } + + [Fact] + public void DeleteCallback_CanInstallNewerGenerationWithoutOldCleanupRemovingIt() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord old = fixture.Record; + bool replaced = false; + fixture.Teardown.TearDownAction = record => + { + Assert.Same(old, record); + if (replaced) + return; + replaced = true; + LiveEntityRegistrationResult result = fixture.Runtime.RegisterLiveEntity( + Spawn(Generation: 2, PositionSequence: 2, Name: "replacement")); + Assert.True(result.LogicalRegistrationCreated); + }; + + Assert.True(fixture.Controller.OnDelete( + new DeleteObject.Parsed(Guid, InstanceSequence: 1))); + + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord replacement)); + Assert.NotSame(old, replacement); + Assert.Equal((ushort)2, replacement.Generation); + Assert.Equal("replacement", replacement.Snapshot.Name); + Assert.Equal(0, fixture.Runtime.PendingTeardownCount); + } + [Fact] public void PartialProjection_IsRetriedInsteadOfMistakenForCompletedHydration() { @@ -1447,6 +1593,7 @@ public sealed class LiveEntityHydrationControllerTests public readonly RecordingReadyPublisher Ready; public readonly RecordingOrigin Origin; public readonly RecordingNetworkSink Network = new(); + public readonly RecordingTeardownCoordinator Teardown = new(); public readonly LiveEntityHydrationController Controller; public Action? PublishTimestampsAction { get; set; } @@ -1465,7 +1612,7 @@ public sealed class LiveEntityHydrationControllerTests Runtime = new LiveEntityRuntime( spatial, Resources, - new DelegateLiveEntityRuntimeComponentLifecycle(_ => { })); + Teardown); Materializer = new RecordingMaterializer(Runtime, Operations); Relationships = new RecordingRelationships(Operations); Ready = new RecordingReadyPublisher(Operations); @@ -1493,6 +1640,7 @@ public sealed class LiveEntityHydrationControllerTests Ready, Origin, Network, + Teardown, (guid, timestamps) => { Operations.Add($"timestamps:{timestamps.Instance}"); @@ -1743,6 +1891,23 @@ public sealed class LiveEntityHydrationControllerTests } } + private sealed class RecordingTeardownCoordinator + : ILiveEntityTeardownCoordinator + { + public int TearDownCount { get; private set; } + public List ForgottenUnknownOwners { get; } = []; + public Action? TearDownAction { get; set; } + + public void TearDown(LiveEntityRecord record) + { + TearDownCount++; + TearDownAction?.Invoke(record); + } + + public void ForgetUnknownOwner(uint serverGuid) => + ForgottenUnknownOwners.Add(serverGuid); + } + private static WorldSession.EntitySpawn Spawn( ushort Generation, ushort PositionSequence, diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTeardownControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTeardownControllerTests.cs new file mode 100644 index 00000000..c03639bf --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTeardownControllerTests.cs @@ -0,0 +1,115 @@ +using AcDream.App.World; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; + +namespace AcDream.App.Tests.World; + +public sealed class LiveEntityRuntimeTeardownControllerTests +{ + [Fact] + public void Retry_ReusesPlanAndDoesNotReplayCompletedOwners() + { + LiveEntityRecord record = new(Spawn()); + int factoryCalls = 0; + int completedOwnerCalls = 0; + int retryingOwnerCalls = 0; + bool failOnce = true; + var controller = new LiveEntityRuntimeTeardownController( + _ => + { + factoryCalls++; + return new LiveEntityTeardownPlan([ + () => completedOwnerCalls++, + () => + { + retryingOwnerCalls++; + if (failOnce) + { + failOnce = false; + throw new InvalidOperationException("fixture failure"); + } + }, + ]); + }, + _ => { }); + + Assert.Throws(() => controller.TearDown(record)); + controller.TearDown(record); + + Assert.Equal(1, factoryCalls); + Assert.Equal(1, completedOwnerCalls); + Assert.Equal(2, retryingOwnerCalls); + Assert.True(record.RuntimeComponentTeardownPlan!.IsComplete); + } + + [Fact] + public void UnknownOwner_IsForwardedWithoutCreatingATeardownPlan() + { + uint forgotten = 0u; + int factoryCalls = 0; + var controller = new LiveEntityRuntimeTeardownController( + _ => + { + factoryCalls++; + return new LiveEntityTeardownPlan([]); + }, + guid => forgotten = guid); + + controller.ForgetUnknownOwner(Guid); + + Assert.Equal(Guid, forgotten); + Assert.Equal(0, factoryCalls); + } + + private const uint Guid = 0x70000001u; + private const uint Cell = 0x01010001u; + + private static WorldSession.EntitySpawn Spawn() + { + var position = new CreateObject.ServerPosition( + Cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 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, + "fixture", + (uint)ItemType.Creature, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} From c203e4799a30bdd04a6d29571702e74ab176bec1 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 18:27:15 +0200 Subject: [PATCH 50/98] docs(plan): record live teardown checkpoint --- docs/architecture/code-structure.md | 7 +++++-- ...-21-gamewindow-slice-4-live-entity-integration.md | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 533d7793..1f3cfd10 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -528,8 +528,11 @@ materialization, origin bootstrap, and landblock recovery into `LiveEntityHydrationController`; `fe551496` moved ObjDesc, Parent, Pickup, child-ready, and exact leave-world/re-entry routing. Appearance and attached subtree recovery now use independent authority transactions and preserve the -same logical/runtime identities. Checkpoint E (Delete/prune and retryable -exact-incarnation teardown ownership) is active. +same logical/runtime identities. Checkpoint E is complete in `f38822c4`: +Delete, visibility prune, retryable exact-incarnation component cleanup, and +derived remote stop-observation lifetime now have focused owners. Checkpoint F +(Position/Vector/State/Movement and equal-generation CreateObject routing) is +active. #### Slice 5 — extract landblock presentation diff --git a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md index bf587f65..5fb2ed19 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md @@ -320,6 +320,18 @@ and adversarial re-reviews were clean. convergence; no surviving renderer/collision/script/effect/light owner. - Full Release build/test; review cycle. +Completed in `f38822c4`. `LiveEntityRuntimeTeardownController` now owns the +retryable App-component half of retail `CObjectMaint::DeleteObject` and keeps +the stable cleanup plan on the exact record tombstone. Hydration rejects the +local player before every lookup or side effect, gates remote Delete by the +exact Instance timestamp, discards only unknown-owner pending effects, and +routes the 25-second prune through the same transaction. Successful cleanup +steps never replay after a failure; a same-GUID replacement cannot be mutated +by the old incarnation's delayed cleanup. The derived remote stop-observation +state also has a focused session owner instead of a loose GameWindow +dictionary. Full Release validation passed 6,891 tests with five intentional +skips and no errors; all three corrected-diff re-reviews were clean. + ### F — network-update owner - Move Movement, Vector, State, and Position routing plus the equal-generation From aa90c64666812eb5e03fbadadf6cebdd85042b53 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 19:11:49 +0200 Subject: [PATCH 51/98] refactor(world): extract live entity network updates Move Position, Vector, State, Movement, and equal-generation CreateObject routing out of GameWindow while preserving per-channel authority, ForcePosition acknowledgement, and motion-runtime ownership. Add adversarial authority and exact-wire coverage so reentrant updates and GUID reuse cannot publish stale state. --- docs/architecture/code-structure.md | 3 + .../retail-divergence-register.md | 22 +- .../Input/LocalPlayerOutboundController.cs | 41 + ...DeferredLiveEntityMotionRuntimeBindings.cs | 64 + .../Physics/LiveEntityInboundAuthorityGate.cs | 209 ++ .../LiveEntityMotionRuntimeController.cs | 488 ++++ .../LiveEntityNetworkUpdateController.cs | 1789 +++++++++++++ .../Physics/LiveEntityVectorRoute.cs | 34 + .../Physics/LocalForcePositionTransaction.cs | 29 + .../Physics/RemotePhysicsUpdater.cs | 9 +- .../RemoteServerControlledVelocityCycle.cs | 81 + src/AcDream.App/Rendering/GameWindow.cs | 2307 +---------------- .../LiveEntitySameGenerationUpdateRouter.cs | 48 + .../LocalPlayerImmediatePositionTests.cs | 114 + ...redLiveEntityMotionRuntimeBindingsTests.cs | 85 + .../LiveEntityInboundAuthorityGateTests.cs | 408 +++ .../LiveEntityNetworkBranchRoutingTests.cs | 96 + .../GameWindowLiveEntityCompositionTests.cs | 21 + ...veEntitySameGenerationUpdateRouterTests.cs | 94 + 19 files changed, 3701 insertions(+), 2241 deletions(-) create mode 100644 src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs create mode 100644 src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs create mode 100644 src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs create mode 100644 src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs create mode 100644 src/AcDream.App/Physics/LiveEntityVectorRoute.cs create mode 100644 src/AcDream.App/Physics/LocalForcePositionTransaction.cs create mode 100644 src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs create mode 100644 src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs create mode 100644 tests/AcDream.App.Tests/Input/LocalPlayerImmediatePositionTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/DeferredLiveEntityMotionRuntimeBindingsTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs create mode 100644 tests/AcDream.App.Tests/World/LiveEntitySameGenerationUpdateRouterTests.cs diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 1f3cfd10..17608be2 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -195,6 +195,9 @@ src/AcDream.App/ │ ├── RemotePhysicsUpdater.cs # ordinary/Hidden remote narrow-tick integration │ ├── LiveEntityOrdinaryPhysicsUpdater.cs # manager-less canonical body Transition path │ ├── LiveEntityNetworkUpdateController.cs # Position/Vector/State/Motion App integration +│ ├── LiveEntityInboundAuthorityGate.cs # exact per-channel timestamp/version admission +│ ├── LiveEntityMotionRuntimeController.cs # shared host/setup/MoveTo/Sticky runtime policy +│ ├── DeferredLiveEntityMotionRuntimeBindings.cs # fail-fast construction-order bridge │ ├── LiveEntityShadowPublisher.cs # authoritative exact-owner/residency collision gate │ ├── RemoteInboundMotionDispatcher.cs # shared animated/headless UpdateMotion funnel │ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index dce12142..38ffd9a9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -86,7 +86,7 @@ accepted-divergence entries (#96, #49, #50). | AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams | | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | -| AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | +| AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | | AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it). The companion `MoveToCancelled` seam only withdraws the waiting client presentation/action. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`OnNaturalMoveToComplete`/`OnMoveToCancelled`); `src/AcDream.App/Rendering/GameWindow.cs` (player MoveTo seam binding); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete`/`MoveToCancelled`) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract; cancellation/error only clears retained intent. | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) | | AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | | AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` | @@ -144,7 +144,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewRenderer.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 | -| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression, called from `GameWindow.OnUpdate` ~:7401) + `GameWindow:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 | +| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 | | AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) | | AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | | AP-37 | LayoutDesc meters collapse Type-3 slice descendants into `UiMeter.BackLeft..FrontRight` and reuse `UiMeter.DrawHBar` rather than building those media descendants and dispatching retail `UIElement_Meter::DrawChildren`. Non-Type-3 meter children are imported normally. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`); `LayoutImporter.cs` meter child predicate | The current vitals/character meter shapes are visually accepted and fixture-pinned; this is a representation adaptation, not a controller overlay. | A meter with a different descendant/media structure can render empty or with incorrect clipping/direction | `UIElement_Meter::DrawChildren @ 0x0046FBD0`; production meter LayoutDesc fixtures | @@ -181,14 +181,14 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-75 | **NARROWED 2026-07-19 — adapter-boundary `adjust_motion` only.** `SetCycle` remaps TurnLeft/SideStepLeft/WalkBackward to their mirror command with negated speed before dispatch. Retail performs that normalization in `CMotionInterp`; GameWindow's local-player adapter can still pass raw ids directly | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap) | Preserves raw local callers until every caller enters through `MotionInterpreter`; literal DAT velocity and omega now flow through CSequence's complete Frame | A future caller that already normalizes a raw left/back command but still passes the original id can be adjusted twice | `CMotionInterp::adjust_motion` @305343; retire with the remaining local caller unification | | AP-77 | **NARROWED 2026-07-19 — animation-less/headless movement fallback only.** When `MotionInterpreter.DefaultSink` or the local PartArray callback is absent, acdream writes grounded command-derived body velocity and applies the DAT-pinned Humanoid `TurnRight` rate (1.5 radians/second) directly to the body Frame. Production animated players/remotes bind `MotionTableDispatchSink` plus CSequence and instead consume the complete DAT-authored root Frame; that path preserves airborne orientation while suppressing only origin exactly like retail | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`); `src/AcDream.App/Input/PlayerMovementController.cs` (no-PartArray object-quantum fallback) | Keeps isolated/headless physics tests and a deliberately animation-less entity controllable without fabricating a PartArray | A future production entity missing its animation binding uses Humanoid-only yaw/velocity, can foot-slide, and can rotate through an airborne quantum differently from a real creature's DAT Frame | `CMotionInterp::apply_interpreted_movement` 0x00528600; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire when animation-less production objects have an explicit motion owner | | AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) | -| AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks), moving to `LiveEntityNetworkUpdateController` in Slice 4 | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. | +| AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (VectorUpdate jump handler); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (landing blocks) | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. | | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | | AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) | | AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | | AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | | AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` | -| AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection + authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit | -| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) | +| AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit | +| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) | | AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) | | AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 | | AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` | @@ -215,8 +215,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | | AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | | AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` | -| AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Rendering/GameWindow.cs` (`RouteSameGenerationCreateObject`); moving to `LiveEntityNetworkUpdateController` in Slice 4 | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | -| AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); App same-generation route in `GameWindow.cs`, moving to `LiveEntityNetworkUpdateController` in Slice 4 | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` | +| AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | +| AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` | ## 4. Temporary stopgap (TS) — 34 active rows + 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) @@ -250,13 +250,13 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) | | TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 | | TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly | -| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 | -| TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame | -| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` | +| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 | +| TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame | +| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`GetSetupCylinder`); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (remote sweep fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` | | ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-91` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) | | TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` | | TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` | -| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Rendering/GameWindow.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` | +| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` | | TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order | | TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each object's ParticleManager and ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` also advances that static owner's managers using its whole admitted elapsed interval. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`) | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and manager order faithful without pretending the shared tails have per-owner timing. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static default scripts/particles use render elapsed rather than `animate_static_object` elapsed/discard timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum | | TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 | diff --git a/src/AcDream.App/Input/LocalPlayerOutboundController.cs b/src/AcDream.App/Input/LocalPlayerOutboundController.cs index daff2089..a0b8c6a1 100644 --- a/src/AcDream.App/Input/LocalPlayerOutboundController.cs +++ b/src/AcDream.App/Input/LocalPlayerOutboundController.cs @@ -121,6 +121,47 @@ public sealed class LocalPlayerOutboundController controller.SimTimeSeconds); } + /// + /// Retail ForcePosition acknowledgement sent immediately after the local + /// body is blipped. Unlike the periodic post-network sender, this bypasses + /// the elapsed/difference predicate but still requires a valid grounded + /// canonical frame. + /// + internal void SendImmediatePosition( + WorldSession? session, + PlayerMovementController? controller) + { + if (session is null + || controller is null + || !controller.CanSendPositionEvent) + { + return; + } + + if (!controller.TryGetOutboundPosition(out Position outboundPosition)) + return; + uint cellId = outboundPosition.ObjCellId; + Vector3 position = outboundPosition.Frame.Origin; + Quaternion rotation = outboundPosition.Frame.Orientation; + + uint sequence = session.NextGameActionSequence(); + byte[] body = AutonomousPosition.Build( + gameActionSequence: sequence, + cellId: cellId, + position: position, + rotation: rotation, + instanceSequence: session.InstanceSequence, + serverControlSequence: session.ServerControlSequence, + teleportSequence: session.TeleportSequence, + forcePositionSequence: session.ForcePositionSequence, + lastContact: 1); + session.SendGameAction(body); + controller.NotePositionSent( + outboundPosition, + controller.ContactPlane, + controller.SimTimeSeconds); + } + public bool TrySendMovement( WorldSession? session, PlayerMovementController? controller, diff --git a/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs b/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs new file mode 100644 index 00000000..6a2cb41c --- /dev/null +++ b/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs @@ -0,0 +1,64 @@ +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.Core.Net; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; + +namespace AcDream.App.Physics; + +/// +/// Narrow reverse seam for collaborators constructed before the live network +/// owner. It carries no state or identity: every call resolves the one bound +/// update-thread owner, and use before bind fails immediately. +/// +internal interface ILiveEntityMotionRuntimeBindings +{ + (float Radius, float Height) GetSetupCylinder(uint serverGuid, WorldEntity entity); + bool RouteServerMoveTo( + MovementManager movement, + uint cellId, + WorldSession.EntityMotionUpdate update); + void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid); + void ClearTargetForHiddenEntity(uint serverGuid); + IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid); +} + +internal sealed class DeferredLiveEntityMotionRuntimeBindings + : ILiveEntityMotionRuntimeBindings +{ + private ILiveEntityMotionRuntimeBindings? _target; + + public void Bind(ILiveEntityMotionRuntimeBindings target) + { + ArgumentNullException.ThrowIfNull(target); + if (_target is not null) + { + throw new InvalidOperationException( + "Live entity motion runtime bindings are already bound."); + } + _target = target; + } + + private ILiveEntityMotionRuntimeBindings Target => + _target ?? throw new InvalidOperationException( + "Live entity motion runtime bindings were used before binding."); + + public (float Radius, float Height) GetSetupCylinder( + uint serverGuid, + WorldEntity entity) => Target.GetSetupCylinder(serverGuid, entity); + + public bool RouteServerMoveTo( + MovementManager movement, + uint cellId, + WorldSession.EntityMotionUpdate update) => + Target.RouteServerMoveTo(movement, cellId, update); + + public void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid) => + Target.StickToObjectFromWire(host, targetGuid); + + public void ClearTargetForHiddenEntity(uint serverGuid) => + Target.ClearTargetForHiddenEntity(serverGuid); + + public IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid) => + Target.ResolvePhysicsHost(serverGuid); +} diff --git a/src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs b/src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs new file mode 100644 index 00000000..96916677 --- /dev/null +++ b/src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs @@ -0,0 +1,209 @@ +using System.Numerics; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; + +namespace AcDream.App.Physics; + +internal readonly record struct AcceptedMotionNetworkUpdate( + WorldSession.EntityMotionUpdate Update, + LiveEntityRecord Record, + AcceptedPhysicsTimestamps Timestamps, + ulong MovementAuthorityVersion, + ulong VelocityAuthorityVersion); + +internal readonly record struct AcceptedVectorNetworkUpdate( + VectorUpdate.Parsed Update, + LiveEntityRecord Record, + ulong VectorAuthorityVersion, + ulong VelocityAuthorityVersion); + +internal readonly record struct AcceptedStateNetworkUpdate( + SetState.Parsed Update, + LiveEntityRecord Record, + ulong StateAuthorityVersion); + +internal readonly record struct AcceptedPositionNetworkUpdate( + WorldSession.EntityPositionUpdate Update, + LiveEntityRecord Record, + WorldSession.EntitySpawn Spawn, + AcceptedPhysicsTimestamps Timestamps, + PositionTimestampDisposition TimestampDisposition, + ulong PositionAuthorityVersion, + ulong VelocityAuthorityVersion); + +/// +/// Owns the one retail timestamp/authority admission edge for live inbound +/// Movement, Vector, State, and Position packets. The accepted context pins +/// the exact record and channel versions before any re-entrant App callback +/// can run; channel presenters must revalidate those captures after callbacks. +/// +internal sealed class LiveEntityInboundAuthorityGate +{ + private readonly LiveEntityRuntime _liveEntities; + private readonly Action _publishTimestamps; + + public LiveEntityInboundAuthorityGate( + LiveEntityRuntime liveEntities, + Action publishTimestamps) + { + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _publishTimestamps = publishTimestamps + ?? throw new ArgumentNullException(nameof(publishTimestamps)); + } + + internal uint? LastLivePlayerLandblockId { get; private set; } + + internal void ResetSessionState() => LastLivePlayerLandblockId = null; + + internal bool TryAcceptMotion( + WorldSession.EntityMotionUpdate update, + bool retainPayload, + out AcceptedMotionNetworkUpdate accepted, + out bool timestampAccepted) + { + accepted = default; + timestampAccepted = _liveEntities.TryApplyMotion( + update, + retainPayload, + out _, + out AcceptedPhysicsTimestamps timestamps); + if (!timestampAccepted) + return false; + + LiveEntityRecord? record = null; + ulong movementAuthorityVersion = 0; + ulong velocityAuthorityVersion = 0; + if (retainPayload) + { + if (!_liveEntities.TryGetRecord(update.Guid, out record)) + return false; + movementAuthorityVersion = record.MovementAuthorityVersion; + velocityAuthorityVersion = record.VelocityAuthorityVersion; + } + + _publishTimestamps(update.Guid, timestamps); + if (!retainPayload) + return false; + if (!_liveEntities.IsCurrentMovementAuthority( + record!, + movementAuthorityVersion) + || !_liveEntities.IsCurrentVelocityAuthority( + record!, + velocityAuthorityVersion)) + { + return false; + } + + accepted = new AcceptedMotionNetworkUpdate( + update, + record!, + timestamps, + movementAuthorityVersion, + velocityAuthorityVersion); + return true; + } + + internal bool TryAcceptVector( + VectorUpdate.Parsed update, + bool payloadIsValid, + out AcceptedVectorNetworkUpdate accepted) + { + accepted = default; + if (!payloadIsValid + || !_liveEntities.TryApplyVector(update, out _) + || !_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record)) + { + return false; + } + + accepted = new AcceptedVectorNetworkUpdate( + update, + record, + record.VectorAuthorityVersion, + record.VelocityAuthorityVersion); + return true; + } + + internal bool TryAcceptState( + SetState.Parsed update, + out AcceptedStateNetworkUpdate accepted) + { + accepted = default; + if (!_liveEntities.TryApplyState(update, out _, out _) + || !_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record)) + { + return false; + } + + accepted = new AcceptedStateNetworkUpdate( + update, + record, + record.StateAuthorityVersion); + return true; + } + + internal bool TryAcceptPosition( + WorldSession.EntityPositionUpdate update, + uint localPlayerGuid, + Quaternion? forcePositionRotation, + Vector3? currentLocalVelocity, + bool payloadIsValid, + out AcceptedPositionNetworkUpdate accepted) + { + accepted = default; + if (!payloadIsValid) + return false; + + bool isLocalPlayer = update.Guid == localPlayerGuid; + bool known = _liveEntities.TryApplyPosition( + update, + isLocalPlayer, + isLocalPlayer ? forcePositionRotation : null, + isLocalPlayer ? currentLocalVelocity : null, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn spawn, + out AcceptedPhysicsTimestamps timestamps); + if (!known) + { + if (isLocalPlayer) + LastLivePlayerLandblockId = update.Position.LandblockId; + return false; + } + + LiveEntityRecord? record = null; + ulong positionAuthorityVersion = 0; + ulong velocityAuthorityVersion = 0; + if (disposition is not PositionTimestampDisposition.Rejected) + { + if (!_liveEntities.TryGetRecord(update.Guid, out record)) + return false; + positionAuthorityVersion = record.PositionAuthorityVersion; + velocityAuthorityVersion = record.VelocityAuthorityVersion; + } + + _publishTimestamps(update.Guid, timestamps); + if (disposition is PositionTimestampDisposition.Rejected) + return false; + if (!_liveEntities.IsCurrentPositionAuthority( + record!, + positionAuthorityVersion)) + { + return false; + } + + accepted = new AcceptedPositionNetworkUpdate( + update, + record!, + spawn, + timestamps, + disposition, + positionAuthorityVersion, + velocityAuthorityVersion); + return true; + } + + internal void ObserveAcceptedLocalPosition(uint landblockId) => + LastLivePlayerLandblockId = landblockId; +} diff --git a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs new file mode 100644 index 00000000..55195a3f --- /dev/null +++ b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs @@ -0,0 +1,488 @@ +using AcDream.App.Interaction; +using AcDream.App.Rendering; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.World; + +namespace AcDream.App.Physics; + +/// +/// Focused owner of reusable live CPhysicsObj-style host lookup, remote motion +/// binding, server MoveTo/sticky routing, setup-cylinder dimensions, and +/// Hidden target invalidation. It is independent of packet orchestration so +/// renderer, selection, and movement collaborators share one one-way service. +/// +internal sealed class LiveEntityMotionRuntimeController + : ILiveEntityMotionRuntimeBindings +{ + private readonly LiveEntityRuntime _liveEntities; + private readonly PhysicsDataCache _physicsDataCache; + private readonly Func _selectionInteractions; + private readonly SelectionState _selection; + private readonly LiveWorldOriginState _origin; + + private IReadOnlyDictionary _entitiesByServerGuid => + _liveEntities.MaterializedWorldEntities; + private IReadOnlyDictionary _visibleEntitiesByServerGuid => + _liveEntities.WorldEntities; + + public LiveEntityMotionRuntimeController( + LiveEntityRuntime liveEntities, + PhysicsDataCache physicsDataCache, + Func selectionInteractions, + SelectionState selection, + LiveWorldOriginState origin) + { + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache)); + _selectionInteractions = selectionInteractions ?? throw new ArgumentNullException(nameof(selectionInteractions)); + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + } + internal AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings( + RemoteMotion rm, LiveEntityAnimationState? ae, uint serverGuid) + { + AcDream.Core.Physics.AnimationSequencer? sequencer = ae?.Sequencer; + if (sequencer is not null && rm.Sink is null) + { + rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(sequencer); + rm.Motion.DefaultSink = rm.Sink; + } + // #174 (2026-07-05): the RemoveLinkAnimations seam is retail + // CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to + // CPartArray::HandleEnterWorld 0x00517d70 → + // MotionTableManager::HandleEnterWorld 0x0051bdd0: strip the + // sequence's link animations AND drain pending_animations + // completely (each pop fires MotionDone → CMotionInterp pops its + // pending_motions node in lockstep). The previous binding was the + // bare sequence strip — every LeaveGround (jump) stripped the + // animations that queued manager nodes were counting down on, + // orphaning them (NumAnims>0, anims gone) and permanently damming + // BOTH queues: MotionsPending() never drained again, so every + // armed moveto (ACE's walk-to-door mt-6, the close-range use turn) + // starved — the "door only works on a fresh session" bug. + if (sequencer is not null) + { + rm.Motion.RemoveLinkAnimations = () => sequencer.Manager.HandleEnterWorld(); + rm.Motion.InitializeMotionTables = () => sequencer.Manager.InitializeState(); + // R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions + // 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions). + rm.Motion.CheckForCompletedMotions = sequencer.Manager.CheckForCompletedMotions; + } + + // Host/MoveTo ownership does not depend on a render animation. AP-77 + // deliberately supplies CMotionInterp's headless body fallback when a + // live object has no PartArray sink, so build the manager once anyway. + if (rm.Host is not null) + return rm.Sink; + if (_liveEntities is not { } liveEntities + || !liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord hostRecord) + || !ReferenceEquals(hostRecord.RemoteMotionRuntime, rm)) + { + return rm.Sink; + } + // R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness + // wiring (the conformance-tested reference). Positions are WORLD + // space on both sides (getPosition + the MovementStruct positions + // the UM router builds) — one consistent space, so the manager's + // distance/heading math matches the harness exactly. + // R5-V5: the construction is now the MovementManager facade's + // MoveToFactory (the acdream stand-in for the physics_obj/weenie_obj + // backpointers retail's MakeMoveToManager 0x00524000 constructs + // from); MakeMoveToManager() below invokes it once, preserving the + // pre-facade eager timing. + var rmT = rm; + var mtBody = rm.Body; + // R5-V3 (#171): real setup cylsphere radii — retail CPartArray:: + // GetRadius/GetHeight (0x005180a0/0x005180b0). Lazy per-call resolve + // (a dictionary hit) so the read never races the spawn path's + // CacheSetup. Feeds GetCurrentDistance's UseSpheres cylinder math + // (own side) and StickyManager::adjust_offset's own-radius gap term. + // Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with + // R5-V3"). + if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var selfEntity)) + return rm.Sink; + // R5-V2: forward-declared so the MoveToManager's target seams route + // into the entity's TargetManager (retail CPhysicsObj::set_target → + // TargetManager::SetTarget). Assigned right after the manager is built. + EntityPhysicsHost host = null!; + double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + rm.Movement.MoveToFactory = () => + { + var mtm = new AcDream.Core.Physics.Motion.MoveToManager( + rm.Motion, + stopCompletely: () => rmT.Movement.PerformMovement( + new AcDream.Core.Physics.MovementStruct + { + Type = AcDream.Core.Physics.MovementType.StopCompletely, + }), + getPosition: () => new AcDream.Core.Physics.Position( + rmT.CellId, mtBody.Position, mtBody.Orientation), + getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading( + mtBody.Orientation), + setHeading: (h, _) => mtBody.Orientation = + AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h), + getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, + getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height, + contact: () => mtBody.OnWalkable, + isInterpolating: () => rmT.Interp.IsActive, + getVelocity: () => mtBody.Velocity, + getSelfId: () => serverGuid, + // R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum + // → the entity's TargetManager (replaces the AP-79 TrackedTarget* + // poll). The manager passes (0, top_level_id, 0.5, 0.0). + setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q), + clearTarget: () => host.ClearTarget(), + getTargetQuantum: () => host.TargetManager.GetTargetQuantum(), + setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q), + // R4-V5: real clock (same epoch-seconds base the per-remote tick uses). + curTime: NowSeconds); + // R5-V3 (#171, retires TS-39): bind the sticky seams to the host's + // PositionManager (host is constructed before MakeMoveToManager + // invokes this factory) — + // * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo + // (retail MoveToManager::BeginNextNode @0x00529d3a); + // * PerformMovement's head unstick: unstick_from_object → + // PositionManager::UnStick (MoveToManager.PerformMovement:414). + mtm.StickTo = (tlid, radius, height) => + host.PositionManager.StickTo(tlid, radius, height); + mtm.Unstick = host.PositionManager.UnStick; + return mtm; + }; + // TS-36 (remote side): the interp's interrupt seam is retail's + // interrupt_current_movement → MovementManager::CancelMoveTo(0x36) + // chain (V2's reentrancy tests prove the wiring is inert-safe; + // R5-V5: the chain now lands on the literal facade relay 0x005241b0). + rm.Motion.InterruptCurrentMovement = + () => rmT.Movement.CancelMoveTo( + AcDream.Core.Physics.WeenieError.ActionCancelled); + + // R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position + // is the entity's WORLD position (WorldEntity.Position — exactly the + // source the AP-79 poll used for a tracked target, so the voyeur system + // delivers the identical position for a moveto's quantum-0 case). + // HandleTargetting ticks per frame (added in the per-remote loop); + // HandleUpdateTarget fans deliveries to this entity's MoveToManager + // and (R5-V3, inside the host) its PositionManager — retail's + // CPhysicsObj::HandleUpdateTarget order. + var configuredHost = new EntityPhysicsHost( + serverGuid, + getPosition: () => new AcDream.Core.Physics.Position( + hostRecord.FullCellId, + hostRecord.WorldEntity?.Position ?? mtBody.Position, + mtBody.Orientation), + getVelocity: () => mtBody.Velocity, + getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, + inContact: () => mtBody.OnWalkable, + minterpMaxSpeed: () => rmT.Motion.GetMaxSpeed(), + curTime: NowSeconds, + physicsTimerTime: NowSeconds, + getObjectA: ResolvePhysicsHost, + // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head: + // MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade + // relay to the moveto side); the host chains the PositionManager + // leg after it. + handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info), + interruptCurrentMovement: () => rmT.Movement.CancelMoveTo( + AcDream.Core.Physics.WeenieError.ActionCancelled)); + host = EntityPhysicsHost.InstallOrRebind( + liveEntities, + hostRecord, + configuredHost); + rm.MarkFullPhysicsHostBound(); + + // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the + // MoveToManager via the factory above (host now exists for the + // sticky binds inside it). The UM funnel head's unstick + // (CPhysicsObj::unstick_from_object 0x0050eaea, invoked at the mt-0 + // routing sites) binds on the interp beside it. + rm.Movement.MakeMoveToManager(); + rm.Motion.UnstickFromObject = host.PositionManager.UnStick; + return rm.Sink; + } + + /// + /// R5-V2 — retail CObjectMaint::GetObjectA(id): resolve any entity's + /// by guid, the + /// cross-entity seam every host's GetObjectA uses. If the entity has + /// a bound host (from or + /// EnterPlayerModeNow) return it; otherwise, for any entity that + /// EXISTS in the world, lazily create a minimal position-only host. Retail + /// gives every CPhysicsObj the capacity to host a + /// target_manager — a STATIC object (chest, corpse) must still answer + /// add_voyeur so a mover can moveto/stick to it (without this, + /// auto-walk to a never-animated object would arm the moveto but never + /// receive the immediate target snapshot, and never start). The minimal + /// host is registered so subsequent lookups reuse it; it is NOT ticked + /// (a static object never drifts, so the AddVoyeur immediate snapshot is + /// all a watcher needs; despawn still fires ExitWorld via the registry). + /// + public AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id) + { + if (_liveEntities is not { } liveEntities) + return null; + + bool isActive = liveEntities.TryGetRecord(id, out LiveEntityRecord activeRecord); + if (!isActive) + return null; + + // TS-49: CObjCell::hide_object removes Hidden objects from the + // relationship surface until DetectionManager is ported. Pending, + // attached, and otherwise non-render-visible active objects remain in + // CObjectMaint's object table and must still resolve here. + if (liveEntities.IsHidden(id)) + { + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + Console.WriteLine( + $"[autowalk-host-miss] object=0x{id:X8} " + + $"materialized={_entitiesByServerGuid.ContainsKey(id)} " + + $"registered={liveEntities.TryGetPhysicsHost(id, out _)} " + + $"hidden={liveEntities.IsHidden(id)}"); + } + return null; + } + if (liveEntities.TryGetPhysicsHost(id, out var existing)) + return existing; + + double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + var minimal = EntityPhysicsHost.CreateMinimal( + activeRecord, + ResolvePhysicsHost, + NowSeconds); + liveEntities.InstallPhysicsHost(activeRecord, minimal); + return minimal; + } + + /// + /// R5-V3 (#171): retail CPartArray::GetRadius/GetHeight + /// (0x005180a0/0x005180b0 — setup->radius/height × scale; ACE + /// PartArray.cs:189-207 reads the same Setup-level fields). Returns + /// (0, 0) when the entity has no resolvable Setup (bare-GfxObj props) — + /// ACE's PartArray != null ? … : 0 fallback shape + /// (PhysicsObj.cs:951-952). Live spawns cache their Setup in + /// _physicsDataCache at spawn time (CacheSetup, ~3250), so this is + /// a dictionary hit for every creature. + /// + public (float Radius, float Height) GetSetupCylinder( + uint serverGuid, AcDream.Core.World.WorldEntity entity) + { + var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); + if (setup is null) + return (0f, 0f); + // Live spawns bake ObjScale into MeshRefs and never populate + // WorldEntity.Scale (a scenery-pipeline field, default 1.0) — the + // authoritative per-entity scale is the SPAWN RECORD's ObjScale, the + // same source the collision registration's entScale uses (~4137). + // entity.Scale remains the fallback for non-spawn entities (scenery — + // never a chase/sticky participant). Diff-review find: without this, + // a server-scaled creature variant read unscaled radii and kept the + // #171 interpenetration exactly for scaled bodies. + float scale = + _liveEntities.Snapshots.TryGetValue(serverGuid, out var sp) + && sp.ObjScale is { } objScale && objScale > 0f + ? objScale + : (entity.Scale > 0f ? entity.Scale : 1f); + return (setup.Radius * scale, setup.Height * scale); + } + + // #184 Slice 2a: ApplyPositionManagerDelta + SyncRemoteShadowToBody moved to + // AcDream.App.Physics.RemotePhysicsUpdater. ApplyPositionManagerDelta had no + // caller outside the DR tick; SyncRemoteShadowToBody is now called back via + // _remotePhysicsUpdater from the NPC UP-branch tail (below). + + /// + /// R5-V4: retail CPhysicsObj::stick_to_object (0x005127e0) — the + /// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the + /// motionFlags 0x1 trailer guid): resolve the target, read its PartArray + /// radius/height (0 when shapeless — retail's null-PartArray arm), then + /// PositionManager::StickTo. Unresolvable target → no stick at + /// all (retail's GetObjectA-null path). Retail resolves the target's + /// top-level PARENT first (parent ?? self); acdream's entity + /// table is flat (no client-side parenting), so the guid is used as-is — + /// the same convention as RouteServerMoveTo's TopLevelId. + /// + public void StickToObjectFromWire( + AcDream.Core.Physics.Motion.IPhysicsObjHost? host, + uint targetGuid) + { + if (host is not EntityPhysicsHost entityHost) + return; + if (_liveEntities is not { } liveEntities + || !liveEntities.TryGetInteractionEligibleEntity(targetGuid, out var tgtEnt)) + return; + var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt); + entityHost.PositionManager.StickTo(targetGuid, radius, height); + } + + public void ClearTargetForHiddenEntity(uint serverGuid) + { + if (_selectionInteractions() is { } interactions) + interactions.OnEntityHidden(serverGuid); + else if (_selection.SelectedObjectId == serverGuid) + _selection.Clear( + AcDream.Core.Selection.SelectionChangeSource.System, + AcDream.Core.Selection.SelectionChangeReason.Cleared); + + if (_liveEntities?.TryGetPhysicsHost(serverGuid, out var hiddenHost) == true + && hiddenHost is EntityPhysicsHost hiddenEntityHost) + hiddenEntityHost.NotifyHidden(); + } + + /// + /// R4-V5: shared retail unpack_movement type-6..9 routing + /// (0x00524440 cases 6/7/8/9, decomp §2f) — one body for remotes + /// (R4-V4) and the local player (R4-V5). Writes the wire run rate to + /// the interp (my_run_rate, unpack @300603/@300660 — plan M13), + /// builds the MovementStruct (mt 6/8 resolve the target guid + /// against the entity table; unresolvable degrades to + /// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f — + /// NOT an error), and calls the R5-V5 facade's PerformMovement + /// (MovementManager::PerformMovement 0x005240d0 — types 6-9 + /// MakeMoveToManager + delegate; retail's cases call MakeMoveToManager + /// at each head, a no-op here since the bind sites create eagerly). + /// Returns true when the event was a type-6..9 moveto (consumed); false + /// for every other movement type (caller falls through to its funnel / + /// skip posture). + /// + public bool RouteServerMoveTo( + AcDream.Core.Physics.Motion.MovementManager movement, + uint cellId, + AcDream.Core.Net.WorldSession.EntityMotionUpdate update) + { + if (update.MotionState.IsServerControlledMoveTo + && update.MotionState.MoveToPath is { } path) + { + // my_run_rate write (unpack_movement @300603). + if (update.MotionState.MoveToRunRate is { } mtRunRate) + movement.Minterp.MyRunRate = mtRunRate; + + var destWorld = AcDream.Core.Physics.Motion.MoveToMath + .OriginToWorld( + path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ, + _origin.CenterX, _origin.CenterY); + var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire( + path.Bitfield, + path.DistanceToObject, + path.MinDistance, + path.FailDistance, + update.MotionState.MoveToSpeed ?? 1f, + path.WalkRunThreshold, + path.DesiredHeading); + + var ms = new AcDream.Core.Physics.MovementStruct + { + Params = mp, + }; + // mt 6 with a resolvable target → MoveToObject (the P4 tracker + // feeds position updates per tick); else degrade to + // MoveToPosition at the wire origin (§2f). + if (update.MotionState.MovementType == 6 + && path.TargetGuid is { } tgtGuid + && _liveEntities is { } liveMoveEntities + && liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt)) + { + ms.Type = AcDream.Core.Physics.MovementType.MoveToObject; + ms.ObjectId = tgtGuid; + ms.TopLevelId = tgtGuid; + // R5-V3 (#171): retail resolves the TARGET object's PartArray + // radius/height at the MoveToObject call site + // (CPhysicsObj::MoveToObject 0x005128e9/0x00512903; ACE + // PhysicsObj.cs:951-952) — they become SoughtObjectRadius/ + // Height, feeding GetCurrentDistance's edge-to-edge arrival + // (UseSpheres) and the sticky-arrival handoff's target radius. + // Was unset (0): every attacker closed ~one body-radius deeper + // than retail before stopping — the #171 dogpile term. + (ms.Radius, ms.Height) = GetSetupCylinder(tgtGuid, tgtEnt); + ms.Pos = new AcDream.Core.Physics.Position( + cellId, tgtEnt.Position, + System.Numerics.Quaternion.Identity); + } + else + { + ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition; + ms.Pos = new AcDream.Core.Physics.Position( + cellId, destWorld, + System.Numerics.Quaternion.Identity); + } + movement.PerformMovement(ms); + return true; + } + + if (update.MotionState.IsServerControlledTurnTo + && update.MotionState.TurnToPath is { } turnPath) + { + var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo( + turnPath.Bitfield, + turnPath.Speed, + turnPath.DesiredHeading); + + var ms = new AcDream.Core.Physics.MovementStruct { Params = mp }; + if (update.MotionState.MovementType == 8 + && turnPath.TargetGuid is { } turnTgt + && _liveEntities is { } liveTurnEntities + && liveTurnEntities.TryGetInteractionEligibleEntity(turnTgt, out var turnEnt)) + { + ms.Type = AcDream.Core.Physics.MovementType.TurnToObject; + ms.ObjectId = turnTgt; + ms.TopLevelId = turnTgt; + ms.Pos = new AcDream.Core.Physics.Position( + cellId, turnEnt.Position, + System.Numerics.Quaternion.Identity); + } + else + { + ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading; + // Retail's mt-8 unresolvable-object fallback substitutes the + // STANDALONE wire heading into the params before degrading + // (decomp §2f case 8: `params.desired_heading = wire_heading`). + // Invisible against ACE (P6: both heading fields written from + // the same source) but required for the verbatim degrade — + // closes the V4 carry-over the adversarial review caught + // (TurnToPathData.WireHeading was parsed but never consumed). + if (update.MotionState.MovementType == 8 + && turnPath.WireHeading is { } wireHeading) + { + mp.DesiredHeading = wireHeading; + } + } + movement.PerformMovement(ms); + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + string target = turnPath.TargetGuid is { } targetGuid + ? $"0x{targetGuid:X8}" : "null"; + bool targetVisible = turnPath.TargetGuid is { } visibleGuid + && _visibleEntitiesByServerGuid.ContainsKey(visibleGuid); + bool targetHost = turnPath.TargetGuid is { } hostGuid + && _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true; + var moveTo = movement.MoveTo; + Console.WriteLine( + $"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} " + + $"routed={ms.Type} target={target} visible={targetVisible} " + + $"host={targetHost} stop={mp.StopCompletelyFlag} " + + $"initialized={moveTo?.Initialized ?? false} " + + $"nodes={moveTo?.PendingActions.Count() ?? 0} " + + $"command=0x{moveTo?.CurrentCommand ?? 0u:X8} " + + $"pendingMotions={movement.Minterp.MotionsPending()}"); + } + return true; + } + + return false; + } + + // #184 Slice 2a: TickRemoteMoveTo (retail MovementManager::UseTime + // 0x005242f0 — moveto steering / arrival / fail-distance; the P4 poll is + // gone, TargetManager voyeur delivers positions) moved to + // RemotePhysicsUpdater. The DR tick was its only caller. + + /// + /// Phase 6.6: the server says an entity's motion has changed. Look up + /// the LiveEntityAnimationState for that guid, re-resolve the idle cycle with the + /// new (stance, forward-command) override, and if the cycle is still + /// animated, swap in the new animation/frame range. Entities not in + /// the animated map (static props, entities rejected at spawn time) + /// are simply ignored — there's nothing to tick for them. + /// +} diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs new file mode 100644 index 00000000..a73c69eb --- /dev/null +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -0,0 +1,1789 @@ +using AcDream.App.Combat; +using AcDream.App.Input; +using AcDream.App.Interaction; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.World; +using AcDream.Content; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Items; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.World; +using DatReaderWriter; + +namespace AcDream.App.Physics; + +/// +/// Update-thread owner of retail SmartBox's accepted Movement, Vector, State, +/// and Position presentation transactions. Identity and timestamp authority +/// remain canonical in ; this controller only +/// routes an accepted exact incarnation into its App-layer owners. +/// +internal sealed class LiveEntityNetworkUpdateController + : ILiveEntityNetworkUpdateSink, + ILiveEntitySameGenerationUpdateSink +{ + private readonly LiveEntityRuntime _liveEntities; + private readonly ClientObjectTable _objects; + private readonly LiveEntityHydrationController _liveEntityHydration; + private readonly EntityEffectController _entityEffects; + private readonly LiveEntityPresentationController _liveEntityPresentation; + private readonly LiveEntityLightController _liveEntityLights; + private readonly EquippedChildRenderController _equippedChildRenderer; + private readonly ProjectileController _projectileController; + private readonly RemoteTeleportController _remoteTeleportController; + private readonly LiveEntityAnimationRuntimeView _animatedEntities; + private readonly LiveEntityRemoteMotionRuntimeView _remoteDeadReckon; + private readonly RemoteMovementObservationTracker _remoteMovementObservations; + private readonly RemotePhysicsUpdater _remotePhysicsUpdater; + private readonly RemoteInboundMotionDispatcher _remoteInboundMotion; + private readonly LiveEntityMotionRuntimeController _motionRuntime; + private readonly PhysicsEngine _physicsEngine; + private readonly IDatReaderWriter _dats; + private readonly IAnimationLoader _animLoader; + private readonly CombatTargetController? _combatTargetController; + private readonly LiveWorldOriginState _origin; + private readonly AcDream.App.Streaming.TeleportTransitCoordinator< + WorldSession.EntityPositionUpdate> _teleportTransit; + private readonly Func _playerControllerSource; + private readonly LocalPlayerOutboundController _localPlayerOutbound; + private readonly Func _playerHostSource; + private readonly Func _playerGuid; + private readonly Func _gameTime; + private readonly Func _session; + private readonly LiveEntityInboundAuthorityGate _authorityGate; + private readonly Action _aimTeleportDestination; + private readonly Action + _dumpMovementTruthServerEcho; + + private PlayerMovementController? _playerController => _playerControllerSource(); + private EntityPhysicsHost? _playerHost => _playerHostSource(); + private uint _playerServerGuid => _playerGuid(); + private double _physicsScriptGameTime => _gameTime(); + private IReadOnlyDictionary _entitiesByServerGuid => + _liveEntities.MaterializedWorldEntities; + private IReadOnlyDictionary _visibleEntitiesByServerGuid => + _liveEntities.WorldEntities; + + internal uint? LastLivePlayerLandblockId => + _authorityGate.LastLivePlayerLandblockId; + + public LiveEntityNetworkUpdateController( + LiveEntityRuntime liveEntities, + ClientObjectTable objects, + LiveEntityHydrationController liveEntityHydration, + EntityEffectController entityEffects, + LiveEntityPresentationController liveEntityPresentation, + LiveEntityLightController liveEntityLights, + EquippedChildRenderController equippedChildRenderer, + ProjectileController projectileController, + RemoteTeleportController remoteTeleportController, + LiveEntityAnimationRuntimeView animatedEntities, + LiveEntityRemoteMotionRuntimeView remoteDeadReckon, + RemoteMovementObservationTracker remoteMovementObservations, + RemotePhysicsUpdater remotePhysicsUpdater, + RemoteInboundMotionDispatcher remoteInboundMotion, + LiveEntityMotionRuntimeController motionRuntime, + PhysicsEngine physicsEngine, + IDatReaderWriter dats, + IAnimationLoader animLoader, + CombatTargetController? combatTargetController, + LiveWorldOriginState origin, + AcDream.App.Streaming.TeleportTransitCoordinator teleportTransit, + Func playerControllerSource, + LocalPlayerOutboundController localPlayerOutbound, + Func playerHostSource, + Func playerGuid, + Func gameTime, + Func session, + Action publishTimestamps, + Action aimTeleportDestination, + Action dumpMovementTruthServerEcho) + { + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _liveEntityHydration = liveEntityHydration ?? throw new ArgumentNullException(nameof(liveEntityHydration)); + _entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects)); + _liveEntityPresentation = liveEntityPresentation ?? throw new ArgumentNullException(nameof(liveEntityPresentation)); + _liveEntityLights = liveEntityLights ?? throw new ArgumentNullException(nameof(liveEntityLights)); + _equippedChildRenderer = equippedChildRenderer ?? throw new ArgumentNullException(nameof(equippedChildRenderer)); + _projectileController = projectileController ?? throw new ArgumentNullException(nameof(projectileController)); + _remoteTeleportController = remoteTeleportController ?? throw new ArgumentNullException(nameof(remoteTeleportController)); + _animatedEntities = animatedEntities ?? throw new ArgumentNullException(nameof(animatedEntities)); + _remoteDeadReckon = remoteDeadReckon ?? throw new ArgumentNullException(nameof(remoteDeadReckon)); + _remoteMovementObservations = remoteMovementObservations ?? throw new ArgumentNullException(nameof(remoteMovementObservations)); + _remotePhysicsUpdater = remotePhysicsUpdater ?? throw new ArgumentNullException(nameof(remotePhysicsUpdater)); + _remoteInboundMotion = remoteInboundMotion ?? throw new ArgumentNullException(nameof(remoteInboundMotion)); + _motionRuntime = motionRuntime ?? throw new ArgumentNullException(nameof(motionRuntime)); + _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine)); + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _animLoader = animLoader ?? throw new ArgumentNullException(nameof(animLoader)); + _combatTargetController = combatTargetController; + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _teleportTransit = teleportTransit ?? throw new ArgumentNullException(nameof(teleportTransit)); + _playerControllerSource = playerControllerSource ?? throw new ArgumentNullException(nameof(playerControllerSource)); + _localPlayerOutbound = localPlayerOutbound + ?? throw new ArgumentNullException(nameof(localPlayerOutbound)); + _playerHostSource = playerHostSource ?? throw new ArgumentNullException(nameof(playerHostSource)); + _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _gameTime = gameTime ?? throw new ArgumentNullException(nameof(gameTime)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _authorityGate = new LiveEntityInboundAuthorityGate( + liveEntities, + publishTimestamps); + _aimTeleportDestination = aimTeleportDestination ?? throw new ArgumentNullException(nameof(aimTeleportDestination)); + _dumpMovementTruthServerEcho = dumpMovementTruthServerEcho ?? throw new ArgumentNullException(nameof(dumpMovementTruthServerEcho)); + } + + internal void ResetSessionState() => _authorityGate.ResetSessionState(); + + private static bool IsPlayerGuid(uint guid) => + (guid & 0xFF000000u) == 0x50000000u; + + private static bool IsDoorName(string? name) => name == "Door"; + + private bool RunRemoteTeleportHook( + uint serverGuid, + uint localEntityId, + Func isCurrent) + { + _remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote); + EntityPhysicsHost? host = + _liveEntities.TryGetPhysicsHost(serverGuid, out var registered) + ? registered as EntityPhysicsHost + : null; + return RemoteTeleportHook.Execute( + new RemoteTeleportHookActions( + CancelMoveTo: error => remote?.Movement.CancelMoveTo(error), + UnStick: () => host?.PositionManager.UnStick(), + StopInterpolating: () => remote?.Interp.Clear(), + UnConstrain: () => host?.PositionManager.UnConstrain(), + NotifyTeleported: () => host?.NotifyTeleported(), + ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)), + isCurrent); + } + public void ApplySameGeneration( + SameGenerationCreateObjectEvents refresh) => + LiveEntitySameGenerationUpdateRouter.Apply(refresh, this); + + void ILiveEntitySameGenerationUpdateSink.OnDescription( + uint ownerGuid, + PhysicsSpawnData description) + { + if (_liveEntities.TryGetEffectProfile( + ownerGuid, + out var effectProfile) + && effectProfile is EntityEffectProfile liveProfile) + { + liveProfile.ApplyNetworkDescription(description); + _entityEffects.OnLiveEntityDescriptionChanged(ownerGuid); + } + } + + void ILiveEntitySameGenerationUpdateSink.OnAppearance( + AcDream.Core.Net.Messages.ObjDescEvent.Parsed appearance) => + _liveEntityHydration.OnAppearance(appearance); + + void ILiveEntitySameGenerationUpdateSink.OnParent(CreateParentUpdate parent) => + _liveEntityHydration.OnCreateParentAccepted(parent); + + void ILiveEntitySameGenerationUpdateSink.OnPosition( + WorldSession.EntityPositionUpdate position) => OnPosition(position); + + void ILiveEntitySameGenerationUpdateSink.OnPickup( + AcDream.Core.Net.Messages.PickupEvent.Parsed pickup) => + _liveEntityHydration.OnPickup(pickup); + + void ILiveEntitySameGenerationUpdateSink.OnMovement( + WorldSession.EntityMotionUpdate movement) => OnMotion(movement); + + void ILiveEntitySameGenerationUpdateSink.OnState( + AcDream.Core.Net.Messages.SetState.Parsed state) => OnState(state); + + void ILiveEntitySameGenerationUpdateSink.OnVector( + AcDream.Core.Net.Messages.VectorUpdate.Parsed vector) => OnVector(vector); + + + public void OnMotion(AcDream.Core.Net.WorldSession.EntityMotionUpdate update) + { + // L.2g S1 (DEV-6): retail staleness gate — BEFORE any state mutation. + // Retail drops stale/duplicate/superseded movement events at + // DispatchSmartBoxEvent (INSTANCE_TS, pseudo-C:357214) + + // CPhysics::SetObjectMovement (MOVEMENT_TS strictly-newer + + // SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered + // straggler re-applies an old gait or un-stops a stop. + bool retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous; + if (!_authorityGate.TryAcceptMotion( + update, + retainPayload, + out AcceptedMotionNetworkUpdate accepted, + out bool timestampAccepted)) + { + if (!timestampAccepted + && (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + ) + { + Console.WriteLine( + $"[UM_STALE] guid={update.Guid:X8} inst={update.InstanceSequence} " + + $"mov={update.MovementSequence} sc={update.ServerControlSequence} dropped"); + } + return; + } + + // R4-V5 (pin P1): retail CPhysics::SetObjectMovement's autonomous + // gate (0x00509690 @0050972e, raw 271370-271431) — a movement event + // whose wire autonomous byte is set is DROPPED ENTIRELY (no state + // application, no interrupt) when the addressed object IsThePlayer. + // ACE reflects the client's own outbound MoveToState back to the + // sender with IsAutonomous=1 hardcoded (MovementData.cs:162 + + // Player_Networking.cs:365) and retail never lets that echo reach + // unpack_movement — which is what makes the unconditional + // unpack-head interrupt in the player branch below safe against + // ACE. Order matches retail: the sequence gates above run FIRST. + // last_move_was_autonomous is NOT stored for dropped events (stored + // only on the unpack path). This retires the row-less "don't cancel + // on non-MoveTo UM" adaptation that lived here pre-V5 (its causal + // story was stale — V0-pins.md P1). Run-rate sync is re-anchored to + // retail's own feeds: PlayerDescription skills (SetCharacterSkills, + // K-fix7) + the mt-6/7 my_run_rate wire write below (M13) — the + // former ApplyServerRunRate echo tap is deleted, not gated. + LiveEntityRecord acceptedMotionRecord = accepted.Record; + ulong acceptedMovementAuthorityVersion = + accepted.MovementAuthorityVersion; + ulong acceptedMovementVelocityAuthorityVersion = + accepted.VelocityAuthorityVersion; + + if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; + if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) + { + DispatchRemoteInboundMotion( + update, + entity, + ae: null, + acceptedMotionRecord, + acceptedMovementAuthorityVersion, + acceptedMovementVelocityAuthorityVersion); + return; + } + if (_dats is null) return; + + // Re-resolve using the new stance/command. Keep the setup and + // motion-table we already know about — the server's motion + // updates override state within the same table, not swap tables. + // + // IMPORTANT: stance and command are BOTH optional. Remote-player + // autonomous broadcasts frequently set only one flag (e.g. just + // ForwardCommand) with currentStyle=0x0000 meaning "no stance + // change — keep current." Treating stance=0 as "default stance" + // drops the real state; instead we preserve the sequencer's + // current style. + ushort stance = update.MotionState.Stance; + ushort? command = update.MotionState.ForwardCommand; + + // A.1 (Commit A.1 2026-05-03): UM_RAW — every inbound UM, one line, + // gated on ACDREAM_REMOTE_VEL_DIAG=1. Skips the local player. Tells + // us the actual UM arrival rate per remote and which fields are set + // on each. The bug-suspect is "ACE sends UMs without ForwardCommand + // bit during running, our picker resolves to Ready, SetCycle(Ready) + // resets the cycle". This diag lets us count how often that happens. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && update.Guid != _playerServerGuid) + { + string cmdStrRaw = command.HasValue ? $"0x{command.Value:X4}" : "null"; + string sideStr = update.MotionState.SideStepCommand is { } s ? $"0x{s:X4}" : "null"; + string turnStr = update.MotionState.TurnCommand is { } t ? $"0x{t:X4}" : "null"; + string fwdSpdStr = update.MotionState.ForwardSpeed is { } fs ? $"{fs:F2}" : "null"; + uint seqMot = ae.Sequencer?.CurrentMotion ?? 0; + System.Console.WriteLine( + $"[UM_RAW] guid={update.Guid:X8} stance=0x{stance:X4} fwd={cmdStrRaw} fwdSpd={fwdSpdStr} " + + $"side={sideStr} turn={turnStr} mt=0x{update.MotionState.MovementType:X2} " + + $"isMoveTo={update.MotionState.IsServerControlledMoveTo} " + + $"seq.CurrentMotion=0x{seqMot:X8}"); + } + + // Diagnostic: dump every inbound UpdateMotion so we can trace why + // remote chars don't transition off RunForward when they stop. + // Enable with ACDREAM_DUMP_MOTION=1. + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1" + && update.Guid != _playerServerGuid) + { + string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null"; + float spd = update.MotionState.ForwardSpeed + ?? ((update.MotionState.MoveToSpeed ?? 0f) + * (update.MotionState.MoveToRunRate ?? 0f)); + uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0; + uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0; + Console.WriteLine( + $"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " + + $"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}"); + } + + // Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled + && IsDoorName(_objects.Get(update.Guid)?.Name)) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}")); + } + + // ── Sequencer path (preferred) ────────────────────────────────── + // Call SetCycle directly. The sequencer already handles: + // - left→right / backward→forward remapping via adjust_motion + // - style and motion as u32 MotionCommand values + // - fast-path for identical state + // + // When the server omits a field (stance flag not set, or command + // flag not set), "no change" means we must preserve the sequencer's + // current state, NOT fall back to a table default. + if (ae.Sequencer is not null) + { + uint fullStyle = stance != 0 + ? (0x80000000u | (uint)stance) + : ae.Sequencer.CurrentStyle; + + // ACE's stop signal: ForwardCommand flag CLEARED on the wire. + // Per ACE InterpretedMotionState(MovementData) ctor + BuildMovementFlags, + // when the player releases keys the InterpretedMotionState has + // ForwardCommand = Invalid (default) and BuildMovementFlags doesn't + // set bit 0x02 — so the field is absent. Retail's decompiled + // handler (FUN_005295D0 → FUN_0051F260 @ chunk_00510000.c:13957) + // bulk-copies Invalid/0 into the physics obj, which StopCompletely + // treats as "return to style default (Ready)." + // + // command == null → retail stop signal → Ready + // command.Value == 0 → explicit 0 (rare) → Ready + // otherwise → resolve class byte and use full cmd + float speedMod = update.MotionState.ForwardSpeed ?? 1f; + uint fullMotion; + // R4-V4: the PlanMoveToStart seed is DELETED — MoveTo UMs no + // longer flow through the interpreted funnel at all (retail + // unpack_movement routes types 6-9 to MoveToManager; only type + // 0 does the interpreted-state copy). The manager's own + // BeginMoveForward -> get_command -> _DoMotion produces the + // cycle through the same sink every other motion uses. + if (!command.HasValue || command.Value == 0) + { + fullMotion = 0x41000003u; + } + else + { + // Use MotionCommandResolver to restore the proper class + // byte from the wire's 16-bit ForwardCommand. + uint resolved = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(command.Value); + fullMotion = resolved != 0 + ? resolved + : (ae.Sequencer.CurrentMotion & 0xFF000000u) | (uint)command.Value; + if (fullMotion == (uint)command.Value) // no class bits yet + fullMotion = 0x40000000u | (uint)command.Value; + } + + // ForwardSpeed from the InterpretedMotionState (flag 0x04). + // ACE omits this field when speed == 1.0 (only sets the flag + // when ForwardSpeed != 1.0 — InterpretedMotionState.cs:101). + // So: + // - field absent → default 1.0 (normal speed) + // - field present → USE THE VALUE, including zero. + // + // Zero is a VALID stop signal: when the retail client releases + // W, ACE broadcasts WalkForward with ForwardSpeed=0 (via + // apply_run_to_command). Treating zero as "unspecified / 1.0" + // produces "slow walk that never stops" — exactly what the + // stop bug looked like. + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1" + && update.Guid != _playerServerGuid) + Console.WriteLine( + $"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8}, speed={speedMod:F2})"); + + // No-op if same; the sequencer's fast path guards against that. + uint priorMotion = ae.Sequencer.CurrentMotion; + + // The SetObjectMovement gate above already rejects local + // autonomous echoes. A local event reaching this point is + // server-authored, so retail applies it through the interpreted + // funnel. This is especially important for attacks: ACE chooses + // the exact swing and carries it in Commands[]. + if (update.Guid == _playerServerGuid) + { + // B.6 slice 1 (2026-05-14): trace inbound motion for the + // local player. One line per inbound UM, gated on + // ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5). + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + string cmdHex = command.HasValue ? $"0x{command.Value:X4}" : "null"; + string pathStr = update.MotionState.MoveToPath is { } p + ? $"path=cell=0x{p.OriginCellId:X8},xyz=({p.OriginX:F2},{p.OriginY:F2},{p.OriginZ:F2}),minDist={p.MinDistance:F2},objDist={p.DistanceToObject:F2}" + : "path=null"; + string spd = update.MotionState.ForwardSpeed is { } fs + ? $"fwdSpd={fs:F2}" + : "fwdSpd=null"; + string mtsSpd = update.MotionState.MoveToSpeed is { } ms + ? $"mtSpd={ms:F2}" + : "mtSpd=null"; + string mtsRun = update.MotionState.MoveToRunRate is { } mr + ? $"mtRun={mr:F2}" + : "mtRun=null"; + Console.WriteLine(System.FormattableString.Invariant( + $"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}")); + } + + // R4-V5: retail unpack_movement dispatch for the local + // player — the SAME shape the remote branch uses below. + // Head (@300566): interrupt + unstick fire for EVERY + // movement event that reached unpack (the P1 gate above + // already dropped the autonomous echoes that would have + // made this unsafe against ACE); then types 6-9 route to + // the player's MoveToManager. mt-0 falls through to the + // interpreted-state copy below; LastMoveWasAutonomous=false + // is the local equivalent of LoseControlToServer until the + // next user-input edge takes control back. + if (_playerController is not null) + { + // P1 tail (00509730): the unpack path stores the wire + // autonomous byte BEFORE unpack_movement — always false + // here (the gate above dropped autonomous events). This + // is what routes the controller's per-tick pump (A3 + // dual dispatch) to the INTERPRETED branch during a + // server moveto. LOCAL PLAYER ONLY for now: remotes' + // interps have no WeenieObj, which A3 treats as + // IsThePlayer — storing a remote player's autonomous + // byte would flip their per-tick apply onto the raw + // branch and clobber their funnel state; the remote + // store lands with real remote weenies (R5+). + _playerController.SetLastMoveWasAutonomous(update.IsAutonomous); + bool IsCurrentLocalMotion() => + _liveEntities.IsCurrentMovementAuthority( + acceptedMotionRecord, + acceptedMovementAuthorityVersion) + && _liveEntities.IsCurrentVelocityAuthority( + acceptedMotionRecord, + acceptedMovementVelocityAuthorityVersion) + && ReferenceEquals( + acceptedMotionRecord.WorldEntity, + entity); + if (!IsCurrentLocalMotion()) + return; + + // Local and remote packets now share the literal + // MovementManager::unpack_movement funnel. Besides + // removing duplicate retail ordering, the authority + // predicate is rechecked after every callback boundary; + // a nested newer packet can never be overwritten by the + // tail of this older one. + AcDream.App.Physics.RemoteInboundMotionDispatchResult localDispatch = + _remoteInboundMotion.Apply( + update, + _playerController.Movement, + _playerController.Motion.DefaultSink, + _playerHost, + _playerController.CellId, + ae.Sequencer.CurrentMotion & 0xFF000000u, + IsCurrentLocalMotion); + if (localDispatch.Superseded + || !IsCurrentLocalMotion()) + { + return; + } + if (localDispatch.RoutedMoveTo) + { + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={_playerController.Movement.IsMovingTo()} type={_playerController.MoveTo?.MovementTypeState}")); + } + return; + } + if (!localDispatch.AppliedInterpretedState) + return; + fullMotion = localDispatch.CurrentForwardCommand; + } + } + else + { + // One packet owner handles both PartArray-backed remotes and + // AP-77's animation-less body fallback. GameWindow performs + // only live-owner lookup and supplies the optional sink. + AcDream.App.Physics.RemoteInboundMotionDispatchResult dispatch = + DispatchRemoteInboundMotion( + update, + entity, + ae, + acceptedMotionRecord, + acceptedMovementAuthorityVersion, + acceptedMovementVelocityAuthorityVersion); + if (dispatch.Superseded + || dispatch.RoutedMoveTo + || !dispatch.AppliedInterpretedState) + return; + fullMotion = dispatch.CurrentForwardCommand; + } + + // Authoritative Dead motion invalidates a selected combat target. + // The controller clears shared selection, whose SelectionChanged + // consumer ports retail's post-clear AutoTarget behavior. + _combatTargetController?.OnMotionApplied( + update.Guid, ae.Sequencer.CurrentMotion); + if (!_liveEntities.IsCurrentMovementAuthority( + acceptedMotionRecord, + acceptedMovementAuthorityVersion) + || !_liveEntities.IsCurrentVelocityAuthority( + acceptedMotionRecord, + acceptedMovementVelocityAuthorityVersion)) + { + return; + } + + // CRITICAL: when we enter a locomotion cycle (Walk/Run/etc), + // stamp the remote observation timestamp to "now". Without this, + // the stop-detection loop in TickAnimations sees the previous + // observation timestamp (set by the last UpdatePosition, + // often >300ms ago during idle) and fires the stop signal + // IMMEDIATELY — flipping the sequencer straight back to Ready. + // The visible symptom was "remote char never animates; just + // stands there, teleporting position every UpdatePosition." + // Fresh timestamp gives the stop-timer a full 300ms window to + // observe genuine position stagnation before reverting. + uint newLo = fullMotion & 0xFFu; + bool enteringLocomotion = newLo == 0x05 || newLo == 0x06 + || newLo == 0x07 + || newLo == 0x0F || newLo == 0x10; + uint oldLo = priorMotion & 0xFFu; + bool wasLocomotion = oldLo == 0x05 || oldLo == 0x06 + || oldLo == 0x07 + || oldLo == 0x0F || oldLo == 0x10; + if (enteringLocomotion && !wasLocomotion && update.Guid != _playerServerGuid) + { + // Reset both stop signals so stop-detection starts a fresh + // window from this transition. Without this, the entity + // starts its run animation and is instantly interrupted. + var refreshedTime = System.DateTime.UtcNow; + if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev)) + _remoteMovementObservations[update.Guid] = (prev.Pos, refreshedTime); + if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr)) + dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds; + } + + return; + } + + // ── Legacy path (entities without a sequencer) ────────────────── + // Here we DO use GetIdleCycle because the legacy tick loop needs + // a concrete Animation + frame range. Only swap when the resolver + // returns a clearly-better cycle. + var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( + ae.Setup, _dats, _animLoader!, + motionTableIdOverride: null, + stanceOverride: stance, + commandOverride: command); + bool newCycleIsGood = newCycle is not null + && newCycle.Framerate != 0f + && newCycle.HighFrame >= newCycle.LowFrame + && newCycle.Animation.PartFrames.Count >= 1; + if (!newCycleIsGood) return; + + ae.Animation = newCycle!.Animation; + ae.LowFrame = Math.Max(0, newCycle.LowFrame); + ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1); + ae.Framerate = newCycle.Framerate; + ae.CurrFrame = ae.LowFrame; + } + + /// + /// Resolves one live remote owner and delegates retail's entire + /// unpack_movement body to the shared animation-optional packet + /// dispatcher. The render PartArray contributes only its optional sink. + /// + private AcDream.App.Physics.RemoteInboundMotionDispatchResult + DispatchRemoteInboundMotion( + AcDream.Core.Net.WorldSession.EntityMotionUpdate update, + AcDream.Core.World.WorldEntity entity, + LiveEntityAnimationState? ae, + LiveEntityRecord acceptedRecord, + ulong acceptedMovementAuthorityVersion, + ulong acceptedVelocityAuthorityVersion) + { + if (update.Guid == _playerServerGuid) + return default; + + bool IsCurrentOwner(RemoteMotion? expectedRemote = null) => + _liveEntities is { } live + && live.IsCurrentMovementAuthority( + acceptedRecord, + acceptedMovementAuthorityVersion) + && live.IsCurrentVelocityAuthority( + acceptedRecord, + acceptedVelocityAuthorityVersion) + && ReferenceEquals(acceptedRecord.WorldEntity, entity) + && (ae is null + ? acceptedRecord.AnimationRuntime is null + : ReferenceEquals(acceptedRecord.AnimationRuntime, ae)) + && (expectedRemote is null + || ReferenceEquals( + acceptedRecord.RemoteMotionRuntime, + expectedRemote)); + if (!IsCurrentOwner()) + return default; + + if (!_remoteDeadReckon.TryGetValue(update.Guid, out var remote)) + { + remote = CreateRemoteMotion(update.Guid); + remote.Body.Orientation = entity.Rotation; + remote.Body.Position = entity.Position; + _remoteDeadReckon[update.Guid] = remote; + } + if (!IsCurrentOwner(remote)) + return default; + + var sink = _motionRuntime.EnsureRemoteMotionBindings(remote, ae, update.Guid); + uint commandClass = ae?.Sequencer?.CurrentMotion & 0xFF000000u + ?? remote.Motion.InterpretedState.ForwardCommand & 0xFF000000u; + if (commandClass == 0u) + commandClass = 0x41000000u; + + AcDream.App.Physics.RemoteInboundMotionDispatchResult result = + _remoteInboundMotion.Apply( + update, + remote.Movement, + sink, + remote.Host, + remote.CellId, + commandClass, + () => IsCurrentOwner(remote)); + + if (result.Superseded || !IsCurrentOwner(remote)) + return result with { Superseded = true }; + + if (result.ForwardCommandChanged) + { + if (System.Environment.GetEnvironmentVariable( + "ACDREAM_REMOTE_VEL_DIAG") == "1") + { + System.Console.WriteLine( + $"[FWD_WIRE] guid={update.Guid:X8} " + + $"oldCmd=0x{result.PreviousForwardCommand:X8} " + + $"newCmd=0x{result.CurrentForwardCommand:X8} " + + $"newLow=0x{result.CurrentForwardCommand & 0xFFu:X2} " + + $"speed={update.MotionState.ForwardSpeed ?? 1f:F3}"); + } + remote.PrevServerPosTime = 0.0; + } + + if (result.AppliedInterpretedState && ae is null) + { + _combatTargetController?.OnMotionApplied( + update.Guid, + result.CurrentForwardCommand); + if (!IsCurrentOwner(remote)) + return result with { Superseded = true }; + } + return result; + } + + /// + /// Phase 6.7: the server says an entity moved. Translate its new + /// landblock-local position into acdream world space (same math as + /// CreateObject hydration) and update the entity's Position/Rotation + /// in place so the next Draw picks up the new transform. + /// + /// Phase B.3 extension: if the player controller is in PortalSpace and + /// this update is for our own character, detect a large position change + /// (different landblock or > 100 units distance). If detected, recenter + /// the streaming controller, resolve the new position through physics, + /// snap the player entity + controller, and return to InWorld. Also sends + /// LoginComplete so the server knows the client has loaded the destination. + /// + /// + /// Reports whether the exact remote component currently belongs to the + /// visible ordinary-object workset that consumes interpolation targets. + /// + private bool WillAdvanceRemoteMotion(uint serverGuid, RemoteMotion remote) + { + return _liveEntities is { } runtime + && runtime.TryGetRecord(serverGuid, out LiveEntityRecord record) + && ReferenceEquals(record.RemoteMotionRuntime, remote) + && (record.FinalPhysicsState + & AcDream.Core.Physics.PhysicsStateFlags.Static) == 0 + && runtime.GetRootObjectClockDisposition(serverGuid) + is AcDream.Core.Physics.RetailObjectClockDisposition.Advance + && runtime.IsCurrentSpatialRemoteMotion(record, remote); + } + + /// + /// Creates the optional MovementManager companion for one live object. + /// A retained projectile or static-animation owner contributes the + /// already-owned CPhysicsObj body; an ordinary remote gets local storage. + /// + private RemoteMotion CreateRemoteMotion(uint serverGuid) + { + LiveEntityRecord? record = null; + if (_liveEntities is { } liveEntities + && liveEntities.TryGetRecord(serverGuid, out var currentRecord)) + { + record = currentRecord; + } + + RemoteMotion remote; + if (record?.PhysicsBody is { } canonicalBody) + { + // Retail has one CPhysicsObj. A projectile or Physics-Static + // animation workset can create it before the MovementManager; + // adopt its current frame/vectors without replaying CreateObject. + remote = new RemoteMotion(canonicalBody); + } + else + { + remote = new RemoteMotion(); + if (record is not null) + AcDream.App.Physics.RemotePhysicsBodyInitializer.Initialize( + remote.Body, + record); + } + + RemoteMotion incarnation = remote; + remote.Movement.ActivatePhysicsObject = () => + _liveEntities?.TryActivateOrdinaryObject(serverGuid, incarnation); + return remote; + } + + /// + /// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate from remote jumps. + /// The payload seeds the world-space launch velocity and angular velocity. + /// + public void OnVector(AcDream.Core.Net.Messages.VectorUpdate.Parsed update) + { + bool payloadIsValid = _projectileController?.CanAcceptVectorPayload( + update.Guid, + update.Velocity, + update.Omega) != false; + if (!_authorityGate.TryAcceptVector( + update, + payloadIsValid, + out AcceptedVectorNetworkUpdate accepted)) + { + return; + } + LiveEntityRecord acceptedVectorRecord = accepted.Record; + ulong acceptedVectorAuthorityVersion = + accepted.VectorAuthorityVersion; + ulong acceptedVectorVelocityAuthorityVersion = + accepted.VelocityAuthorityVersion; + + LiveEntityVectorRouter.Route( + () => _projectileController?.ApplyAuthoritativeVector( + acceptedVectorRecord, + acceptedVectorAuthorityVersion, + acceptedVectorVelocityAuthorityVersion, + update.Velocity, + update.Omega, + _physicsScriptGameTime) == true, + () => + { + // A Physics-Static animation owner can own the canonical + // CPhysicsObj before any MovementManager exists. F74E writes + // directly to that body and must not manufacture a remote. + if (update.Guid == _playerServerGuid + || acceptedVectorRecord.RemoteMotionRuntime is not null + || acceptedVectorRecord.PhysicsBody is not { } canonicalBody) + { + return false; + } + _liveEntities.TryCommitAuthoritativeVector( + acceptedVectorRecord, + canonicalBody, + update.Velocity, + update.Omega, + _physicsScriptGameTime); + return true; + }, + () => ApplyOrdinaryVector( + update, + acceptedVectorRecord, + acceptedVectorAuthorityVersion, + acceptedVectorVelocityAuthorityVersion)); + } + + private void ApplyOrdinaryVector( + AcDream.Core.Net.Messages.VectorUpdate.Parsed update, + LiveEntityRecord acceptedVectorRecord, + ulong acceptedVectorAuthorityVersion, + ulong acceptedVectorVelocityAuthorityVersion) + { + if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return; + + if (update.Guid == _playerServerGuid) return; // local jump uses our own physics + if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rm)) return; + LiveEntityRecord remoteRecord = acceptedVectorRecord; + + // World-space velocity. Apply directly to the body — the per-tick + // remote update will integrate Position += Velocity × dt + 0.5 × Accel × dt². + // L.3.1 Task 6: apply Omega too. LiveEntityRuntime commits both + // writes to the one canonical CPhysicsObj and wakes its retained + // update_time clock on the same non-Static edge. + if (!_liveEntities.TryCommitAuthoritativeVector( + remoteRecord, + rm.Body, + update.Velocity, + update.Omega, + _physicsScriptGameTime)) + { + return; + } + + // Mark airborne when the launch has meaningful +Z. Threshold + // 0.5 m/s rejects noise / horizontal-only updates (server might + // also use VectorUpdate for non-jump events). The per-tick + // remote update reads .Airborne to skip the ground-clamp branch + // and apply gravity instead. + if (update.Velocity.Z > 0.5f) + { + rm.Airborne = true; + // Clear ground-contact bits + enable gravity so calc_acceleration + // returns (0, 0, -9.8) instead of zero. UpdatePhysicsInternal then + // produces the parabolic arc. + rm.Body.TransientState &= ~(AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable); + rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity; + + // R3-W4 (J19 — K-fix10/K-fix18 DELETED): the retail mechanism. + // The remote's ground departure fires LeaveGround (0x00528b00): + // strips pending transition links (the RemoveLinkAnimations + // seam) + re-applies movement through DefaultSink, whose + // contact-gated funnel dispatch engages Falling — no forced + // SetCycle, no skip flag. The wire velocity/omega are re-applied + // AFTER so they stay authoritative over LeaveGround's + // state-derived velocity write (adaptation note: retail's + // equivalence comes from the per-tick transition-sweep order — + // R6 scope). + if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent) + && _animatedEntities.TryGetValue(ent.Id, out var ae) + && ae.Sequencer is not null) + { + _motionRuntime.EnsureRemoteMotionBindings(rm, ae, update.Guid); + rm.Motion.LeaveGround(); + if (!_liveEntities.IsCurrentVectorAuthority( + remoteRecord, + acceptedVectorAuthorityVersion) + || !_liveEntities.IsCurrentVelocityAuthority( + remoteRecord, + acceptedVectorVelocityAuthorityVersion) + || !_liveEntities.TryCommitAuthoritativeVector( + remoteRecord, + rm.Body, + update.Velocity, + update.Omega, + _physicsScriptGameTime)) + { + return; + } + } + } + + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + { + Console.WriteLine( + $"VU guid=0x{update.Guid:X8} vel=({update.Velocity.X:F2},{update.Velocity.Y:F2},{update.Velocity.Z:F2}) airborne={rm.Airborne}"); + } + } + + /// + /// L.2g slice 1: inbound SetState (0xF74B) handler. Propagates the + /// new PhysicsState bits into ShadowObjectRegistry so the + /// existing check honors + /// the flip on the next resolver tick. Chiefly doors: + /// server flips ETHEREAL_PS = 0x4 on Use, the door's + /// cylinder collision stops blocking the threshold. + /// + public void OnState(AcDream.Core.Net.Messages.SetState.Parsed parsed) + { + if (!_authorityGate.TryAcceptState( + parsed, + out AcceptedStateNetworkUpdate accepted)) + return; + LiveEntityRecord record = accepted.Record; + ulong acceptedStateAuthorityVersion = accepted.StateAuthorityVersion; + + // Retail set_state order: Lighting, NoDraw, then Hidden. The live + // runtime already committed the raw/final bits and draw visibility; + // apply the ordered owners before updating motion/collision consumers. + _liveEntityLights?.OnStateChanged(parsed.Guid); + _liveEntityPresentation?.OnStateAccepted(parsed.Guid); + + if (!_liveEntities.IsCurrentStateAuthority( + record, + acceptedStateAuthorityVersion)) + { + return; + } + + _projectileController?.ApplyAuthoritativeState( + record, + acceptedStateAuthorityVersion, + record.FinalPhysicsState, + _physicsScriptGameTime, + _origin.CenterX, + _origin.CenterY); + if (!_liveEntities.IsCurrentStateAuthority( + record, + acceptedStateAuthorityVersion)) + { + return; + } + if (parsed.Guid == _playerServerGuid) + _playerController?.ApplyPhysicsState(record.FinalPhysicsState); + + if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return; + + // L.2g slice 1c (2026-05-13): the server addresses entities by + // ServerGuid (parsed.Guid, e.g. 0x7A9B4015), but + // ShadowObjectRegistry's cell index is keyed by local entity.Id + // (e.g. 0x000F4245). Translate via _entitiesByServerGuid before + // mutating the registry — otherwise the lookup misses and the + // state flip silently no-ops, leaving doors blocked even though + // ACE flipped the ETHEREAL bit. + uint registryKey = entity.Id; + + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) + Console.WriteLine(System.FormattableString.Invariant( + $"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} raw=0x{parsed.PhysicsState:X8} final=0x{(uint)record.FinalPhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}")); + } + + public void OnPosition(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) + { + bool payloadIsValid = _projectileController?.CanAcceptPositionPayload( + update.Guid, + update.Position, + update.Velocity) != false; + if (!_authorityGate.TryAcceptPosition( + update, + _playerServerGuid, + update.Guid == _playerServerGuid && _playerController is not null + ? _playerController.BodyOrientation + : null, + update.Guid == _playerServerGuid && _playerController is not null + ? _playerController.BodyVelocity + : null, + payloadIsValid, + out AcceptedPositionNetworkUpdate accepted)) + { + return; + } + var timestampDisposition = accepted.TimestampDisposition; + var acceptedSpawn = accepted.Spawn; + var timestamps = accepted.Timestamps; + LiveEntityRecord acceptedPositionRecord = accepted.Record; + ulong acceptedPositionAuthorityVersion = + accepted.PositionAuthorityVersion; + ulong acceptedPositionVelocityAuthorityVersion = + accepted.VelocityAuthorityVersion; + bool IsCurrentPositionOwner( + AcDream.Core.World.WorldEntity? expectedEntity = null) => + _liveEntities.IsCurrentPositionAuthority( + acceptedPositionRecord, + acceptedPositionAuthorityVersion) + && (expectedEntity is null + || ReferenceEquals( + acceptedPositionRecord.WorldEntity, + expectedEntity)); + if (!IsCurrentPositionOwner()) + return; + + // A PlayerDescription/CreateObject may establish the live record + // without either Position or enough render data. Bind streaming + // readiness directly to this first accepted canonical Position before + // translating it through the current world origin; projection recovery + // below is a separate concern and may never be needed for UI-only state. + if (_liveEntityHydration?.EnsureWorldOrigin( + acceptedPositionRecord, + acceptedPositionAuthorityVersion, + acceptedSpawn) != true + || !IsCurrentPositionOwner()) + return; + + var p = update.Position; + int lbX = (int)((p.LandblockId >> 24) & 0xFFu); + int lbY = (int)((p.LandblockId >> 16) & 0xFFu); + var origin = new System.Numerics.Vector3( + (lbX - _origin.CenterX) * 192f, + (lbY - _origin.CenterY) * 192f, + 0f); + var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin; + + bool forceLocal = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition + && update.Guid == _playerServerGuid + && _playerController is not null; + if (!LocalForcePositionTransaction.Apply( + forceLocal, + () => IsCurrentPositionOwner(), + () => _playerController!.BlipPosition( + worldPos, + p.LandblockId, + new System.Numerics.Vector3( + p.PositionX, + p.PositionY, + p.PositionZ)), + () => _localPlayerOutbound.SendImmediatePosition( + _session(), + _playerController))) + return; + + if (!_entitiesByServerGuid.ContainsKey(update.Guid)) + { + if (!IsCurrentPositionOwner()) + return; + AcDream.App.Rendering.ChildUnparentDisposition unparented = + _equippedChildRenderer?.OnChildBecameUnparented( + update.Guid, + () => + { + if (!IsCurrentPositionOwner()) + return; + _liveEntityHydration!.RecoverProjection( + acceptedPositionRecord, + acceptedPositionAuthorityVersion, + acceptedSpawn); + }) + ?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached; + if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.Superseded + or AcDream.App.Rendering.ChildUnparentDisposition.Pending) + return; + if (!IsCurrentPositionOwner()) + return; + if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.NotAttached) + { + _liveEntityHydration!.RecoverProjection( + acceptedPositionRecord, + acceptedPositionAuthorityVersion, + acceptedSpawn); + if (!IsCurrentPositionOwner()) + return; + } + } + + if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; + if (!IsCurrentPositionOwner(entity)) + return; + _entityEffects?.MarkLiveOwnerPoseDirty(update.Guid); + if (!IsCurrentPositionOwner(entity)) + return; + + // Phase A.1 / #135: track the PLAYER's last server-known landblock so the + // streaming controller can follow the player in the fly-camera / pre-player-mode + // (login hold) views. Filtered to our OWN character guid — resolving the original + // Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock + // must NOT move the streaming observer: during a dungeon-login hold (player not + // yet placed, so _playerController is null and the PortalSpace observer branch + // can't apply) that would drift the observer off the pre-collapsed dungeon + // landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window + // the pre-collapse just suppressed. _playerServerGuid is set from CharacterList + // (~line 1984) before world entry, so it is valid by the time updates arrive. + if (update.Guid == _playerServerGuid) + _authorityGate.ObserveAcceptedLocalPosition(update.Position.LandblockId); + + // B.6 slice 1 (2026-05-14): trace inbound UpdatePosition cadence for + // the local player. Combined with [autowalk-mt] this answers + // whether ACE's broadcast frequency during a server-initiated + // auto-walk is dense enough to drive smooth visible motion (the + // Option C viability check from the design spec). Gated on + // ACDREAM_PROBE_AUTOWALK=1; skips remote entities. + if (update.Guid == _playerServerGuid + && AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + string velStr = update.Velocity is { } v + ? $"vel=({v.X:F2},{v.Y:F2},{v.Z:F2})" + : "vel=null"; + Console.WriteLine(System.FormattableString.Invariant( + $"[autowalk-up] cell=0x{p.LandblockId:X8} pos=({p.PositionX:F2},{p.PositionY:F2},{p.PositionZ:F2}) world=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2}) {velStr} grounded={update.IsGrounded}")); + } + var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition + ? entity.Rotation + : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); + _dumpMovementTruthServerEcho(update, worldPos); + + bool remoteHardTeleport = update.Guid != _playerServerGuid + && timestamps.TeleportHookRequired; + bool remotePlacementRequired = update.Guid != _playerServerGuid + && (remoteHardTeleport + || _remoteTeleportController?.HasPending(update.Guid) == true); + if (remoteHardTeleport) + { + if (!RunRemoteTeleportHook( + update.Guid, + entity.Id, + () => IsCurrentPositionOwner(entity))) + { + return; + } + } + + // Missiles reconcile the same predicted PhysicsBody in place. The + // timestamp gate above already rejected stale corrections; returning + // here prevents the generic remote locomotion path from allocating a + // second body or interpolation owner for the projectile. + if (_projectileController?.ApplyAuthoritativePosition( + acceptedPositionRecord, + acceptedPositionAuthorityVersion, + acceptedPositionVelocityAuthorityVersion, + worldPos, + new System.Numerics.Vector3( + p.PositionX, + p.PositionY, + p.PositionZ), + rot, + acceptedSpawn.Physics?.Velocity + ?? System.Numerics.Vector3.Zero, + p.LandblockId, + _physicsScriptGameTime, + _origin.CenterX, + _origin.CenterY) == true) + return; + + if (!_liveEntities.TryGetRecord( + update.Guid, + out LiveEntityRecord positionRecord) + || !ReferenceEquals(positionRecord, acceptedPositionRecord) + || !ReferenceEquals(positionRecord.WorldEntity, entity) + || !_liveEntities.IsCurrentPositionAuthority( + positionRecord, + acceptedPositionAuthorityVersion)) + { + return; + } + + // Capture the pre-update render position for the soft-snap residual + // calculation below. Assign entity.Position to the server truth up + // front; if we then compute a snap residual, we restore the rendered + // position by adding the residual back (so the visual doesn't jerk + // for one frame before the residual decay kicks in on the next tick). + System.Numerics.Vector3 preSnapPos = entity.Position; + entity.SetPosition(worldPos); + entity.ParentCellId = p.LandblockId; + entity.Rotation = rot; + if (!_liveEntities!.RebucketLiveEntity(update.Guid, p.LandblockId) + || !_liveEntities.TryGetRecord( + update.Guid, + out LiveEntityRecord afterRebucket) + || !ReferenceEquals(afterRebucket, positionRecord) + || !ReferenceEquals(afterRebucket.WorldEntity, entity) + || !_liveEntities.IsCurrentPositionAuthority( + afterRebucket, + acceptedPositionAuthorityVersion)) + { + // A projection callback superseded or deleted this incarnation. + // Never let an older UpdatePosition seed the replacement's + // placement, interpolation, or collision state. + return; + } + + if (remotePlacementRequired) + { + _remoteTeleportController!.BeginPlacement( + update.Guid, + acceptedSpawn.InstanceSequence); + if (!IsCurrentPositionOwner(entity)) + return; + } + + // Commit B 2026-04-29 — keep the shadow registry in sync with + // server-authoritative position so the player's collision broadphase + // tests against the up-to-date target body. Skip the local player + // (its body is the simulator, not a target). Retail does the + // equivalent via SetPosition → change_cell → AddShadowObject + // (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862). + // #184 Slice 2b: the former players-only RAW-pos shadow sync is RETIRED. + // It was a Slice-1 stopgap while grounded player remotes (old Path A) skipped + // the sweep and tracked the server position closely. Now that Slice 2b runs + // the SAME per-tick sweep + shadow-follows-resolved for players, writing the + // raw (overlapping) server pos here would re-snap a packed player's shadow + // into overlap once per UP and fight the in-tick de-overlap (research + // finding 9). Player shadows now follow the RESOLVED body — via the DR-tick + // loop (SyncRemoteShadowToBody, pose/cell-gated) and the player UP-branch tail + // below (first-UP / no-Sequencer case), exactly like NPCs. Local-player + // broadphase still tests an up-to-date remote shadow; it is just the resolved + // body now, not the raw wire pos. + +// Track remote-entity motion for stop detection. Only record the + // timestamp when position moved MEANINGFULLY (> 0.05m). Updates + // that report the same position keep the old Time, so the + // TickAnimations check can see when motion last changed. + // + // Also populate the dead-reckon state so TickAnimations can + // integrate velocity between server updates and avoid teleport jitter. + // Observed-velocity is computed from the position delta across + // consecutive updates — this is the fallback when the motion table's + // MotionData.Velocity is zero (NPCs without HasVelocity). + if (update.Guid != _playerServerGuid) + { + var now = System.DateTime.UtcNow; + if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev)) + { + float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos); + if (moveDist > 0.05f) + _remoteMovementObservations[update.Guid] = (worldPos, now); + // else: leave old entry so "Time" = last real movement time + } + else + { + _remoteMovementObservations[update.Guid] = (worldPos, now); + } + + // Retail-faithful hard-snap on UpdatePosition. + // Decompile: FUN_00559030 @ chunk_00550000.c:8232 writes + // pos/rot directly into PhysicsObj+0x80..0xBC with no blending. + // Between UpdatePositions, per-tick velocity integration keeps + // the rendered position close to server truth so each snap is + // small. When HasVelocity is set, we also seed PhysicsBody + // velocity (matches retail's set_velocity call in the same + // dispatcher). + if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rmState)) + { + rmState = CreateRemoteMotion(update.Guid); + _remoteDeadReckon[update.Guid] = rmState; + // Hard-snap orientation on first spawn so the per-tick + // slerp doesn't visibly rotate from Identity to truth. + rmState.Body.Orientation = rot; + // #184 Slice 2b: PLACE the body at the server position on creation, + // mirroring the UM handler's seed (:5176 `Body.Position = + // entity.Position`). A UP-first RemoteMotion (created here before any + // UM) was left at the default (0,0,0). Path A never swept, so that + // stale origin was harmless — it caught up gradually. Now that Slice + // 2b runs the sweep for grounded PLAYERS too, an unplaced body would + // sweep from (0,0,0) in the server cell that does not contain it → + // garbage resolved pos → the digest's INVISIBLE/misplaced-body bug. + // Seeding here is the root-cause fix (the UP creation path should + // seed exactly like the UM path); worldPos == entity.Position (the + // unconditional snap at the top of this handler already ran). + rmState.Body.Position = worldPos; + } + + // PositionPack::UnPack initializes an absent velocity to zero; + // MoveOrTeleport installs that exact vector with set_velocity. + // The canonical seam wakes the retained ObjectClock and body in + // one operation. Position-delta velocity below remains animation + // diagnostics and is never substituted into physics. + if (!_liveEntities.IsCurrentPositionAuthority( + positionRecord, + acceptedPositionAuthorityVersion)) + { + return; + } + if (_liveEntities.IsCurrentVelocityAuthority( + positionRecord, + acceptedPositionVelocityAuthorityVersion) + && !_liveEntities.TryCommitAuthoritativeVelocity( + positionRecord, + rmState.Body, + acceptedSpawn.Physics?.Velocity + ?? System.Numerics.Vector3.Zero, + _physicsScriptGameTime)) + { + return; + } + + // Retail CPhysicsObj::MoveOrTeleport Branch A (0x00516330): a + // fresh TELEPORT_TS, or the first placement of a cell-less body, + // runs teleport_hook and SetPosition(0x1012) BEFORE the contact + // test. In particular, an airborne UP cannot veto or undo this + // authoritative destination. Do not pre-clear velocity or invent + // grounded flags here: retail SetPosition derives contact from its + // transition, while MoveOrTeleport does not consume arg5/arg6. + if (remotePlacementRequired) + { + double teleportTime = + (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + bool projectionVisible = _liveEntities.TryGetRecord( + update.Guid, + out LiveEntityRecord teleportRecord) + && teleportRecord.IsSpatiallyVisible; + var placement = _remoteTeleportController!.TryApply( + positionRecord, + acceptedPositionAuthorityVersion, + acceptedPositionVelocityAuthorityVersion, + rmState, + entity, + worldPos, + p.LandblockId, + new System.Numerics.Vector3( + p.PositionX, + p.PositionY, + p.PositionZ), + rot, + teleportTime, + projectionVisible, + acceptedSpawn.InstanceSequence, + acceptedSpawn.PositionSequence); + if (placement.Superseded + || !IsCurrentPositionOwner(entity)) + { + return; + } + if (!placement.Applied) + { + entity.SetPosition(rmState.Body.Position); + entity.ParentCellId = rmState.CellId; + entity.Rotation = rmState.Body.Orientation; + if (rmState.CellId != 0) + _liveEntities.RebucketLiveEntity(update.Guid, rmState.CellId); + return; + } + + if (!IsCurrentPositionOwner(entity)) + return; + entity.SetPosition(rmState.Body.Position); + entity.Rotation = rmState.Body.Orientation; + return; + } + + // L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for + // player remotes. Mirrors CPhysicsObj::MoveOrTeleport + // (acclient @ 0x00516330) — airborne no-op, far-snap, near + // InterpolateTo. Gated on IsPlayerGuid so NPCs continue through + // the legacy synth-velocity branch below; their motion comes + // from ServerVelocity / ServerMoveTo which the legacy path + // already handles correctly. + // + if (IsPlayerGuid(update.Guid)) + { + // InterpolationManager retains the complete target Position. + // A near correction replaces both origin and orientation via + // Position::subtract2; only placement/far branches snap here. + // Adopt server's cell ID on every UP (airborne or grounded). + // Required by the legacy airborne path's per-tick + // ResolveWithTransition gate (rm.CellId != 0); without this + // an airborne player remote falls through the floor because + // the sphere sweep is skipped. Note: enabling the sweep also + // exposes a pre-existing depenetration bug — see #42. + rmState.CellId = p.LandblockId; + + // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous + // server-pos snapshot forward AND print the per-UP comparison + // between the max literal CSequence root-motion speed observed + // since the last UP and the actual server broadcast pace. Both are sampled + // over the same window so the ratio reflects real overshoot. + { + double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && rmState.LastServerPosTime > 0.0) + { + double dtServer = nowSecDiag - rmState.LastServerPosTime; + if (dtServer > 0.001) + { + var serverDelta = worldPos - rmState.LastServerPos; + float serverSpeed = (float)(serverDelta.Length() / dtServer); + float rootMotionSpeed = rmState.MaxRootMotionSpeedSinceLastUP; + if (serverSpeed > 0.1f || rootMotionSpeed > 0.1f) + { + System.Console.WriteLine( + $"[VEL_DIAG] guid={update.Guid:X8} maxRootMotionSpeed={rootMotionSpeed:F3} m/s " + + $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s " + + $"ratio={(serverSpeed > 1e-3f ? rootMotionSpeed / serverSpeed : 0f):F3}"); + } + } + } + rmState.MaxRootMotionSpeedSinceLastUP = 0f; + rmState.PrevServerPos = rmState.LastServerPos; + rmState.PrevServerPosTime = rmState.LastServerPosTime; + rmState.LastServerPos = worldPos; + rmState.LastServerPosTime = nowSecDiag; + } + + // ── AIRBORNE NO-OP ──────────────────────────────────────────── + // Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330): + // when has_contact==0, return false (don't touch body, don't queue). + // body.Velocity (set once by OnLiveVectorUpdated at jump start) keeps + // integrating gravity via per-frame UpdatePhysicsInternal. Server is + // authoritative for the arc; we don't predict it locally. + if (!update.IsGrounded) + { + // Undo the unconditional entity hard-snap at the top of the + // function (entity.SetPosition(worldPos)): the body is mid-arc + // and TickAnimations will write entity = body next frame + // anyway. Setting entity = body now prevents a 1-frame + // teleport-to-server-then-yank-back rubber-band. + entity.SetPosition(rmState.Body.Position); + return; + } + + // ── LANDING TRANSITION ──────────────────────────────────────── + // First IsGrounded=true UP after rmState.Airborne signals landed. + // Clear airborne flags, hard-snap to authoritative landing position, + // clear interpolation queue (any pre-jump waypoints are stale). + if (rmState.Airborne) + { + rmState.Airborne = false; + rmState.Body.Velocity = System.Numerics.Vector3.Zero; + rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rmState.Interp.Clear(); + rmState.Body.Position = worldPos; + rmState.Body.Orientation = rot; + + // #161: retail landing = MovementManager::HitGround + // (minterp → moveto, 0x00524300 — the R5-V5 facade + // relay) with the Gravity state bit STILL SET + // (CMotionInterp::HitGround gates on state&0x400). The + // re-apply dispatches the PRESERVED pre-fall forward + // command → landing link → cycle. This replaces the + // forced SetCycle, which read the then-clobbered + // ForwardCommand (Falling) and re-set the pose it meant + // to clear. See the twin block in TickAnimations + // (VU.land). + if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand) + && aeForLand.Sequencer is not null) + { + _motionRuntime.EnsureRemoteMotionBindings(rmState, aeForLand, update.Guid); + } + ulong landingStateAuthorityVersion = + positionRecord.StateAuthorityVersion; + rmState.Movement.HitGround(); + if (!IsCurrentPositionOwner(entity) + || !ReferenceEquals( + positionRecord.RemoteMotionRuntime, + rmState)) + { + return; + } + // DR bookkeeping only (partner of the jump-start + // `State |= Gravity`). + if (_liveEntities.IsCurrentStateAuthority( + positionRecord, + landingStateAuthorityVersion)) + { + rmState.Body.State &= + ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + } + return; + } + + // ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ──────────── + const float MaxPhysicsDistance = 96f; // retail player_distance far-snap + const float BodySnapThreshold = 4f; // large correction / teleport / unplaced -> snap + var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero; + float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos); + // #184 Slice 2b: the player UP routing gains the SAME placement-snap + // backstop the NPC routing got in Slice 1 (AP-87). Now that grounded + // PLAYER remotes run the sweep, an unplaced / stale-cell body — a + // UM-first RemoteMotion seeded to the spawn pos (:5176) then a first UP + // in a DIFFERENT cell, which the UP-creation seed (:5720) does NOT cover + // — would enqueue and the per-tick sweep would run from a cell that does + // not contain the body -> garbage resolved pos -> the digest's + // INVISIBLE/misplaced player. The 4 m bodyToTarget guard is the + // LOAD-BEARING backstop (AP-87; firstUp via LastServerPosTime is a poor + // signal for players — it is already set by the VEL_DIAG block above); + // !willBeDrTicked snaps a no-Sequencer player whose queue nothing would + // consume; dist>96 is retail's far-snap. Placed + near corrections still + // enqueue for the smooth catch-up. + float bodyToTarget = System.Numerics.Vector3.Distance( + rmState.Body.Position, worldPos); + bool willBeDrTicked = WillAdvanceRemoteMotion( + update.Guid, + rmState); + + if (dist > MaxPhysicsDistance || !willBeDrTicked + || bodyToTarget > BodySnapThreshold) + { + // Beyond view bubble / large correction / unplaced body: + // SetPositionSimple slide-snap. Clear queue. + rmState.Interp.Clear(); + rmState.Body.Position = worldPos; + rmState.Body.Orientation = rot; + } + else + { + // Within view bubble, placed + near: enqueue waypoint for + // adjust_offset to walk to. The per-frame TickAnimations player- + // remote path drives the actual body advancement via + // InterpolationManager.AdjustOffset. Pass body's current position so + // the InterpolationManager can detect a far-distance enqueue (>100 m + // from body) and pre-arm an immediate blip. + System.Numerics.Quaternion? immediateOrientation = + rmState.Interp.Enqueue( + worldPos, + rot, + isMovingTo: rmState.Movement.IsMovingTo(), + currentBodyPosition: rmState.Body.Position, + currentBodyOrientation: rmState.Body.Orientation); + if (immediateOrientation is { } closeOrientation) + rmState.Body.Orientation = closeOrientation; + } + // Track the UP-derived synth velocity for diagnostics + // ([VEL_DIAG] pace comparison). L.2g S5 (2026-07-02): the + // #39-era cycle-refinement call that used to live here is + // DELETED — retail never adapts a remote's animation from + // observed pace (deviation map DEV-2; premise refuted at + // decomp + ACE source + the S0 live capture, which shows + // explicit 0x0005↔0x0007 UMs on every Shift toggle). + // Player-remote cycles are UM-driven only. + if (rmState.PrevServerPosTime > 0.0) + { + double nowSecVel = rmState.LastServerPosTime; + double dtPos = nowSecVel - rmState.PrevServerPosTime; + if (dtPos > 0.001) + { + var synthVel = (worldPos - rmState.PrevServerPos) / (float)dtPos; + rmState.ServerVelocity = synthVel; + rmState.HasServerVelocity = true; + } + } + + // Sync the visible entity to the body — overrides the unconditional + // entity.SetPosition(worldPos) snap at the top of this function. + // For the far-snap branch this is a no-op (body == worldPos); for + // the near-enqueue branch this prevents a 1-frame teleport-then- + // yank-back rubber-band as TickAnimations chases worldPos via the + // queue. + // + // #184 Slice 2b: sync the player-remote shadow to the RESOLVED/placed + // body (mirrors the NPC UP-branch tail). Now that grounded players run + // the DR-tick sweep + shadow-follows-resolved, the retired raw-pos sync + // (top of this handler) would have re-snapped the shadow into overlap + // each UP; this keeps collision == render for the first UP (before any + // DR tick) and for no-Sequencer players. rmState.CellId is the server + // cell adopted above (:5735). The per-tick loop keeps it current + // between UPs (pose-gated). Commit the complete root before the + // publication gate, matching SetPositionInternal ordering. + entity.SetPosition(rmState.Body.Position); + entity.ParentCellId = rmState.CellId; + entity.Rotation = rmState.Body.Orientation; + AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote( + _liveEntities, + positionRecord, + entity, + rmState, + acceptedPositionAuthorityVersion, + () => _remotePhysicsUpdater.SyncRemoteShadowToBody( + entity.Id, + rmState, + _origin.CenterX, + _origin.CenterY)); + return; + } + + double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds; + System.Numerics.Vector3? serverVelocity = update.Velocity; + if (serverVelocity is null + && !IsPlayerGuid(update.Guid) + && rmState.LastServerPosTime > 0.0) + { + double elapsed = nowSec - rmState.LastServerPosTime; + if (elapsed > 0.001) + serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed; + } + if (serverVelocity is { } authoritativeVelocity) + { + rmState.ServerVelocity = authoritativeVelocity; + rmState.HasServerVelocity = true; + } + else if (!IsPlayerGuid(update.Guid)) + { + rmState.ServerVelocity = System.Numerics.Vector3.Zero; + rmState.HasServerVelocity = false; + } + // R5-V3 #171 residual (2026-07-04 gate: "flashing/flapping", + // stale facing, pushed-into-player): while an entity is STUCK, + // the sticky steer owns its frame — retail's UP corrections flow + // through the InterpolationManager into the SAME adjust_offset + // chain where StickyManager OVERWRITES them while armed + // (PositionManager::adjust_offset 0x00555190 order; sticky + // assigns m_fOrigin 0x00555430), so a server correction can + // never fight an armed stick frame-by-frame. This legacy NPC + // path hard-snaps OUTSIDE that chain, producing a visible + // snap-out/steer-back oscillation at UP cadence (position) and + // a stale-facing stomp (orientation). Faithful translation to + // the snap architecture: suppress the position/orientation/ + // velocity snaps while stuck. LastServerPos/Time bookkeeping + // still records below — server truth reasserts on the first UP + // after unstick (bounded by the 1 s sticky lease). Register + // row with TS-41/TS-44. + bool snapSuppressedByStick = + (rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u; + if (snapSuppressedByStick + && AcDream.Core.Physics.PhysicsDiagnostics.ProbeStickyEnabled) + { + float snapDist = System.Numerics.Vector3.Distance( + worldPos, rmState.Body.Position); + Console.WriteLine(FormattableString.Invariant( + $"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})")); + } + if (!snapSuppressedByStick) + { + // #184 (2026-07-07): retail CPhysicsObj::MoveOrTeleport (0x00516330) + // for grounded NPC remotes. The body is PLACED (hard-snapped) whenever + // it is not already tracking NEAR the server position — the first UP, + // a large correction / teleport, an out-of-view creature (>96 m from + // the local player), or an entity the DR loop won't tick — otherwise the + // server point is a GENTLE dead-reckoning TARGET the per-tick interp + // catch-up walks to, and the KEPT sweep de-overlaps that movement. + // + // The placement-snap is LOAD-BEARING: the earlier attempt (reverted) + // enqueued EVERYTHING, so an unplaced body (origin, first UP) blipped + // over a huge distance into the sweep -> a resolve started in a cell that + // did not contain the body -> garbage resolved pos -> INVISIBLE monster + // while its shadow (synced to server truth) stayed put -> player stuck on + // nothing. Airborne keeps the authoritative hard-snap (arc integrates + // locally, K-fix15). Physics digest 2026-07-07 banner. + if (rmState.Airborne) + { + rmState.Body.Position = worldPos; + rmState.Body.Orientation = rot; + } + else + { + const float MaxPhysicsDistanceNpc = 96f; // retail player_distance far-snap + const float BodySnapThresholdNpc = 4f; // large correction / teleport -> snap + var localPlayerPosNpc = _playerController?.Position + ?? System.Numerics.Vector3.Zero; + float distNpc = System.Numerics.Vector3.Distance(worldPos, localPlayerPosNpc); + float bodyToTargetNpc = System.Numerics.Vector3.Distance( + rmState.Body.Position, worldPos); + // The 4 m bodyToTargetNpc guard is the LOAD-BEARING placement backstop: + // it snaps any body not already near the target (an unplaced first-UP + // body sits at the origin / spawn pos, far from worldPos). firstUpNpc is + // a belt-and-suspenders hint only — it is NOT a reliable "never placed" + // signal because a UM that enters a locomotion cycle can stamp + // LastServerPosTime before the first UP (~:5340). Don't tune the 4 m + // threshold down without re-checking the unplaced-body case. + bool firstUpNpc = rmState.LastServerPosTime <= 0.0; + // Enqueue only if the canonical ordinary-object workset + // will consume the queue. Animation is optional in retail; + // LiveEntityAnimationScheduler advances a spatial + // RemoteMotion even when no LiveEntityAnimationState exists. + bool willBeDrTickedNpc = WillAdvanceRemoteMotion( + update.Guid, + rmState); + + if (firstUpNpc || !willBeDrTickedNpc + || distNpc > MaxPhysicsDistanceNpc + || bodyToTargetNpc > BodySnapThresholdNpc) + { + // Placement / far / large-correction: SNAP + clear queue. + rmState.Interp.Clear(); + rmState.Body.Position = worldPos; + rmState.Body.Orientation = rot; + } + else + { + // Near DR correction: enqueue the waypoint for the per-tick + // catch-up (Path B consumes it via ComputeOffset). + System.Numerics.Quaternion? immediateOrientation = + rmState.Interp.Enqueue( + worldPos, + rot, + isMovingTo: rmState.Movement.IsMovingTo(), + currentBodyPosition: rmState.Body.Position, + currentBodyOrientation: rmState.Body.Orientation); + if (immediateOrientation is { } closeOrientation) + rmState.Body.Orientation = closeOrientation; + } + } + } + // K-fix15 (2026-04-26): DON'T auto-clear airborne on UP. + // ACE broadcasts UPs during the arc (peak / mid-fall / land) + // at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on + // the FIRST UP after the jump, which: + // * restored Contact + OnWalkable, + // * removed the Gravity flag, + // * caused the next per-tick to stomp Velocity via + // apply_current_movement (reading InterpretedState = + // Ready, so Velocity.Z went to 0), + // …so the body got stuck at the server-broadcast apex Z, + // visibly hovering. The fix: leave Airborne true; the + // per-tick post-resolve logic detects an actual landing + // (resolveResult.IsOnGround && Velocity.Z <= 0) and clears + // it then. Mirrors how PlayerMovementController re-grounds + // the local player at the bottom of its arc. + // + // The position-snap above is still authoritative — if ACE + // says the body is at Z=68 mid-arc, we render Z=68. But we + // continue integrating gravity from there, so the body + // proceeds along the parabolic path between UPs. + // Adopt the server's cell ID as the transition starting cell. + // Retail authoritatively hard-snaps cell membership here too; our + // per-tick ResolveWithTransition sweep then advances CheckCellId + // as the sphere crosses cells and writes the new cell back into + // rmState.CellId so the NEXT frame starts in the correct cell. + rmState.CellId = p.LandblockId; + + // Near UpdatePosition orientation is carried by the same complete + // interpolation Frame as translation. Placement, airborne, and + // far-correction branches above install the authoritative Frame + // directly. Sticky still receives the shared Frame afterward and + // may replace it while armed. + rmState.LastServerPos = worldPos; + rmState.LastServerPosTime = nowSec; + + if (rmState.HasServerVelocity + && !snapSuppressedByStick + && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity)) + { + // NPC/monster remotes: PlanFromVelocity cycle selection from + // UP-derived velocity (ACE broadcasts NPC motion patterns the + // UM stream alone doesn't cover). Player remotes return early + // inside — their cycles are UM-driven only per retail (L.2g + // S5; DEV-2 deleted). Unification of NPCs onto the + // CMotionInterp funnel is S6. + // + // D2 (Commit A 2026-05-03): tag whether the velocity feeding + // ApplyServerControlledVelocityCycle is wire-explicit or + // synthesized from position deltas (the common case). + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + string velSrc = update.Velocity is null ? "synth" : "wire"; + System.Console.WriteLine( + $"[UPCYCLE_SRC] guid={update.Guid:X8} src={velSrc}"); + } + RemoteServerControlledVelocityCycle.Apply( + update.Guid, + aeForVelocity, + rmState, + rmState.ServerVelocity); + } + + // #184: sync the NPC shadow to the resolved/placed body (NOT the raw + // server pos — the raw-pos sync was RETIRED for players too in Slice 2b) + // so collision == render and the de-overlap isn't snapped away each UP. + // Covers the first UP (before any DR tick) and no-Sequencer NPCs (which + // the per-tick loop skips). The per-tick loop keeps it current between + // UPs, pose-gated. rmState.CellId is the server cell adopted above. + // The root frame is committed before collision publication, as in + // retail SetPositionInternal. + entity.SetPosition(rmState.Body.Position); + entity.ParentCellId = rmState.CellId; + entity.Rotation = rmState.Body.Orientation; + AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote( + _liveEntities, + positionRecord, + entity, + rmState, + acceptedPositionAuthorityVersion, + () => _remotePhysicsUpdater.SyncRemoteShadowToBody( + entity.Id, + rmState, + _origin.CenterX, + _origin.CenterY)); + } + + // F751 is only a notification gate; the accepted Position may arrive + // before or after it. Canonical physics above always consumes the + // packet first. The presentation coordinator exposes exactly one + // sequence-correlated destination without reordering that state. + if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Apply + && update.Guid == _playerServerGuid + && _teleportTransit.OfferDestination( + update.TeleportSequence, + timestamps.TeleportAdvanced, + update, + out var teleportDestination)) + { + _aimTeleportDestination(teleportDestination); + } + } + + // Retail teleport transit: the 7-state TAS drives portal/view-plane presentation, holds the + // player in PortalSpace until the destination is resident (TeleportWorldReady), then + // fires Place (materialize) and FireLoginComplete (regain control + ack the server). + // Replaces the old TeleportArrivalController hold/place machine. +} diff --git a/src/AcDream.App/Physics/LiveEntityVectorRoute.cs b/src/AcDream.App/Physics/LiveEntityVectorRoute.cs new file mode 100644 index 00000000..9b81b733 --- /dev/null +++ b/src/AcDream.App/Physics/LiveEntityVectorRoute.cs @@ -0,0 +1,34 @@ +namespace AcDream.App.Physics; + +internal enum LiveEntityVectorRoute +{ + Projectile, + CanonicalBody, + OrdinaryRemote, +} + +/// +/// Pins retail's one-CPhysicsObj vector-update priority. A missile owns its +/// canonical body first; otherwise a retained static body consumes the wire +/// vector; only an ordinary remote reaches MovementManager storage. +/// +internal static class LiveEntityVectorRouter +{ + internal static LiveEntityVectorRoute Route( + Func tryProjectile, + Func tryCanonicalBody, + Action applyOrdinaryRemote) + { + ArgumentNullException.ThrowIfNull(tryProjectile); + ArgumentNullException.ThrowIfNull(tryCanonicalBody); + ArgumentNullException.ThrowIfNull(applyOrdinaryRemote); + + if (tryProjectile()) + return LiveEntityVectorRoute.Projectile; + if (tryCanonicalBody()) + return LiveEntityVectorRoute.CanonicalBody; + + applyOrdinaryRemote(); + return LiveEntityVectorRoute.OrdinaryRemote; + } +} diff --git a/src/AcDream.App/Physics/LocalForcePositionTransaction.cs b/src/AcDream.App/Physics/LocalForcePositionTransaction.cs new file mode 100644 index 00000000..a48f35fd --- /dev/null +++ b/src/AcDream.App/Physics/LocalForcePositionTransaction.cs @@ -0,0 +1,29 @@ +namespace AcDream.App.Physics; + +/// +/// Preserves retail ForcePosition's atomic local order: validate the accepted +/// Position authority, blip once, acknowledge once, then stop if the +/// acknowledgement synchronously displaced that authority. +/// +internal static class LocalForcePositionTransaction +{ + internal static bool Apply( + bool isForcePosition, + Func isCurrent, + Action blip, + Action acknowledge) + { + ArgumentNullException.ThrowIfNull(isCurrent); + ArgumentNullException.ThrowIfNull(blip); + ArgumentNullException.ThrowIfNull(acknowledge); + + if (!isForcePosition) + return true; + if (!isCurrent()) + return false; + + blip(); + acknowledge(); + return isCurrent(); + } +} diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index 412ef6e2..7837f878 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -24,11 +24,10 @@ namespace AcDream.App.Physics; /// the !IsPlayerGuid-gated stale-velocity animation-cycle stop. See /// docs/research/2026-07-07-184-slice2-unify-extract-handoff.md. /// -/// Shared helpers that GameWindow also calls elsewhere are injected: -/// GetSetupCylinder (a general Setup-dimension helper with ~9 callers, -/// incl. the local player's own cylinder — kept on GameWindow) and -/// ApplyServerControlledVelocityCycle (anim-cycle selection, also called -/// from the UP handler) arrive as delegates. SyncRemoteShadowToBody +/// Shared policy arrives through focused Physics delegates: +/// GetSetupCylinder is owned by LiveEntityMotionRuntimeController, +/// while ApplyServerControlledVelocityCycle is stateless policy also +/// consumed by the UpdatePosition route. SyncRemoteShadowToBody /// (remote-physics-specific) moved here and is called back from the UP-branch /// tail; ApplyPositionManagerDelta / TickRemoteMoveTo had no other /// callers and moved here outright. diff --git a/src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs b/src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs new file mode 100644 index 00000000..312e54c2 --- /dev/null +++ b/src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs @@ -0,0 +1,81 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Physics; + +/// +/// Stateless AP-80 compatibility refinement for server-controlled non-player +/// locomotion. Kept outside network routing because the per-frame remote +/// physics updater and accepted Position tail consume the same operation. +/// +internal static class RemoteServerControlledVelocityCycle +{ + private static bool IsPlayerGuid(uint guid) => + (guid & 0xFF000000u) == 0x50000000u; + public static void Apply( + uint serverGuid, + LiveEntityAnimationState ae, + RemoteMotion rm, + System.Numerics.Vector3 velocity) + { + if (rm.Airborne) return; + if (ae.Sequencer is null) return; + // R4-V4: an active MoveToManager owns the cycle (BeginMoveForward's + // get_command dispatch). Velocity-estimated cycle planning would + // fight it — same rationale as the pre-V4 ServerMoveToActive guard. + if (rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }) return; + + if (IsPlayerGuid(serverGuid)) + { + // L.2g S5 (2026-07-02, DEV-2 DELETED): player remotes get NO + // pace-derived cycle refinement — retail has no such mechanism + // anywhere in its inbound pipeline (deviation map DEV-2, two + // independent decomp dives + ACE cross-check). The #39-era + // premise ("retail's outbound goes silent on Shift toggle") was + // refuted at all three oracles + the S0 live capture: retail + // sends a fresh MoveToState on HoldRun toggle while moving + // (CommandInterpreter 0x006b37a8 → SendMovementEvent), ACE + // rebroadcasts every MoveToState (GameActionMoveToState.cs:36), + // and the wire shows explicit 0x0005↔0x0007 UMs on each toggle + // (launch-s0-wireprobe.log). The refinement layer's re-promote + // after legitimate flags=0 Ready UMs was itself the observed + // Ready↔Run thrash. Cycle changes for player remotes come from + // UpdateMotion ONLY, exactly like retail; position error is the + // InterpolationManager chase's job. + // + // NPC/monster remotes below keep PlanFromVelocity until S6 + // unifies all entity classes onto the CMotionInterp funnel. + return; + } + + uint currentMotion = ae.Sequencer.CurrentMotion; + // AP-80 is a position-only compatibility adaptation, not an + // animation authority. Restrict it to Ready/Walk/Run so late death + // position deltas cannot replace the authoritative Dead substate. + if (!AcDream.Core.Physics.ServerControlledLocomotion + .CanApplyVelocityCycle(currentMotion)) + return; + + var plan = AcDream.Core.Physics.ServerControlledLocomotion + .PlanFromVelocity(velocity); + + uint style = ae.Sequencer.CurrentStyle != 0 + ? ae.Sequencer.CurrentStyle + : 0x8000003Du; + + // D2 (Commit A 2026-05-03): UPCYCLE diag — proves whether + // ApplyServerControlledVelocityCycle is racing UpdateMotion-driven + // SetCycle for non-player remotes (NPCs / monsters). + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + System.Console.WriteLine( + $"[UPCYCLE] guid={serverGuid:X8} " + + $"vel=({velocity.X:F2},{velocity.Y:F2},{velocity.Z:F2}) " + + $"|v|={velocity.Length():F2} " + + $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} " + + $"prev=0x{currentMotion:X8} " + + $"airborne={rm.Airborne} moveTo={rm.MoveTo?.MovementTypeState ?? AcDream.Core.Physics.MovementType.Invalid}"); + } + ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod); + } + +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5a088d91..2e522bc7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -164,7 +164,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.UI.Abstractions.Settings.QualitySettings _resolvedQuality = AcDream.UI.Abstractions.Settings.QualitySettings.From( AcDream.UI.Abstractions.Settings.QualityPreset.High); - private uint? _lastLivePlayerLandblockId; // Phase B.3: physics engine — populated from the streaming pipeline. private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new(); @@ -175,9 +174,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache = new(); // #184 Slice 2a: the per-remote dead-reckoning tick, extracted out of the - // >10k-line TickAnimations (Code Structure Rule 1). Assigned in the ctor - // once the injected shared helpers exist as method groups (GetSetupCylinder, - // ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater. + // >10k-line TickAnimations (Code Structure Rule 1). Reusable setup/motion + // policy is supplied through the once-bound live motion runtime, while the + // stateless server-velocity cycle remains a focused Physics helper. private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater; private readonly AcDream.App.Physics.RemoteInboundMotionDispatcher _remoteInboundMotion; @@ -196,6 +195,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.World.LiveEntityProjectionWithdrawalController? _liveEntityProjectionWithdrawal; private AcDream.App.World.LiveEntityHydrationController? _liveEntityHydration; + private AcDream.App.Physics.LiveEntityNetworkUpdateController? _liveEntityNetworkUpdates; + private readonly AcDream.App.Physics.DeferredLiveEntityMotionRuntimeBindings + _liveEntityMotionBindings = new(); // Step 4: portal-based interior cell visibility. private readonly CellVisibility _cellVisibility = new(); @@ -390,14 +392,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// a Walk/Run command or from packet cadence. /// /// - private readonly LiveEntityRemoteMotionRuntimeView _remoteDeadReckon; /// /// L.2g S1 (DEV-6): per-entity inbound movement-event staleness gates, /// keyed by server guid. Retail keeps these stamps on CPhysicsObj /// (update_times); seeded from CreateObject's PhysicsDesc timestamp /// block during , consulted at the - /// top of , and dropped by the live + /// top of , and dropped by the live /// entity teardown owner. /// private AcDream.App.World.LiveEntityRuntime? _liveEntities; @@ -786,23 +787,25 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _uiRegistry = uiRegistry; _animatedEntities = new LiveEntityAnimationRuntimeView(() => _liveEntities); - _remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView(() => _liveEntities); SpellBook = new AcDream.Core.Spells.Spellbook(); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); - // #184 Slice 2a: the extracted per-remote DR tick. Shares GameWindow's - // PhysicsEngine; the two helpers it also needs but that have callers - // outside the DR loop (GetSetupCylinder — the player's own cylinder + - // moveto/sticky radii; ApplyServerControlledVelocityCycle — the UP - // handler) stay on GameWindow and are injected as delegates. + // #184 Slice 2a: the extracted per-remote DR tick. Its stateful + // setup/motion dependency crosses the fail-fast construction bridge; + // the server-velocity animation cycle is stateless shared policy. _remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater( - _physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle); + _physicsEngine, + _liveEntityMotionBindings.GetSetupCylinder, + AcDream.App.Physics.RemoteServerControlledVelocityCycle.Apply); _remoteInboundMotion = new AcDream.App.Physics.RemoteInboundMotionDispatcher( - RouteServerMoveTo, - StickToObjectFromWire); + (movement, cellId, update) => + _liveEntityMotionBindings.RouteServerMoveTo( + movement, cellId, update), + (host, targetGuid) => + _liveEntityMotionBindings.StickToObjectFromWire(host, targetGuid)); var ordinaryPhysicsUpdater = new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater( _physicsEngine, - GetSetupCylinder); + _liveEntityMotionBindings.GetSetupCylinder); _liveAnimationScheduler = new LiveEntityAnimationScheduler( () => _liveEntities, () => _playerServerGuid, @@ -2066,6 +2069,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // all guaranteed non-null here (startup throws above if any are missing). var liveEntityComponentLifecycle = new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle(); + AcDream.App.Physics.LiveEntityMotionRuntimeController? + liveEntityMotionRuntime = null; { var wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter!); // Sequencer factory: look up Setup + MotionTable from dats and build @@ -2224,6 +2229,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext entityScriptActivator.OnCreate, entityScriptActivator.OnRemove)), liveEntityComponentLifecycle); + liveEntityMotionRuntime = + new AcDream.App.Physics.LiveEntityMotionRuntimeController( + _liveEntities, + _physicsDataCache, + () => _selectionInteractions, + _selection, + _liveWorldOrigin); + _liveEntityMotionBindings.Bind(liveEntityMotionRuntime); _liveEntities.ProjectionVisibilityChanged += (record, visible) => { if (record.WorldEntity is { } entity) @@ -2306,7 +2319,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _physicsEngine.ShadowObjects, entityEffects.PlayTypedFromHiddenTransition, _equippedChildRenderer.SetDirectChildrenNoDraw, - ClearTargetForHiddenEntity, + _liveEntityMotionBindings.ClearTargetForHiddenEntity, () => (_liveCenterX, _liveCenterY), handlePartArrayEnterWorld: localEntityId => { @@ -2316,7 +2329,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController( _physicsEngine, _liveEntities, - GetSetupCylinder, + _liveEntityMotionBindings.GetSetupCylinder, CellLocalForSeed, (entity, remote, cellId) => _remotePhysicsUpdater.SyncRemoteShadowToBody( entity.Id, @@ -2361,7 +2374,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext player.CellId, player.Position) : null, - GetSetupCylinder, + _liveEntityMotionBindings.GetSetupCylinder, setupId => { if (_dats is null) @@ -2594,14 +2607,45 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext PublishLocalPhysicsTimestamps, () => _playerServerGuid, _options.DumpLiveSpawns ? Console.WriteLine : null); + _liveEntityNetworkUpdates = + new AcDream.App.Physics.LiveEntityNetworkUpdateController( + _liveEntities, + Objects, + _liveEntityHydration, + _entityEffects!, + _liveEntityPresentation!, + _liveEntityLights!, + _equippedChildRenderer!, + _projectileController!, + _remoteTeleportController!, + _animatedEntities, + new LiveEntityRemoteMotionRuntimeView( + () => _liveEntities), + _remoteMovementObservations, + _remotePhysicsUpdater, + _remoteInboundMotion, + liveEntityMotionRuntime!, + _physicsEngine, + _dats!, + _animLoader!, + _combatTargetController, + _liveWorldOrigin, + _teleportTransit, + () => _playerController, + _localPlayerOutbound, + () => _playerHost, + () => _playerServerGuid, + () => _physicsScriptGameTime, + () => LiveSession, + PublishLocalPhysicsTimestamps, + AimTeleportDestination, + DumpMovementTruthServerEcho); _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController( _liveEntities, () => _playerServerGuid, candidate => _liveEntityHydration.OnPrune(candidate)); parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection); - networkUpdateBridge.Bind( - new AcDream.App.World.DelegateLiveEntityNetworkUpdateSink( - RouteSameGenerationCreateObject)); + networkUpdateBridge.Bind(_liveEntityNetworkUpdates); _equippedChildRenderer.EntityReady += candidate => _liveEntityHydration.OnEntityReady(candidate); _liveEntityHydration.AppearanceApplied += guid => @@ -2759,7 +2803,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; _lastSeenRunSkill = -1; _lastSeenJumpSkill = -1; - _lastLivePlayerLandblockId = null; + _liveEntityNetworkUpdates?.ResetSessionState(); _liveEntityHydration?.ResetSessionState(); Shortcuts = Array.Empty(); DesiredComponents = Array.Empty<(uint Id, uint Amount)>(); @@ -2853,13 +2897,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _liveEntityHydration!, pickup, static (hydration, value) => hydration.OnPickup(value)), MotionUpdated: motion => _inboundEntityEvents.Run( - this, motion, static (window, value) => window.OnLiveMotionUpdated(value)), + _liveEntityNetworkUpdates!, motion, + static (updates, value) => updates.OnMotion(value)), PositionUpdated: position => _inboundEntityEvents.Run( - this, position, static (window, value) => window.OnLivePositionUpdated(value)), + _liveEntityNetworkUpdates!, position, + static (updates, value) => updates.OnPosition(value)), VectorUpdated: vector => _inboundEntityEvents.Run( - this, vector, static (window, value) => window.OnLiveVectorUpdated(value)), + _liveEntityNetworkUpdates!, vector, + static (updates, value) => updates.OnVector(value)), StateUpdated: state => _inboundEntityEvents.Run( - this, state, static (window, value) => window.OnLiveStateUpdated(value)), + _liveEntityNetworkUpdates!, state, + static (updates, value) => updates.OnState(value)), ParentUpdated: parent => _inboundEntityEvents.Run( _liveEntityHydration!, parent, static (hydration, value) => hydration.OnParent(value)), @@ -3141,37 +3189,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext doll.MeshRefs = reposed; } - private void RouteSameGenerationCreateObject( - AcDream.App.World.SameGenerationCreateObjectEvents refresh) - { - if (_liveEntities!.TryGetEffectProfile( - refresh.Appearance.Guid, - out var effectProfile) - && effectProfile is AcDream.App.Rendering.Vfx.EntityEffectProfile liveProfile) - { - liveProfile.ApplyNetworkDescription(refresh.Description); - _entityEffects?.OnLiveEntityDescriptionChanged(refresh.Appearance.Guid); - } - - _liveEntityHydration!.OnAppearance(refresh.Appearance); - - if (refresh.Parent is { } parent) - _liveEntityHydration.OnCreateParentAccepted(parent); - else if (refresh.Position is { } position) - { - OnLivePositionUpdated(position); - } - else if (refresh.Pickup is { } pickup) - { - _liveEntityHydration.OnPickup(pickup); - } - - if (refresh.Movement is { } movement) - OnLiveMotionUpdated(movement); - OnLiveStateUpdated(refresh.State); - OnLiveVectorUpdated(refresh.Vector); - } - private void PublishLocalPhysicsTimestamps( uint guid, AcDream.App.World.AcceptedPhysicsTimestamps timestamps) @@ -3248,2145 +3265,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// InitializeMotionTables). Idempotent; safe from both the UM path and /// the VectorUpdate path regardless of arrival order. /// - private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings( - RemoteMotion rm, LiveEntityAnimationState? ae, uint serverGuid) - { - AcDream.Core.Physics.AnimationSequencer? sequencer = ae?.Sequencer; - if (sequencer is not null && rm.Sink is null) - { - rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(sequencer); - rm.Motion.DefaultSink = rm.Sink; - } - // #174 (2026-07-05): the RemoveLinkAnimations seam is retail - // CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to - // CPartArray::HandleEnterWorld 0x00517d70 → - // MotionTableManager::HandleEnterWorld 0x0051bdd0: strip the - // sequence's link animations AND drain pending_animations - // completely (each pop fires MotionDone → CMotionInterp pops its - // pending_motions node in lockstep). The previous binding was the - // bare sequence strip — every LeaveGround (jump) stripped the - // animations that queued manager nodes were counting down on, - // orphaning them (NumAnims>0, anims gone) and permanently damming - // BOTH queues: MotionsPending() never drained again, so every - // armed moveto (ACE's walk-to-door mt-6, the close-range use turn) - // starved — the "door only works on a fresh session" bug. - if (sequencer is not null) - { - rm.Motion.RemoveLinkAnimations = () => sequencer.Manager.HandleEnterWorld(); - rm.Motion.InitializeMotionTables = () => sequencer.Manager.InitializeState(); - // R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions - // 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions). - rm.Motion.CheckForCompletedMotions = sequencer.Manager.CheckForCompletedMotions; - } - - // Host/MoveTo ownership does not depend on a render animation. AP-77 - // deliberately supplies CMotionInterp's headless body fallback when a - // live object has no PartArray sink, so build the manager once anyway. - if (rm.Host is not null) - return rm.Sink; - if (_liveEntities is not { } liveEntities - || !liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord hostRecord) - || !ReferenceEquals(hostRecord.RemoteMotionRuntime, rm)) - { - return rm.Sink; - } - // R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness - // wiring (the conformance-tested reference). Positions are WORLD - // space on both sides (getPosition + the MovementStruct positions - // the UM router builds) — one consistent space, so the manager's - // distance/heading math matches the harness exactly. - // R5-V5: the construction is now the MovementManager facade's - // MoveToFactory (the acdream stand-in for the physics_obj/weenie_obj - // backpointers retail's MakeMoveToManager 0x00524000 constructs - // from); MakeMoveToManager() below invokes it once, preserving the - // pre-facade eager timing. - var rmT = rm; - var mtBody = rm.Body; - // R5-V3 (#171): real setup cylsphere radii — retail CPartArray:: - // GetRadius/GetHeight (0x005180a0/0x005180b0). Lazy per-call resolve - // (a dictionary hit) so the read never races the spawn path's - // CacheSetup. Feeds GetCurrentDistance's UseSpheres cylinder math - // (own side) and StickyManager::adjust_offset's own-radius gap term. - // Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with - // R5-V3"). - if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var selfEntity)) - return rm.Sink; - // R5-V2: forward-declared so the MoveToManager's target seams route - // into the entity's TargetManager (retail CPhysicsObj::set_target → - // TargetManager::SetTarget). Assigned right after the manager is built. - EntityPhysicsHost host = null!; - double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - rm.Movement.MoveToFactory = () => - { - var mtm = new AcDream.Core.Physics.Motion.MoveToManager( - rm.Motion, - stopCompletely: () => rmT.Movement.PerformMovement( - new AcDream.Core.Physics.MovementStruct - { - Type = AcDream.Core.Physics.MovementType.StopCompletely, - }), - getPosition: () => new AcDream.Core.Physics.Position( - rmT.CellId, mtBody.Position, mtBody.Orientation), - getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading( - mtBody.Orientation), - setHeading: (h, _) => mtBody.Orientation = - AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h), - getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, - getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height, - contact: () => mtBody.OnWalkable, - isInterpolating: () => rmT.Interp.IsActive, - getVelocity: () => mtBody.Velocity, - getSelfId: () => serverGuid, - // R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum - // → the entity's TargetManager (replaces the AP-79 TrackedTarget* - // poll). The manager passes (0, top_level_id, 0.5, 0.0). - setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q), - clearTarget: () => host.ClearTarget(), - getTargetQuantum: () => host.TargetManager.GetTargetQuantum(), - setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q), - // R4-V5: real clock (same epoch-seconds base the per-remote tick uses). - curTime: NowSeconds); - // R5-V3 (#171, retires TS-39): bind the sticky seams to the host's - // PositionManager (host is constructed before MakeMoveToManager - // invokes this factory) — - // * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo - // (retail MoveToManager::BeginNextNode @0x00529d3a); - // * PerformMovement's head unstick: unstick_from_object → - // PositionManager::UnStick (MoveToManager.PerformMovement:414). - mtm.StickTo = (tlid, radius, height) => - host.PositionManager.StickTo(tlid, radius, height); - mtm.Unstick = host.PositionManager.UnStick; - return mtm; - }; - // TS-36 (remote side): the interp's interrupt seam is retail's - // interrupt_current_movement → MovementManager::CancelMoveTo(0x36) - // chain (V2's reentrancy tests prove the wiring is inert-safe; - // R5-V5: the chain now lands on the literal facade relay 0x005241b0). - rm.Motion.InterruptCurrentMovement = - () => rmT.Movement.CancelMoveTo( - AcDream.Core.Physics.WeenieError.ActionCancelled); - - // R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position - // is the entity's WORLD position (WorldEntity.Position — exactly the - // source the AP-79 poll used for a tracked target, so the voyeur system - // delivers the identical position for a moveto's quantum-0 case). - // HandleTargetting ticks per frame (added in the per-remote loop); - // HandleUpdateTarget fans deliveries to this entity's MoveToManager - // and (R5-V3, inside the host) its PositionManager — retail's - // CPhysicsObj::HandleUpdateTarget order. - var configuredHost = new EntityPhysicsHost( - serverGuid, - getPosition: () => new AcDream.Core.Physics.Position( - hostRecord.FullCellId, - hostRecord.WorldEntity?.Position ?? mtBody.Position, - mtBody.Orientation), - getVelocity: () => mtBody.Velocity, - getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, - inContact: () => mtBody.OnWalkable, - minterpMaxSpeed: () => rmT.Motion.GetMaxSpeed(), - curTime: NowSeconds, - physicsTimerTime: NowSeconds, - getObjectA: ResolvePhysicsHost, - // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head: - // MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade - // relay to the moveto side); the host chains the PositionManager - // leg after it. - handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info), - interruptCurrentMovement: () => rmT.Movement.CancelMoveTo( - AcDream.Core.Physics.WeenieError.ActionCancelled)); - host = EntityPhysicsHost.InstallOrRebind( - liveEntities, - hostRecord, - configuredHost); - rm.MarkFullPhysicsHostBound(); - - // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the - // MoveToManager via the factory above (host now exists for the - // sticky binds inside it). The UM funnel head's unstick - // (CPhysicsObj::unstick_from_object 0x0050eaea, invoked at the mt-0 - // routing sites) binds on the interp beside it. - rm.Movement.MakeMoveToManager(); - rm.Motion.UnstickFromObject = host.PositionManager.UnStick; - return rm.Sink; - } - - /// - /// R5-V2 — retail CObjectMaint::GetObjectA(id): resolve any entity's - /// by guid, the - /// cross-entity seam every host's GetObjectA uses. If the entity has - /// a bound host (from or - /// EnterPlayerModeNow) return it; otherwise, for any entity that - /// EXISTS in the world, lazily create a minimal position-only host. Retail - /// gives every CPhysicsObj the capacity to host a - /// target_manager — a STATIC object (chest, corpse) must still answer - /// add_voyeur so a mover can moveto/stick to it (without this, - /// auto-walk to a never-animated object would arm the moveto but never - /// receive the immediate target snapshot, and never start). The minimal - /// host is registered so subsequent lookups reuse it; it is NOT ticked - /// (a static object never drifts, so the AddVoyeur immediate snapshot is - /// all a watcher needs; despawn still fires ExitWorld via the registry). - /// - private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id) - { - if (_liveEntities is not { } liveEntities) - return null; - - bool isActive = liveEntities.TryGetRecord(id, out LiveEntityRecord activeRecord); - if (!isActive) - return null; - - // TS-49: CObjCell::hide_object removes Hidden objects from the - // relationship surface until DetectionManager is ported. Pending, - // attached, and otherwise non-render-visible active objects remain in - // CObjectMaint's object table and must still resolve here. - if (liveEntities.IsHidden(id)) - { - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - Console.WriteLine( - $"[autowalk-host-miss] object=0x{id:X8} " - + $"materialized={_entitiesByServerGuid.ContainsKey(id)} " - + $"registered={liveEntities.TryGetPhysicsHost(id, out _)} " - + $"hidden={liveEntities.IsHidden(id)}"); - } - return null; - } - if (liveEntities.TryGetPhysicsHost(id, out var existing)) - return existing; - - double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - var minimal = EntityPhysicsHost.CreateMinimal( - activeRecord, - ResolvePhysicsHost, - NowSeconds); - liveEntities.InstallPhysicsHost(activeRecord, minimal); - return minimal; - } - - /// - /// R5-V3 (#171): retail CPartArray::GetRadius/GetHeight - /// (0x005180a0/0x005180b0 — setup->radius/height × scale; ACE - /// PartArray.cs:189-207 reads the same Setup-level fields). Returns - /// (0, 0) when the entity has no resolvable Setup (bare-GfxObj props) — - /// ACE's PartArray != null ? … : 0 fallback shape - /// (PhysicsObj.cs:951-952). Live spawns cache their Setup in - /// _physicsDataCache at spawn time (CacheSetup, ~3250), so this is - /// a dictionary hit for every creature. - /// - private (float Radius, float Height) GetSetupCylinder( - uint serverGuid, AcDream.Core.World.WorldEntity entity) - { - var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); - if (setup is null) - return (0f, 0f); - // Live spawns bake ObjScale into MeshRefs and never populate - // WorldEntity.Scale (a scenery-pipeline field, default 1.0) — the - // authoritative per-entity scale is the SPAWN RECORD's ObjScale, the - // same source the collision registration's entScale uses (~4137). - // entity.Scale remains the fallback for non-spawn entities (scenery — - // never a chase/sticky participant). Diff-review find: without this, - // a server-scaled creature variant read unscaled radii and kept the - // #171 interpenetration exactly for scaled bodies. - float scale = - LastSpawns.TryGetValue(serverGuid, out var sp) - && sp.ObjScale is { } objScale && objScale > 0f - ? objScale - : (entity.Scale > 0f ? entity.Scale : 1f); - return (setup.Radius * scale, setup.Height * scale); - } - - // #184 Slice 2a: ApplyPositionManagerDelta + SyncRemoteShadowToBody moved to - // AcDream.App.Physics.RemotePhysicsUpdater. ApplyPositionManagerDelta had no - // caller outside the DR tick; SyncRemoteShadowToBody is now called back via - // _remotePhysicsUpdater from the NPC UP-branch tail (below). - - /// - /// R5-V4: retail CPhysicsObj::stick_to_object (0x005127e0) — the - /// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the - /// motionFlags 0x1 trailer guid): resolve the target, read its PartArray - /// radius/height (0 when shapeless — retail's null-PartArray arm), then - /// PositionManager::StickTo. Unresolvable target → no stick at - /// all (retail's GetObjectA-null path). Retail resolves the target's - /// top-level PARENT first (parent ?? self); acdream's entity - /// table is flat (no client-side parenting), so the guid is used as-is — - /// the same convention as RouteServerMoveTo's TopLevelId. - /// - private void StickToObjectFromWire( - AcDream.Core.Physics.Motion.IPhysicsObjHost? host, - uint targetGuid) - { - if (host is not EntityPhysicsHost entityHost) - return; - if (_liveEntities is not { } liveEntities - || !liveEntities.TryGetInteractionEligibleEntity(targetGuid, out var tgtEnt)) - return; - var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt); - entityHost.PositionManager.StickTo(targetGuid, radius, height); - } - - private void ClearTargetForHiddenEntity(uint serverGuid) - { - if (_selectionInteractions is { } interactions) - interactions.OnEntityHidden(serverGuid); - else if (_selection.SelectedObjectId == serverGuid) - _selection.Clear( - AcDream.Core.Selection.SelectionChangeSource.System, - AcDream.Core.Selection.SelectionChangeReason.Cleared); - - if (_liveEntities?.TryGetPhysicsHost(serverGuid, out var hiddenHost) == true - && hiddenHost is EntityPhysicsHost hiddenEntityHost) - hiddenEntityHost.NotifyHidden(); - } - - /// - /// R4-V5: shared retail unpack_movement type-6..9 routing - /// (0x00524440 cases 6/7/8/9, decomp §2f) — one body for remotes - /// (R4-V4) and the local player (R4-V5). Writes the wire run rate to - /// the interp (my_run_rate, unpack @300603/@300660 — plan M13), - /// builds the MovementStruct (mt 6/8 resolve the target guid - /// against the entity table; unresolvable degrades to - /// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f — - /// NOT an error), and calls the R5-V5 facade's PerformMovement - /// (MovementManager::PerformMovement 0x005240d0 — types 6-9 - /// MakeMoveToManager + delegate; retail's cases call MakeMoveToManager - /// at each head, a no-op here since the bind sites create eagerly). - /// Returns true when the event was a type-6..9 moveto (consumed); false - /// for every other movement type (caller falls through to its funnel / - /// skip posture). - /// - private bool RouteServerMoveTo( - AcDream.Core.Physics.Motion.MovementManager movement, - uint cellId, - AcDream.Core.Net.WorldSession.EntityMotionUpdate update) - { - if (update.MotionState.IsServerControlledMoveTo - && update.MotionState.MoveToPath is { } path) - { - // my_run_rate write (unpack_movement @300603). - if (update.MotionState.MoveToRunRate is { } mtRunRate) - movement.Minterp.MyRunRate = mtRunRate; - - var destWorld = AcDream.Core.Physics.Motion.MoveToMath - .OriginToWorld( - path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ, - _liveCenterX, _liveCenterY); - var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire( - path.Bitfield, - path.DistanceToObject, - path.MinDistance, - path.FailDistance, - update.MotionState.MoveToSpeed ?? 1f, - path.WalkRunThreshold, - path.DesiredHeading); - - var ms = new AcDream.Core.Physics.MovementStruct - { - Params = mp, - }; - // mt 6 with a resolvable target → MoveToObject (the P4 tracker - // feeds position updates per tick); else degrade to - // MoveToPosition at the wire origin (§2f). - if (update.MotionState.MovementType == 6 - && path.TargetGuid is { } tgtGuid - && _liveEntities is { } liveMoveEntities - && liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt)) - { - ms.Type = AcDream.Core.Physics.MovementType.MoveToObject; - ms.ObjectId = tgtGuid; - ms.TopLevelId = tgtGuid; - // R5-V3 (#171): retail resolves the TARGET object's PartArray - // radius/height at the MoveToObject call site - // (CPhysicsObj::MoveToObject 0x005128e9/0x00512903; ACE - // PhysicsObj.cs:951-952) — they become SoughtObjectRadius/ - // Height, feeding GetCurrentDistance's edge-to-edge arrival - // (UseSpheres) and the sticky-arrival handoff's target radius. - // Was unset (0): every attacker closed ~one body-radius deeper - // than retail before stopping — the #171 dogpile term. - (ms.Radius, ms.Height) = GetSetupCylinder(tgtGuid, tgtEnt); - ms.Pos = new AcDream.Core.Physics.Position( - cellId, tgtEnt.Position, - System.Numerics.Quaternion.Identity); - } - else - { - ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition; - ms.Pos = new AcDream.Core.Physics.Position( - cellId, destWorld, - System.Numerics.Quaternion.Identity); - } - movement.PerformMovement(ms); - return true; - } - - if (update.MotionState.IsServerControlledTurnTo - && update.MotionState.TurnToPath is { } turnPath) - { - var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo( - turnPath.Bitfield, - turnPath.Speed, - turnPath.DesiredHeading); - - var ms = new AcDream.Core.Physics.MovementStruct { Params = mp }; - if (update.MotionState.MovementType == 8 - && turnPath.TargetGuid is { } turnTgt - && _liveEntities is { } liveTurnEntities - && liveTurnEntities.TryGetInteractionEligibleEntity(turnTgt, out var turnEnt)) - { - ms.Type = AcDream.Core.Physics.MovementType.TurnToObject; - ms.ObjectId = turnTgt; - ms.TopLevelId = turnTgt; - ms.Pos = new AcDream.Core.Physics.Position( - cellId, turnEnt.Position, - System.Numerics.Quaternion.Identity); - } - else - { - ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading; - // Retail's mt-8 unresolvable-object fallback substitutes the - // STANDALONE wire heading into the params before degrading - // (decomp §2f case 8: `params.desired_heading = wire_heading`). - // Invisible against ACE (P6: both heading fields written from - // the same source) but required for the verbatim degrade — - // closes the V4 carry-over the adversarial review caught - // (TurnToPathData.WireHeading was parsed but never consumed). - if (update.MotionState.MovementType == 8 - && turnPath.WireHeading is { } wireHeading) - { - mp.DesiredHeading = wireHeading; - } - } - movement.PerformMovement(ms); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - string target = turnPath.TargetGuid is { } targetGuid - ? $"0x{targetGuid:X8}" : "null"; - bool targetVisible = turnPath.TargetGuid is { } visibleGuid - && _visibleEntitiesByServerGuid.ContainsKey(visibleGuid); - bool targetHost = turnPath.TargetGuid is { } hostGuid - && _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true; - var moveTo = movement.MoveTo; - Console.WriteLine( - $"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} " - + $"routed={ms.Type} target={target} visible={targetVisible} " - + $"host={targetHost} stop={mp.StopCompletelyFlag} " - + $"initialized={moveTo?.Initialized ?? false} " - + $"nodes={moveTo?.PendingActions.Count() ?? 0} " - + $"command=0x{moveTo?.CurrentCommand ?? 0u:X8} " - + $"pendingMotions={movement.Minterp.MotionsPending()}"); - } - return true; - } - - return false; - } - - // #184 Slice 2a: TickRemoteMoveTo (retail MovementManager::UseTime - // 0x005242f0 — moveto steering / arrival / fail-distance; the P4 poll is - // gone, TargetManager voyeur delivers positions) moved to - // RemotePhysicsUpdater. The DR tick was its only caller. - - /// - /// Phase 6.6: the server says an entity's motion has changed. Look up - /// the LiveEntityAnimationState for that guid, re-resolve the idle cycle with the - /// new (stance, forward-command) override, and if the cycle is still - /// animated, swap in the new animation/frame range. Entities not in - /// the animated map (static props, entities rejected at spawn time) - /// are simply ignored — there's nothing to tick for them. - /// - private void OnLiveMotionUpdated(AcDream.Core.Net.WorldSession.EntityMotionUpdate update) - { - // L.2g S1 (DEV-6): retail staleness gate — BEFORE any state mutation. - // Retail drops stale/duplicate/superseded movement events at - // DispatchSmartBoxEvent (INSTANCE_TS, pseudo-C:357214) + - // CPhysics::SetObjectMovement (MOVEMENT_TS strictly-newer + - // SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered - // straggler re-applies an old gait or un-stops a stop. - bool retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous; - if (!_liveEntities!.TryApplyMotion( - update, - retainPayload, - out _, - out var timestamps)) - { - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1" - || Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") - { - Console.WriteLine( - $"[UM_STALE] guid={update.Guid:X8} inst={update.InstanceSequence} " - + $"mov={update.MovementSequence} sc={update.ServerControlSequence} dropped"); - } - return; - } - - PublishLocalPhysicsTimestamps(update.Guid, timestamps); - - // R4-V5 (pin P1): retail CPhysics::SetObjectMovement's autonomous - // gate (0x00509690 @0050972e, raw 271370-271431) — a movement event - // whose wire autonomous byte is set is DROPPED ENTIRELY (no state - // application, no interrupt) when the addressed object IsThePlayer. - // ACE reflects the client's own outbound MoveToState back to the - // sender with IsAutonomous=1 hardcoded (MovementData.cs:162 + - // Player_Networking.cs:365) and retail never lets that echo reach - // unpack_movement — which is what makes the unconditional - // unpack-head interrupt in the player branch below safe against - // ACE. Order matches retail: the sequence gates above run FIRST. - // last_move_was_autonomous is NOT stored for dropped events (stored - // only on the unpack path). This retires the row-less "don't cancel - // on non-MoveTo UM" adaptation that lived here pre-V5 (its causal - // story was stale — V0-pins.md P1). Run-rate sync is re-anchored to - // retail's own feeds: PlayerDescription skills (SetCharacterSkills, - // K-fix7) + the mt-6/7 my_run_rate wire write below (M13) — the - // former ApplyServerRunRate echo tap is deleted, not gated. - if (!retainPayload) - return; - - if (!_liveEntities.TryGetRecord( - update.Guid, - out LiveEntityRecord acceptedMotionRecord)) - { - return; - } - ulong acceptedMovementAuthorityVersion = - acceptedMotionRecord.MovementAuthorityVersion; - ulong acceptedMovementVelocityAuthorityVersion = - acceptedMotionRecord.VelocityAuthorityVersion; - - if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; - if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) - { - DispatchRemoteInboundMotion( - update, - entity, - ae: null, - acceptedMotionRecord, - acceptedMovementAuthorityVersion, - acceptedMovementVelocityAuthorityVersion); - return; - } - if (_dats is null) return; - - // Re-resolve using the new stance/command. Keep the setup and - // motion-table we already know about — the server's motion - // updates override state within the same table, not swap tables. - // - // IMPORTANT: stance and command are BOTH optional. Remote-player - // autonomous broadcasts frequently set only one flag (e.g. just - // ForwardCommand) with currentStyle=0x0000 meaning "no stance - // change — keep current." Treating stance=0 as "default stance" - // drops the real state; instead we preserve the sequencer's - // current style. - ushort stance = update.MotionState.Stance; - ushort? command = update.MotionState.ForwardCommand; - - // A.1 (Commit A.1 2026-05-03): UM_RAW — every inbound UM, one line, - // gated on ACDREAM_REMOTE_VEL_DIAG=1. Skips the local player. Tells - // us the actual UM arrival rate per remote and which fields are set - // on each. The bug-suspect is "ACE sends UMs without ForwardCommand - // bit during running, our picker resolves to Ready, SetCycle(Ready) - // resets the cycle". This diag lets us count how often that happens. - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" - && update.Guid != _playerServerGuid) - { - string cmdStrRaw = command.HasValue ? $"0x{command.Value:X4}" : "null"; - string sideStr = update.MotionState.SideStepCommand is { } s ? $"0x{s:X4}" : "null"; - string turnStr = update.MotionState.TurnCommand is { } t ? $"0x{t:X4}" : "null"; - string fwdSpdStr = update.MotionState.ForwardSpeed is { } fs ? $"{fs:F2}" : "null"; - uint seqMot = ae.Sequencer?.CurrentMotion ?? 0; - System.Console.WriteLine( - $"[UM_RAW] guid={update.Guid:X8} stance=0x{stance:X4} fwd={cmdStrRaw} fwdSpd={fwdSpdStr} " - + $"side={sideStr} turn={turnStr} mt=0x{update.MotionState.MovementType:X2} " - + $"isMoveTo={update.MotionState.IsServerControlledMoveTo} " - + $"seq.CurrentMotion=0x{seqMot:X8}"); - } - - // Diagnostic: dump every inbound UpdateMotion so we can trace why - // remote chars don't transition off RunForward when they stop. - // Enable with ACDREAM_DUMP_MOTION=1. - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1" - && update.Guid != _playerServerGuid) - { - string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null"; - float spd = update.MotionState.ForwardSpeed - ?? ((update.MotionState.MoveToSpeed ?? 0f) - * (update.MotionState.MoveToRunRate ?? 0f)); - uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0; - uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0; - Console.WriteLine( - $"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " + - $"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}"); - } - - // Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled - && IsDoorName(Objects.Get(update.Guid)?.Name)) - { - Console.WriteLine(System.FormattableString.Invariant( - $"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}")); - } - - // ── Sequencer path (preferred) ────────────────────────────────── - // Call SetCycle directly. The sequencer already handles: - // - left→right / backward→forward remapping via adjust_motion - // - style and motion as u32 MotionCommand values - // - fast-path for identical state - // - // When the server omits a field (stance flag not set, or command - // flag not set), "no change" means we must preserve the sequencer's - // current state, NOT fall back to a table default. - if (ae.Sequencer is not null) - { - uint fullStyle = stance != 0 - ? (0x80000000u | (uint)stance) - : ae.Sequencer.CurrentStyle; - - // ACE's stop signal: ForwardCommand flag CLEARED on the wire. - // Per ACE InterpretedMotionState(MovementData) ctor + BuildMovementFlags, - // when the player releases keys the InterpretedMotionState has - // ForwardCommand = Invalid (default) and BuildMovementFlags doesn't - // set bit 0x02 — so the field is absent. Retail's decompiled - // handler (FUN_005295D0 → FUN_0051F260 @ chunk_00510000.c:13957) - // bulk-copies Invalid/0 into the physics obj, which StopCompletely - // treats as "return to style default (Ready)." - // - // command == null → retail stop signal → Ready - // command.Value == 0 → explicit 0 (rare) → Ready - // otherwise → resolve class byte and use full cmd - float speedMod = update.MotionState.ForwardSpeed ?? 1f; - uint fullMotion; - // R4-V4: the PlanMoveToStart seed is DELETED — MoveTo UMs no - // longer flow through the interpreted funnel at all (retail - // unpack_movement routes types 6-9 to MoveToManager; only type - // 0 does the interpreted-state copy). The manager's own - // BeginMoveForward -> get_command -> _DoMotion produces the - // cycle through the same sink every other motion uses. - if (!command.HasValue || command.Value == 0) - { - fullMotion = 0x41000003u; - } - else - { - // Use MotionCommandResolver to restore the proper class - // byte from the wire's 16-bit ForwardCommand. - uint resolved = AcDream.Core.Physics.MotionCommandResolver - .ReconstructFullCommand(command.Value); - fullMotion = resolved != 0 - ? resolved - : (ae.Sequencer.CurrentMotion & 0xFF000000u) | (uint)command.Value; - if (fullMotion == (uint)command.Value) // no class bits yet - fullMotion = 0x40000000u | (uint)command.Value; - } - - // ForwardSpeed from the InterpretedMotionState (flag 0x04). - // ACE omits this field when speed == 1.0 (only sets the flag - // when ForwardSpeed != 1.0 — InterpretedMotionState.cs:101). - // So: - // - field absent → default 1.0 (normal speed) - // - field present → USE THE VALUE, including zero. - // - // Zero is a VALID stop signal: when the retail client releases - // W, ACE broadcasts WalkForward with ForwardSpeed=0 (via - // apply_run_to_command). Treating zero as "unspecified / 1.0" - // produces "slow walk that never stops" — exactly what the - // stop bug looked like. - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1" - && update.Guid != _playerServerGuid) - Console.WriteLine( - $"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8}, speed={speedMod:F2})"); - - // No-op if same; the sequencer's fast path guards against that. - uint priorMotion = ae.Sequencer.CurrentMotion; - - // The SetObjectMovement gate above already rejects local - // autonomous echoes. A local event reaching this point is - // server-authored, so retail applies it through the interpreted - // funnel. This is especially important for attacks: ACE chooses - // the exact swing and carries it in Commands[]. - if (update.Guid == _playerServerGuid) - { - // B.6 slice 1 (2026-05-14): trace inbound motion for the - // local player. One line per inbound UM, gated on - // ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5). - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - string cmdHex = command.HasValue ? $"0x{command.Value:X4}" : "null"; - string pathStr = update.MotionState.MoveToPath is { } p - ? $"path=cell=0x{p.OriginCellId:X8},xyz=({p.OriginX:F2},{p.OriginY:F2},{p.OriginZ:F2}),minDist={p.MinDistance:F2},objDist={p.DistanceToObject:F2}" - : "path=null"; - string spd = update.MotionState.ForwardSpeed is { } fs - ? $"fwdSpd={fs:F2}" - : "fwdSpd=null"; - string mtsSpd = update.MotionState.MoveToSpeed is { } ms - ? $"mtSpd={ms:F2}" - : "mtSpd=null"; - string mtsRun = update.MotionState.MoveToRunRate is { } mr - ? $"mtRun={mr:F2}" - : "mtRun=null"; - Console.WriteLine(System.FormattableString.Invariant( - $"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}")); - } - - // R4-V5: retail unpack_movement dispatch for the local - // player — the SAME shape the remote branch uses below. - // Head (@300566): interrupt + unstick fire for EVERY - // movement event that reached unpack (the P1 gate above - // already dropped the autonomous echoes that would have - // made this unsafe against ACE); then types 6-9 route to - // the player's MoveToManager. mt-0 falls through to the - // interpreted-state copy below; LastMoveWasAutonomous=false - // is the local equivalent of LoseControlToServer until the - // next user-input edge takes control back. - if (_playerController is not null) - { - // P1 tail (00509730): the unpack path stores the wire - // autonomous byte BEFORE unpack_movement — always false - // here (the gate above dropped autonomous events). This - // is what routes the controller's per-tick pump (A3 - // dual dispatch) to the INTERPRETED branch during a - // server moveto. LOCAL PLAYER ONLY for now: remotes' - // interps have no WeenieObj, which A3 treats as - // IsThePlayer — storing a remote player's autonomous - // byte would flip their per-tick apply onto the raw - // branch and clobber their funnel state; the remote - // store lands with real remote weenies (R5+). - _playerController.SetLastMoveWasAutonomous(update.IsAutonomous); - bool IsCurrentLocalMotion() => - _liveEntities.IsCurrentMovementAuthority( - acceptedMotionRecord, - acceptedMovementAuthorityVersion) - && _liveEntities.IsCurrentVelocityAuthority( - acceptedMotionRecord, - acceptedMovementVelocityAuthorityVersion) - && ReferenceEquals( - acceptedMotionRecord.WorldEntity, - entity); - if (!IsCurrentLocalMotion()) - return; - - // Local and remote packets now share the literal - // MovementManager::unpack_movement funnel. Besides - // removing duplicate retail ordering, the authority - // predicate is rechecked after every callback boundary; - // a nested newer packet can never be overwritten by the - // tail of this older one. - AcDream.App.Physics.RemoteInboundMotionDispatchResult localDispatch = - _remoteInboundMotion.Apply( - update, - _playerController.Movement, - _playerController.Motion.DefaultSink, - _playerHost, - _playerController.CellId, - ae.Sequencer.CurrentMotion & 0xFF000000u, - IsCurrentLocalMotion); - if (localDispatch.Superseded - || !IsCurrentLocalMotion()) - { - return; - } - if (localDispatch.RoutedMoveTo) - { - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - Console.WriteLine(System.FormattableString.Invariant( - $"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={_playerController.Movement.IsMovingTo()} type={_playerController.MoveTo?.MovementTypeState}")); - } - return; - } - if (!localDispatch.AppliedInterpretedState) - return; - fullMotion = localDispatch.CurrentForwardCommand; - } - } - else - { - // One packet owner handles both PartArray-backed remotes and - // AP-77's animation-less body fallback. GameWindow performs - // only live-owner lookup and supplies the optional sink. - AcDream.App.Physics.RemoteInboundMotionDispatchResult dispatch = - DispatchRemoteInboundMotion( - update, - entity, - ae, - acceptedMotionRecord, - acceptedMovementAuthorityVersion, - acceptedMovementVelocityAuthorityVersion); - if (dispatch.Superseded - || dispatch.RoutedMoveTo - || !dispatch.AppliedInterpretedState) - return; - fullMotion = dispatch.CurrentForwardCommand; - } - - // Authoritative Dead motion invalidates a selected combat target. - // The controller clears shared selection, whose SelectionChanged - // consumer ports retail's post-clear AutoTarget behavior. - _combatTargetController?.OnMotionApplied( - update.Guid, ae.Sequencer.CurrentMotion); - if (!_liveEntities.IsCurrentMovementAuthority( - acceptedMotionRecord, - acceptedMovementAuthorityVersion) - || !_liveEntities.IsCurrentVelocityAuthority( - acceptedMotionRecord, - acceptedMovementVelocityAuthorityVersion)) - { - return; - } - - // CRITICAL: when we enter a locomotion cycle (Walk/Run/etc), - // stamp the remote observation timestamp to "now". Without this, - // the stop-detection loop in TickAnimations sees the previous - // observation timestamp (set by the last UpdatePosition, - // often >300ms ago during idle) and fires the stop signal - // IMMEDIATELY — flipping the sequencer straight back to Ready. - // The visible symptom was "remote char never animates; just - // stands there, teleporting position every UpdatePosition." - // Fresh timestamp gives the stop-timer a full 300ms window to - // observe genuine position stagnation before reverting. - uint newLo = fullMotion & 0xFFu; - bool enteringLocomotion = newLo == 0x05 || newLo == 0x06 - || newLo == 0x07 - || newLo == 0x0F || newLo == 0x10; - uint oldLo = priorMotion & 0xFFu; - bool wasLocomotion = oldLo == 0x05 || oldLo == 0x06 - || oldLo == 0x07 - || oldLo == 0x0F || oldLo == 0x10; - if (enteringLocomotion && !wasLocomotion && update.Guid != _playerServerGuid) - { - // Reset both stop signals so stop-detection starts a fresh - // window from this transition. Without this, the entity - // starts its run animation and is instantly interrupted. - var refreshedTime = System.DateTime.UtcNow; - if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev)) - _remoteMovementObservations[update.Guid] = (prev.Pos, refreshedTime); - if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr)) - dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds; - } - - return; - } - - // ── Legacy path (entities without a sequencer) ────────────────── - // Here we DO use GetIdleCycle because the legacy tick loop needs - // a concrete Animation + frame range. Only swap when the resolver - // returns a clearly-better cycle. - var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( - ae.Setup, _dats, _animLoader!, - motionTableIdOverride: null, - stanceOverride: stance, - commandOverride: command); - bool newCycleIsGood = newCycle is not null - && newCycle.Framerate != 0f - && newCycle.HighFrame >= newCycle.LowFrame - && newCycle.Animation.PartFrames.Count >= 1; - if (!newCycleIsGood) return; - - ae.Animation = newCycle!.Animation; - ae.LowFrame = Math.Max(0, newCycle.LowFrame); - ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1); - ae.Framerate = newCycle.Framerate; - ae.CurrFrame = ae.LowFrame; - } - - /// - /// Resolves one live remote owner and delegates retail's entire - /// unpack_movement body to the shared animation-optional packet - /// dispatcher. The render PartArray contributes only its optional sink. - /// - private AcDream.App.Physics.RemoteInboundMotionDispatchResult - DispatchRemoteInboundMotion( - AcDream.Core.Net.WorldSession.EntityMotionUpdate update, - AcDream.Core.World.WorldEntity entity, - LiveEntityAnimationState? ae, - LiveEntityRecord acceptedRecord, - ulong acceptedMovementAuthorityVersion, - ulong acceptedVelocityAuthorityVersion) - { - if (update.Guid == _playerServerGuid) - return default; - - bool IsCurrentOwner(RemoteMotion? expectedRemote = null) => - _liveEntities is { } live - && live.IsCurrentMovementAuthority( - acceptedRecord, - acceptedMovementAuthorityVersion) - && live.IsCurrentVelocityAuthority( - acceptedRecord, - acceptedVelocityAuthorityVersion) - && ReferenceEquals(acceptedRecord.WorldEntity, entity) - && (ae is null - ? acceptedRecord.AnimationRuntime is null - : ReferenceEquals(acceptedRecord.AnimationRuntime, ae)) - && (expectedRemote is null - || ReferenceEquals( - acceptedRecord.RemoteMotionRuntime, - expectedRemote)); - if (!IsCurrentOwner()) - return default; - - if (!_remoteDeadReckon.TryGetValue(update.Guid, out var remote)) - { - remote = CreateRemoteMotion(update.Guid); - remote.Body.Orientation = entity.Rotation; - remote.Body.Position = entity.Position; - _remoteDeadReckon[update.Guid] = remote; - } - if (!IsCurrentOwner(remote)) - return default; - - var sink = EnsureRemoteMotionBindings(remote, ae, update.Guid); - uint commandClass = ae?.Sequencer?.CurrentMotion & 0xFF000000u - ?? remote.Motion.InterpretedState.ForwardCommand & 0xFF000000u; - if (commandClass == 0u) - commandClass = 0x41000000u; - - AcDream.App.Physics.RemoteInboundMotionDispatchResult result = - _remoteInboundMotion.Apply( - update, - remote.Movement, - sink, - remote.Host, - remote.CellId, - commandClass, - () => IsCurrentOwner(remote)); - - if (result.Superseded || !IsCurrentOwner(remote)) - return result with { Superseded = true }; - - if (result.ForwardCommandChanged) - { - if (System.Environment.GetEnvironmentVariable( - "ACDREAM_REMOTE_VEL_DIAG") == "1") - { - System.Console.WriteLine( - $"[FWD_WIRE] guid={update.Guid:X8} " - + $"oldCmd=0x{result.PreviousForwardCommand:X8} " - + $"newCmd=0x{result.CurrentForwardCommand:X8} " - + $"newLow=0x{result.CurrentForwardCommand & 0xFFu:X2} " - + $"speed={update.MotionState.ForwardSpeed ?? 1f:F3}"); - } - remote.PrevServerPosTime = 0.0; - } - - if (result.AppliedInterpretedState && ae is null) - { - _combatTargetController?.OnMotionApplied( - update.Guid, - result.CurrentForwardCommand); - if (!IsCurrentOwner(remote)) - return result with { Superseded = true }; - } - return result; - } - - /// - /// Phase 6.7: the server says an entity moved. Translate its new - /// landblock-local position into acdream world space (same math as - /// CreateObject hydration) and update the entity's Position/Rotation - /// in place so the next Draw picks up the new transform. - /// - /// Phase B.3 extension: if the player controller is in PortalSpace and - /// this update is for our own character, detect a large position change - /// (different landblock or > 100 units distance). If detected, recenter - /// the streaming controller, resolve the new position through physics, - /// snap the player entity + controller, and return to InWorld. Also sends - /// LoginComplete so the server knows the client has loaded the destination. - /// - /// - /// Reports whether the exact remote component currently belongs to the - /// visible ordinary-object workset that consumes interpolation targets. - /// - private bool WillAdvanceRemoteMotion(uint serverGuid, RemoteMotion remote) - { - return _liveEntities is { } runtime - && runtime.TryGetRecord(serverGuid, out LiveEntityRecord record) - && ReferenceEquals(record.RemoteMotionRuntime, remote) - && (record.FinalPhysicsState - & AcDream.Core.Physics.PhysicsStateFlags.Static) == 0 - && runtime.GetRootObjectClockDisposition(serverGuid) - is AcDream.Core.Physics.RetailObjectClockDisposition.Advance - && runtime.IsCurrentSpatialRemoteMotion(record, remote); - } - - /// - /// Creates the optional MovementManager companion for one live object. - /// A retained projectile or static-animation owner contributes the - /// already-owned CPhysicsObj body; an ordinary remote gets local storage. - /// - private RemoteMotion CreateRemoteMotion(uint serverGuid) - { - LiveEntityRecord? record = null; - if (_liveEntities is { } liveEntities - && liveEntities.TryGetRecord(serverGuid, out var currentRecord)) - { - record = currentRecord; - } - - RemoteMotion remote; - if (record?.PhysicsBody is { } canonicalBody) - { - // Retail has one CPhysicsObj. A projectile or Physics-Static - // animation workset can create it before the MovementManager; - // adopt its current frame/vectors without replaying CreateObject. - remote = new RemoteMotion(canonicalBody); - } - else - { - remote = new RemoteMotion(); - if (record is not null) - AcDream.App.Physics.RemotePhysicsBodyInitializer.Initialize( - remote.Body, - record); - } - - RemoteMotion incarnation = remote; - remote.Movement.ActivatePhysicsObject = () => - _liveEntities?.TryActivateOrdinaryObject(serverGuid, incarnation); - return remote; - } - - /// - /// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate from remote jumps. - /// The payload seeds the world-space launch velocity and angular velocity. - /// - private void OnLiveVectorUpdated(AcDream.Core.Net.Messages.VectorUpdate.Parsed update) - { - if (_projectileController?.CanAcceptVectorPayload( - update.Guid, - update.Velocity, - update.Omega) == false) - return; - if (!_liveEntities!.TryApplyVector(update, out _)) return; - if (!_liveEntities.TryGetRecord( - update.Guid, - out LiveEntityRecord acceptedVectorRecord)) - { - return; - } - ulong acceptedVectorAuthorityVersion = - acceptedVectorRecord.VectorAuthorityVersion; - ulong acceptedVectorVelocityAuthorityVersion = - acceptedVectorRecord.VelocityAuthorityVersion; - - if (_projectileController?.ApplyAuthoritativeVector( - acceptedVectorRecord, - acceptedVectorAuthorityVersion, - acceptedVectorVelocityAuthorityVersion, - update.Velocity, - update.Omega, - _physicsScriptGameTime) == true) - return; - - // A Physics-Static animation owner can own the canonical CPhysicsObj - // before any MovementManager exists. F74E still writes directly to - // that object's vectors; do not manufacture a remote-motion runtime - // merely to keep animate_static_object's omega current. - if (update.Guid != _playerServerGuid - && acceptedVectorRecord.RemoteMotionRuntime is null - && acceptedVectorRecord.PhysicsBody is { } canonicalBody) - { - _liveEntities.TryCommitAuthoritativeVector( - acceptedVectorRecord, - canonicalBody, - update.Velocity, - update.Omega, - _physicsScriptGameTime); - return; - } - - if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return; - - if (update.Guid == _playerServerGuid) return; // local jump uses our own physics - if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rm)) return; - LiveEntityRecord remoteRecord = acceptedVectorRecord; - - // World-space velocity. Apply directly to the body — the per-tick - // remote update will integrate Position += Velocity × dt + 0.5 × Accel × dt². - // L.3.1 Task 6: apply Omega too. LiveEntityRuntime commits both - // writes to the one canonical CPhysicsObj and wakes its retained - // update_time clock on the same non-Static edge. - if (!_liveEntities.TryCommitAuthoritativeVector( - remoteRecord, - rm.Body, - update.Velocity, - update.Omega, - _physicsScriptGameTime)) - { - return; - } - - // Mark airborne when the launch has meaningful +Z. Threshold - // 0.5 m/s rejects noise / horizontal-only updates (server might - // also use VectorUpdate for non-jump events). The per-tick - // remote update reads .Airborne to skip the ground-clamp branch - // and apply gravity instead. - if (update.Velocity.Z > 0.5f) - { - rm.Airborne = true; - // Clear ground-contact bits + enable gravity so calc_acceleration - // returns (0, 0, -9.8) instead of zero. UpdatePhysicsInternal then - // produces the parabolic arc. - rm.Body.TransientState &= ~(AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable); - rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity; - - // R3-W4 (J19 — K-fix10/K-fix18 DELETED): the retail mechanism. - // The remote's ground departure fires LeaveGround (0x00528b00): - // strips pending transition links (the RemoveLinkAnimations - // seam) + re-applies movement through DefaultSink, whose - // contact-gated funnel dispatch engages Falling — no forced - // SetCycle, no skip flag. The wire velocity/omega are re-applied - // AFTER so they stay authoritative over LeaveGround's - // state-derived velocity write (adaptation note: retail's - // equivalence comes from the per-tick transition-sweep order — - // R6 scope). - if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent) - && _animatedEntities.TryGetValue(ent.Id, out var ae) - && ae.Sequencer is not null) - { - EnsureRemoteMotionBindings(rm, ae, update.Guid); - rm.Motion.LeaveGround(); - if (!_liveEntities.IsCurrentVectorAuthority( - remoteRecord, - acceptedVectorAuthorityVersion) - || !_liveEntities.IsCurrentVelocityAuthority( - remoteRecord, - acceptedVectorVelocityAuthorityVersion) - || !_liveEntities.TryCommitAuthoritativeVector( - remoteRecord, - rm.Body, - update.Velocity, - update.Omega, - _physicsScriptGameTime)) - { - return; - } - } - } - - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - { - Console.WriteLine( - $"VU guid=0x{update.Guid:X8} vel=({update.Velocity.X:F2},{update.Velocity.Y:F2},{update.Velocity.Z:F2}) airborne={rm.Airborne}"); - } - } - - /// - /// L.2g slice 1: inbound SetState (0xF74B) handler. Propagates the - /// new PhysicsState bits into ShadowObjectRegistry so the - /// existing check honors - /// the flip on the next resolver tick. Chiefly doors: - /// server flips ETHEREAL_PS = 0x4 on Use, the door's - /// cylinder collision stops blocking the threshold. - /// - private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed) - { - if (!_liveEntities!.TryApplyState(parsed, out _, out _)) return; - - if (!_liveEntities.TryGetRecord(parsed.Guid, out LiveEntityRecord record)) - return; - ulong acceptedStateAuthorityVersion = record.StateAuthorityVersion; - - // Retail set_state order: Lighting, NoDraw, then Hidden. The live - // runtime already committed the raw/final bits and draw visibility; - // apply the ordered owners before updating motion/collision consumers. - _liveEntityLights?.OnStateChanged(parsed.Guid); - _liveEntityPresentation?.OnStateAccepted(parsed.Guid); - - if (!_liveEntities.IsCurrentStateAuthority( - record, - acceptedStateAuthorityVersion)) - { - return; - } - - _projectileController?.ApplyAuthoritativeState( - record, - acceptedStateAuthorityVersion, - record.FinalPhysicsState, - _physicsScriptGameTime, - _liveCenterX, - _liveCenterY); - if (!_liveEntities.IsCurrentStateAuthority( - record, - acceptedStateAuthorityVersion)) - { - return; - } - if (parsed.Guid == _playerServerGuid) - _playerController?.ApplyPhysicsState(record.FinalPhysicsState); - - if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return; - - // L.2g slice 1c (2026-05-13): the server addresses entities by - // ServerGuid (parsed.Guid, e.g. 0x7A9B4015), but - // ShadowObjectRegistry's cell index is keyed by local entity.Id - // (e.g. 0x000F4245). Translate via _entitiesByServerGuid before - // mutating the registry — otherwise the lookup misses and the - // state flip silently no-ops, leaving doors blocked even though - // ACE flipped the ETHEREAL bit. - uint registryKey = entity.Id; - - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) - Console.WriteLine(System.FormattableString.Invariant( - $"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} raw=0x{parsed.PhysicsState:X8} final=0x{(uint)record.FinalPhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}")); - } - - private void ApplyServerControlledVelocityCycle( - uint serverGuid, - LiveEntityAnimationState ae, - RemoteMotion rm, - System.Numerics.Vector3 velocity) - { - if (rm.Airborne) return; - if (ae.Sequencer is null) return; - // R4-V4: an active MoveToManager owns the cycle (BeginMoveForward's - // get_command dispatch). Velocity-estimated cycle planning would - // fight it — same rationale as the pre-V4 ServerMoveToActive guard. - if (rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }) return; - - if (IsPlayerGuid(serverGuid)) - { - // L.2g S5 (2026-07-02, DEV-2 DELETED): player remotes get NO - // pace-derived cycle refinement — retail has no such mechanism - // anywhere in its inbound pipeline (deviation map DEV-2, two - // independent decomp dives + ACE cross-check). The #39-era - // premise ("retail's outbound goes silent on Shift toggle") was - // refuted at all three oracles + the S0 live capture: retail - // sends a fresh MoveToState on HoldRun toggle while moving - // (CommandInterpreter 0x006b37a8 → SendMovementEvent), ACE - // rebroadcasts every MoveToState (GameActionMoveToState.cs:36), - // and the wire shows explicit 0x0005↔0x0007 UMs on each toggle - // (launch-s0-wireprobe.log). The refinement layer's re-promote - // after legitimate flags=0 Ready UMs was itself the observed - // Ready↔Run thrash. Cycle changes for player remotes come from - // UpdateMotion ONLY, exactly like retail; position error is the - // InterpolationManager chase's job. - // - // NPC/monster remotes below keep PlanFromVelocity until S6 - // unifies all entity classes onto the CMotionInterp funnel. - return; - } - - uint currentMotion = ae.Sequencer.CurrentMotion; - // AP-80 is a position-only compatibility adaptation, not an - // animation authority. Restrict it to Ready/Walk/Run so late death - // position deltas cannot replace the authoritative Dead substate. - if (!AcDream.Core.Physics.ServerControlledLocomotion - .CanApplyVelocityCycle(currentMotion)) - return; - - var plan = AcDream.Core.Physics.ServerControlledLocomotion - .PlanFromVelocity(velocity); - - uint style = ae.Sequencer.CurrentStyle != 0 - ? ae.Sequencer.CurrentStyle - : 0x8000003Du; - - // D2 (Commit A 2026-05-03): UPCYCLE diag — proves whether - // ApplyServerControlledVelocityCycle is racing UpdateMotion-driven - // SetCycle for non-player remotes (NPCs / monsters). - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") - { - System.Console.WriteLine( - $"[UPCYCLE] guid={serverGuid:X8} " - + $"vel=({velocity.X:F2},{velocity.Y:F2},{velocity.Z:F2}) " - + $"|v|={velocity.Length():F2} " - + $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} " - + $"prev=0x{currentMotion:X8} " - + $"airborne={rm.Airborne} moveTo={rm.MoveTo?.MovementTypeState ?? AcDream.Core.Physics.MovementType.Invalid}"); - } - ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod); - } - - /// - /// Retail's local FORCE_POSITION branch calls SendPositionEvent - /// immediately after BlipPlayer. This acknowledgement uses the accepted - /// timestamps and preserves the player's current heading. - /// - private void SendImmediateLocalPositionEvent() - { - AcDream.Core.Net.WorldSession? session = LiveSession; - if (session is null - || _playerController is null - || !_playerController.CanSendPositionEvent) - { - return; - } - - AcDream.Core.Physics.Position canonical = _playerController.CellPosition; - uint cellId = canonical.ObjCellId; - System.Numerics.Vector3 position = canonical.Frame.Origin; - System.Numerics.Quaternion rotation = _playerController.BodyOrientation; - if (!AcDream.Core.Physics.PositionFrameValidation.IsValid( - cellId, position, rotation)) - { - return; - } - - uint sequence = session.NextGameActionSequence(); - var body = AcDream.Core.Net.Messages.AutonomousPosition.Build( - gameActionSequence: sequence, - cellId: cellId, - position: position, - rotation: rotation, - instanceSequence: session.InstanceSequence, - serverControlSequence: session.ServerControlSequence, - teleportSequence: session.TeleportSequence, - forcePositionSequence: session.ForcePositionSequence, - lastContact: 1); - session.SendGameAction(body); - _playerController.NotePositionSent( - new AcDream.Core.Physics.Position(cellId, position, rotation), - _playerController.ContactPlane, - _playerController.SimTimeSeconds); - } - - private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) - { - if (_projectileController?.CanAcceptPositionPayload( - update.Guid, - update.Position, - update.Velocity) == false) - return; - bool known = _liveEntities!.TryApplyPosition( - update, - isLocalPlayer: update.Guid == _playerServerGuid, - forcePositionRotation: update.Guid == _playerServerGuid && _playerController is not null - ? _playerController.BodyOrientation - : null, - currentLocalVelocity: update.Guid == _playerServerGuid && _playerController is not null - ? _playerController.BodyVelocity - : null, - out var timestampDisposition, - out var acceptedSpawn, - out var timestamps); - if (!known) - { - // Preserve the local streaming observer hint, but do not consume - // a per-object timestamp until a live object can receive the - // packet. Retail queues the full blob in this case (AD-32). - if (update.Guid == _playerServerGuid) - _lastLivePlayerLandblockId = update.Position.LandblockId; - return; - } - - PublishLocalPhysicsTimestamps(update.Guid, timestamps); - - if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Rejected) - return; - if (!_liveEntities.TryGetRecord( - update.Guid, - out LiveEntityRecord acceptedPositionRecord)) - { - return; - } - ulong acceptedPositionAuthorityVersion = - acceptedPositionRecord.PositionAuthorityVersion; - ulong acceptedPositionVelocityAuthorityVersion = - acceptedPositionRecord.VelocityAuthorityVersion; - bool IsCurrentPositionOwner( - AcDream.Core.World.WorldEntity? expectedEntity = null) => - _liveEntities.IsCurrentPositionAuthority( - acceptedPositionRecord, - acceptedPositionAuthorityVersion) - && (expectedEntity is null - || ReferenceEquals( - acceptedPositionRecord.WorldEntity, - expectedEntity)); - if (!IsCurrentPositionOwner()) - return; - - // A PlayerDescription/CreateObject may establish the live record - // without either Position or enough render data. Bind streaming - // readiness directly to this first accepted canonical Position before - // translating it through the current world origin; projection recovery - // below is a separate concern and may never be needed for UI-only state. - if (_liveEntityHydration?.EnsureWorldOrigin( - acceptedPositionRecord, - acceptedPositionAuthorityVersion, - acceptedSpawn) != true - || !IsCurrentPositionOwner()) - return; - - var p = update.Position; - int lbX = (int)((p.LandblockId >> 24) & 0xFFu); - int lbY = (int)((p.LandblockId >> 16) & 0xFFu); - var origin = new System.Numerics.Vector3( - (lbX - _liveCenterX) * 192f, - (lbY - _liveCenterY) * 192f, - 0f); - var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin; - - bool forceLocal = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition - && update.Guid == _playerServerGuid - && _playerController is not null; - if (forceLocal) - { - if (!IsCurrentPositionOwner()) - return; - var cellLocal = new System.Numerics.Vector3( - p.PositionX, p.PositionY, p.PositionZ); - _playerController!.BlipPosition(worldPos, p.LandblockId, cellLocal); - SendImmediateLocalPositionEvent(); - if (!IsCurrentPositionOwner()) - return; - } - - if (!_entitiesByServerGuid.ContainsKey(update.Guid)) - { - if (!IsCurrentPositionOwner()) - return; - AcDream.App.Rendering.ChildUnparentDisposition unparented = - _equippedChildRenderer?.OnChildBecameUnparented( - update.Guid, - () => - { - if (!IsCurrentPositionOwner()) - return; - _liveEntityHydration!.RecoverProjection( - acceptedPositionRecord, - acceptedPositionAuthorityVersion, - acceptedSpawn); - }) - ?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached; - if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.Superseded - or AcDream.App.Rendering.ChildUnparentDisposition.Pending) - return; - if (!IsCurrentPositionOwner()) - return; - if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.NotAttached) - { - _liveEntityHydration!.RecoverProjection( - acceptedPositionRecord, - acceptedPositionAuthorityVersion, - acceptedSpawn); - if (!IsCurrentPositionOwner()) - return; - } - } - - if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; - if (!IsCurrentPositionOwner(entity)) - return; - _entityEffects?.MarkLiveOwnerPoseDirty(update.Guid); - if (!IsCurrentPositionOwner(entity)) - return; - - // Phase A.1 / #135: track the PLAYER's last server-known landblock so the - // streaming controller can follow the player in the fly-camera / pre-player-mode - // (login hold) views. Filtered to our OWN character guid — resolving the original - // Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock - // must NOT move the streaming observer: during a dungeon-login hold (player not - // yet placed, so _playerController is null and the PortalSpace observer branch - // can't apply) that would drift the observer off the pre-collapsed dungeon - // landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window - // the pre-collapse just suppressed. _playerServerGuid is set from CharacterList - // (~line 1984) before world entry, so it is valid by the time updates arrive. - if (update.Guid == _playerServerGuid) - _lastLivePlayerLandblockId = update.Position.LandblockId; - - // B.6 slice 1 (2026-05-14): trace inbound UpdatePosition cadence for - // the local player. Combined with [autowalk-mt] this answers - // whether ACE's broadcast frequency during a server-initiated - // auto-walk is dense enough to drive smooth visible motion (the - // Option C viability check from the design spec). Gated on - // ACDREAM_PROBE_AUTOWALK=1; skips remote entities. - if (update.Guid == _playerServerGuid - && AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - string velStr = update.Velocity is { } v - ? $"vel=({v.X:F2},{v.Y:F2},{v.Z:F2})" - : "vel=null"; - Console.WriteLine(System.FormattableString.Invariant( - $"[autowalk-up] cell=0x{p.LandblockId:X8} pos=({p.PositionX:F2},{p.PositionY:F2},{p.PositionZ:F2}) world=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2}) {velStr} grounded={update.IsGrounded}")); - } - var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition - ? entity.Rotation - : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); - DumpMovementTruthServerEcho(update, worldPos); - - bool remoteHardTeleport = update.Guid != _playerServerGuid - && timestamps.TeleportHookRequired; - bool remotePlacementRequired = update.Guid != _playerServerGuid - && (remoteHardTeleport - || _remoteTeleportController?.HasPending(update.Guid) == true); - if (remoteHardTeleport) - { - if (!RunRemoteTeleportHook( - update.Guid, - entity.Id, - () => IsCurrentPositionOwner(entity))) - { - return; - } - } - - // Missiles reconcile the same predicted PhysicsBody in place. The - // timestamp gate above already rejected stale corrections; returning - // here prevents the generic remote locomotion path from allocating a - // second body or interpolation owner for the projectile. - if (_projectileController?.ApplyAuthoritativePosition( - acceptedPositionRecord, - acceptedPositionAuthorityVersion, - acceptedPositionVelocityAuthorityVersion, - worldPos, - new System.Numerics.Vector3( - p.PositionX, - p.PositionY, - p.PositionZ), - rot, - acceptedSpawn.Physics?.Velocity - ?? System.Numerics.Vector3.Zero, - p.LandblockId, - _physicsScriptGameTime, - _liveCenterX, - _liveCenterY) == true) - return; - - if (!_liveEntities.TryGetRecord( - update.Guid, - out LiveEntityRecord positionRecord) - || !ReferenceEquals(positionRecord, acceptedPositionRecord) - || !ReferenceEquals(positionRecord.WorldEntity, entity) - || !_liveEntities.IsCurrentPositionAuthority( - positionRecord, - acceptedPositionAuthorityVersion)) - { - return; - } - - // Capture the pre-update render position for the soft-snap residual - // calculation below. Assign entity.Position to the server truth up - // front; if we then compute a snap residual, we restore the rendered - // position by adding the residual back (so the visual doesn't jerk - // for one frame before the residual decay kicks in on the next tick). - System.Numerics.Vector3 preSnapPos = entity.Position; - entity.SetPosition(worldPos); - entity.ParentCellId = p.LandblockId; - entity.Rotation = rot; - if (!_liveEntities!.RebucketLiveEntity(update.Guid, p.LandblockId) - || !_liveEntities.TryGetRecord( - update.Guid, - out LiveEntityRecord afterRebucket) - || !ReferenceEquals(afterRebucket, positionRecord) - || !ReferenceEquals(afterRebucket.WorldEntity, entity) - || !_liveEntities.IsCurrentPositionAuthority( - afterRebucket, - acceptedPositionAuthorityVersion)) - { - // A projection callback superseded or deleted this incarnation. - // Never let an older UpdatePosition seed the replacement's - // placement, interpolation, or collision state. - return; - } - - if (remotePlacementRequired) - { - _remoteTeleportController!.BeginPlacement( - update.Guid, - acceptedSpawn.InstanceSequence); - if (!IsCurrentPositionOwner(entity)) - return; - } - - // Commit B 2026-04-29 — keep the shadow registry in sync with - // server-authoritative position so the player's collision broadphase - // tests against the up-to-date target body. Skip the local player - // (its body is the simulator, not a target). Retail does the - // equivalent via SetPosition → change_cell → AddShadowObject - // (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862). - // #184 Slice 2b: the former players-only RAW-pos shadow sync is RETIRED. - // It was a Slice-1 stopgap while grounded player remotes (old Path A) skipped - // the sweep and tracked the server position closely. Now that Slice 2b runs - // the SAME per-tick sweep + shadow-follows-resolved for players, writing the - // raw (overlapping) server pos here would re-snap a packed player's shadow - // into overlap once per UP and fight the in-tick de-overlap (research - // finding 9). Player shadows now follow the RESOLVED body — via the DR-tick - // loop (SyncRemoteShadowToBody, pose/cell-gated) and the player UP-branch tail - // below (first-UP / no-Sequencer case), exactly like NPCs. Local-player - // broadphase still tests an up-to-date remote shadow; it is just the resolved - // body now, not the raw wire pos. - -// Track remote-entity motion for stop detection. Only record the - // timestamp when position moved MEANINGFULLY (> 0.05m). Updates - // that report the same position keep the old Time, so the - // TickAnimations check can see when motion last changed. - // - // Also populate the dead-reckon state so TickAnimations can - // integrate velocity between server updates and avoid teleport jitter. - // Observed-velocity is computed from the position delta across - // consecutive updates — this is the fallback when the motion table's - // MotionData.Velocity is zero (NPCs without HasVelocity). - if (update.Guid != _playerServerGuid) - { - var now = System.DateTime.UtcNow; - if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev)) - { - float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos); - if (moveDist > 0.05f) - _remoteMovementObservations[update.Guid] = (worldPos, now); - // else: leave old entry so "Time" = last real movement time - } - else - { - _remoteMovementObservations[update.Guid] = (worldPos, now); - } - - // Retail-faithful hard-snap on UpdatePosition. - // Decompile: FUN_00559030 @ chunk_00550000.c:8232 writes - // pos/rot directly into PhysicsObj+0x80..0xBC with no blending. - // Between UpdatePositions, per-tick velocity integration keeps - // the rendered position close to server truth so each snap is - // small. When HasVelocity is set, we also seed PhysicsBody - // velocity (matches retail's set_velocity call in the same - // dispatcher). - if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rmState)) - { - rmState = CreateRemoteMotion(update.Guid); - _remoteDeadReckon[update.Guid] = rmState; - // Hard-snap orientation on first spawn so the per-tick - // slerp doesn't visibly rotate from Identity to truth. - rmState.Body.Orientation = rot; - // #184 Slice 2b: PLACE the body at the server position on creation, - // mirroring the UM handler's seed (:5176 `Body.Position = - // entity.Position`). A UP-first RemoteMotion (created here before any - // UM) was left at the default (0,0,0). Path A never swept, so that - // stale origin was harmless — it caught up gradually. Now that Slice - // 2b runs the sweep for grounded PLAYERS too, an unplaced body would - // sweep from (0,0,0) in the server cell that does not contain it → - // garbage resolved pos → the digest's INVISIBLE/misplaced-body bug. - // Seeding here is the root-cause fix (the UP creation path should - // seed exactly like the UM path); worldPos == entity.Position (the - // unconditional snap at the top of this handler already ran). - rmState.Body.Position = worldPos; - } - - // PositionPack::UnPack initializes an absent velocity to zero; - // MoveOrTeleport installs that exact vector with set_velocity. - // The canonical seam wakes the retained ObjectClock and body in - // one operation. Position-delta velocity below remains animation - // diagnostics and is never substituted into physics. - if (!_liveEntities.IsCurrentPositionAuthority( - positionRecord, - acceptedPositionAuthorityVersion)) - { - return; - } - if (_liveEntities.IsCurrentVelocityAuthority( - positionRecord, - acceptedPositionVelocityAuthorityVersion) - && !_liveEntities.TryCommitAuthoritativeVelocity( - positionRecord, - rmState.Body, - acceptedSpawn.Physics?.Velocity - ?? System.Numerics.Vector3.Zero, - _physicsScriptGameTime)) - { - return; - } - - // Retail CPhysicsObj::MoveOrTeleport Branch A (0x00516330): a - // fresh TELEPORT_TS, or the first placement of a cell-less body, - // runs teleport_hook and SetPosition(0x1012) BEFORE the contact - // test. In particular, an airborne UP cannot veto or undo this - // authoritative destination. Do not pre-clear velocity or invent - // grounded flags here: retail SetPosition derives contact from its - // transition, while MoveOrTeleport does not consume arg5/arg6. - if (remotePlacementRequired) - { - double teleportTime = - (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - bool projectionVisible = _liveEntities.TryGetRecord( - update.Guid, - out LiveEntityRecord teleportRecord) - && teleportRecord.IsSpatiallyVisible; - var placement = _remoteTeleportController!.TryApply( - positionRecord, - acceptedPositionAuthorityVersion, - acceptedPositionVelocityAuthorityVersion, - rmState, - entity, - worldPos, - p.LandblockId, - new System.Numerics.Vector3( - p.PositionX, - p.PositionY, - p.PositionZ), - rot, - teleportTime, - projectionVisible, - acceptedSpawn.InstanceSequence, - acceptedSpawn.PositionSequence); - if (placement.Superseded - || !IsCurrentPositionOwner(entity)) - { - return; - } - if (!placement.Applied) - { - entity.SetPosition(rmState.Body.Position); - entity.ParentCellId = rmState.CellId; - entity.Rotation = rmState.Body.Orientation; - if (rmState.CellId != 0) - _liveEntities.RebucketLiveEntity(update.Guid, rmState.CellId); - return; - } - - if (!IsCurrentPositionOwner(entity)) - return; - entity.SetPosition(rmState.Body.Position); - entity.Rotation = rmState.Body.Orientation; - return; - } - - // L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for - // player remotes. Mirrors CPhysicsObj::MoveOrTeleport - // (acclient @ 0x00516330) — airborne no-op, far-snap, near - // InterpolateTo. Gated on IsPlayerGuid so NPCs continue through - // the legacy synth-velocity branch below; their motion comes - // from ServerVelocity / ServerMoveTo which the legacy path - // already handles correctly. - // - if (IsPlayerGuid(update.Guid)) - { - // InterpolationManager retains the complete target Position. - // A near correction replaces both origin and orientation via - // Position::subtract2; only placement/far branches snap here. - // Adopt server's cell ID on every UP (airborne or grounded). - // Required by the legacy airborne path's per-tick - // ResolveWithTransition gate (rm.CellId != 0); without this - // an airborne player remote falls through the floor because - // the sphere sweep is skipped. Note: enabling the sweep also - // exposes a pre-existing depenetration bug — see #42. - rmState.CellId = p.LandblockId; - - // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous - // server-pos snapshot forward AND print the per-UP comparison - // between the max literal CSequence root-motion speed observed - // since the last UP and the actual server broadcast pace. Both are sampled - // over the same window so the ratio reflects real overshoot. - { - double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" - && rmState.LastServerPosTime > 0.0) - { - double dtServer = nowSecDiag - rmState.LastServerPosTime; - if (dtServer > 0.001) - { - var serverDelta = worldPos - rmState.LastServerPos; - float serverSpeed = (float)(serverDelta.Length() / dtServer); - float rootMotionSpeed = rmState.MaxRootMotionSpeedSinceLastUP; - if (serverSpeed > 0.1f || rootMotionSpeed > 0.1f) - { - System.Console.WriteLine( - $"[VEL_DIAG] guid={update.Guid:X8} maxRootMotionSpeed={rootMotionSpeed:F3} m/s " - + $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s " - + $"ratio={(serverSpeed > 1e-3f ? rootMotionSpeed / serverSpeed : 0f):F3}"); - } - } - } - rmState.MaxRootMotionSpeedSinceLastUP = 0f; - rmState.PrevServerPos = rmState.LastServerPos; - rmState.PrevServerPosTime = rmState.LastServerPosTime; - rmState.LastServerPos = worldPos; - rmState.LastServerPosTime = nowSecDiag; - } - - // ── AIRBORNE NO-OP ──────────────────────────────────────────── - // Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330): - // when has_contact==0, return false (don't touch body, don't queue). - // body.Velocity (set once by OnLiveVectorUpdated at jump start) keeps - // integrating gravity via per-frame UpdatePhysicsInternal. Server is - // authoritative for the arc; we don't predict it locally. - if (!update.IsGrounded) - { - // Undo the unconditional entity hard-snap at the top of the - // function (entity.SetPosition(worldPos)): the body is mid-arc - // and TickAnimations will write entity = body next frame - // anyway. Setting entity = body now prevents a 1-frame - // teleport-to-server-then-yank-back rubber-band. - entity.SetPosition(rmState.Body.Position); - return; - } - - // ── LANDING TRANSITION ──────────────────────────────────────── - // First IsGrounded=true UP after rmState.Airborne signals landed. - // Clear airborne flags, hard-snap to authoritative landing position, - // clear interpolation queue (any pre-jump waypoints are stale). - if (rmState.Airborne) - { - rmState.Airborne = false; - rmState.Body.Velocity = System.Numerics.Vector3.Zero; - rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable; - rmState.Interp.Clear(); - rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; - - // #161: retail landing = MovementManager::HitGround - // (minterp → moveto, 0x00524300 — the R5-V5 facade - // relay) with the Gravity state bit STILL SET - // (CMotionInterp::HitGround gates on state&0x400). The - // re-apply dispatches the PRESERVED pre-fall forward - // command → landing link → cycle. This replaces the - // forced SetCycle, which read the then-clobbered - // ForwardCommand (Falling) and re-set the pose it meant - // to clear. See the twin block in TickAnimations - // (VU.land). - if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand) - && aeForLand.Sequencer is not null) - { - EnsureRemoteMotionBindings(rmState, aeForLand, update.Guid); - } - ulong landingStateAuthorityVersion = - positionRecord.StateAuthorityVersion; - rmState.Movement.HitGround(); - if (!IsCurrentPositionOwner(entity) - || !ReferenceEquals( - positionRecord.RemoteMotionRuntime, - rmState)) - { - return; - } - // DR bookkeeping only (partner of the jump-start - // `State |= Gravity`). - if (_liveEntities.IsCurrentStateAuthority( - positionRecord, - landingStateAuthorityVersion)) - { - rmState.Body.State &= - ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; - } - return; - } - - // ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ──────────── - const float MaxPhysicsDistance = 96f; // retail player_distance far-snap - const float BodySnapThreshold = 4f; // large correction / teleport / unplaced -> snap - var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero; - float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos); - // #184 Slice 2b: the player UP routing gains the SAME placement-snap - // backstop the NPC routing got in Slice 1 (AP-87). Now that grounded - // PLAYER remotes run the sweep, an unplaced / stale-cell body — a - // UM-first RemoteMotion seeded to the spawn pos (:5176) then a first UP - // in a DIFFERENT cell, which the UP-creation seed (:5720) does NOT cover - // — would enqueue and the per-tick sweep would run from a cell that does - // not contain the body -> garbage resolved pos -> the digest's - // INVISIBLE/misplaced player. The 4 m bodyToTarget guard is the - // LOAD-BEARING backstop (AP-87; firstUp via LastServerPosTime is a poor - // signal for players — it is already set by the VEL_DIAG block above); - // !willBeDrTicked snaps a no-Sequencer player whose queue nothing would - // consume; dist>96 is retail's far-snap. Placed + near corrections still - // enqueue for the smooth catch-up. - float bodyToTarget = System.Numerics.Vector3.Distance( - rmState.Body.Position, worldPos); - bool willBeDrTicked = WillAdvanceRemoteMotion( - update.Guid, - rmState); - - if (dist > MaxPhysicsDistance || !willBeDrTicked - || bodyToTarget > BodySnapThreshold) - { - // Beyond view bubble / large correction / unplaced body: - // SetPositionSimple slide-snap. Clear queue. - rmState.Interp.Clear(); - rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; - } - else - { - // Within view bubble, placed + near: enqueue waypoint for - // adjust_offset to walk to. The per-frame TickAnimations player- - // remote path drives the actual body advancement via - // InterpolationManager.AdjustOffset. Pass body's current position so - // the InterpolationManager can detect a far-distance enqueue (>100 m - // from body) and pre-arm an immediate blip. - System.Numerics.Quaternion? immediateOrientation = - rmState.Interp.Enqueue( - worldPos, - rot, - isMovingTo: rmState.Movement.IsMovingTo(), - currentBodyPosition: rmState.Body.Position, - currentBodyOrientation: rmState.Body.Orientation); - if (immediateOrientation is { } closeOrientation) - rmState.Body.Orientation = closeOrientation; - } - // Track the UP-derived synth velocity for diagnostics - // ([VEL_DIAG] pace comparison). L.2g S5 (2026-07-02): the - // #39-era cycle-refinement call that used to live here is - // DELETED — retail never adapts a remote's animation from - // observed pace (deviation map DEV-2; premise refuted at - // decomp + ACE source + the S0 live capture, which shows - // explicit 0x0005↔0x0007 UMs on every Shift toggle). - // Player-remote cycles are UM-driven only. - if (rmState.PrevServerPosTime > 0.0) - { - double nowSecVel = rmState.LastServerPosTime; - double dtPos = nowSecVel - rmState.PrevServerPosTime; - if (dtPos > 0.001) - { - var synthVel = (worldPos - rmState.PrevServerPos) / (float)dtPos; - rmState.ServerVelocity = synthVel; - rmState.HasServerVelocity = true; - } - } - - // Sync the visible entity to the body — overrides the unconditional - // entity.SetPosition(worldPos) snap at the top of this function. - // For the far-snap branch this is a no-op (body == worldPos); for - // the near-enqueue branch this prevents a 1-frame teleport-then- - // yank-back rubber-band as TickAnimations chases worldPos via the - // queue. - // - // #184 Slice 2b: sync the player-remote shadow to the RESOLVED/placed - // body (mirrors the NPC UP-branch tail). Now that grounded players run - // the DR-tick sweep + shadow-follows-resolved, the retired raw-pos sync - // (top of this handler) would have re-snapped the shadow into overlap - // each UP; this keeps collision == render for the first UP (before any - // DR tick) and for no-Sequencer players. rmState.CellId is the server - // cell adopted above (:5735). The per-tick loop keeps it current - // between UPs (pose-gated). Commit the complete root before the - // publication gate, matching SetPositionInternal ordering. - entity.SetPosition(rmState.Body.Position); - entity.ParentCellId = rmState.CellId; - entity.Rotation = rmState.Body.Orientation; - AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote( - _liveEntities, - positionRecord, - entity, - rmState, - acceptedPositionAuthorityVersion, - () => _remotePhysicsUpdater.SyncRemoteShadowToBody( - entity.Id, - rmState, - _liveCenterX, - _liveCenterY)); - return; - } - - double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds; - System.Numerics.Vector3? serverVelocity = update.Velocity; - if (serverVelocity is null - && !IsPlayerGuid(update.Guid) - && rmState.LastServerPosTime > 0.0) - { - double elapsed = nowSec - rmState.LastServerPosTime; - if (elapsed > 0.001) - serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed; - } - if (serverVelocity is { } authoritativeVelocity) - { - rmState.ServerVelocity = authoritativeVelocity; - rmState.HasServerVelocity = true; - } - else if (!IsPlayerGuid(update.Guid)) - { - rmState.ServerVelocity = System.Numerics.Vector3.Zero; - rmState.HasServerVelocity = false; - } - // R5-V3 #171 residual (2026-07-04 gate: "flashing/flapping", - // stale facing, pushed-into-player): while an entity is STUCK, - // the sticky steer owns its frame — retail's UP corrections flow - // through the InterpolationManager into the SAME adjust_offset - // chain where StickyManager OVERWRITES them while armed - // (PositionManager::adjust_offset 0x00555190 order; sticky - // assigns m_fOrigin 0x00555430), so a server correction can - // never fight an armed stick frame-by-frame. This legacy NPC - // path hard-snaps OUTSIDE that chain, producing a visible - // snap-out/steer-back oscillation at UP cadence (position) and - // a stale-facing stomp (orientation). Faithful translation to - // the snap architecture: suppress the position/orientation/ - // velocity snaps while stuck. LastServerPos/Time bookkeeping - // still records below — server truth reasserts on the first UP - // after unstick (bounded by the 1 s sticky lease). Register - // row with TS-41/TS-44. - bool snapSuppressedByStick = - (rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u; - if (snapSuppressedByStick - && AcDream.Core.Physics.PhysicsDiagnostics.ProbeStickyEnabled) - { - float snapDist = System.Numerics.Vector3.Distance( - worldPos, rmState.Body.Position); - Console.WriteLine(FormattableString.Invariant( - $"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})")); - } - if (!snapSuppressedByStick) - { - // #184 (2026-07-07): retail CPhysicsObj::MoveOrTeleport (0x00516330) - // for grounded NPC remotes. The body is PLACED (hard-snapped) whenever - // it is not already tracking NEAR the server position — the first UP, - // a large correction / teleport, an out-of-view creature (>96 m from - // the local player), or an entity the DR loop won't tick — otherwise the - // server point is a GENTLE dead-reckoning TARGET the per-tick interp - // catch-up walks to, and the KEPT sweep de-overlaps that movement. - // - // The placement-snap is LOAD-BEARING: the earlier attempt (reverted) - // enqueued EVERYTHING, so an unplaced body (origin, first UP) blipped - // over a huge distance into the sweep -> a resolve started in a cell that - // did not contain the body -> garbage resolved pos -> INVISIBLE monster - // while its shadow (synced to server truth) stayed put -> player stuck on - // nothing. Airborne keeps the authoritative hard-snap (arc integrates - // locally, K-fix15). Physics digest 2026-07-07 banner. - if (rmState.Airborne) - { - rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; - } - else - { - const float MaxPhysicsDistanceNpc = 96f; // retail player_distance far-snap - const float BodySnapThresholdNpc = 4f; // large correction / teleport -> snap - var localPlayerPosNpc = _playerController?.Position - ?? System.Numerics.Vector3.Zero; - float distNpc = System.Numerics.Vector3.Distance(worldPos, localPlayerPosNpc); - float bodyToTargetNpc = System.Numerics.Vector3.Distance( - rmState.Body.Position, worldPos); - // The 4 m bodyToTargetNpc guard is the LOAD-BEARING placement backstop: - // it snaps any body not already near the target (an unplaced first-UP - // body sits at the origin / spawn pos, far from worldPos). firstUpNpc is - // a belt-and-suspenders hint only — it is NOT a reliable "never placed" - // signal because a UM that enters a locomotion cycle can stamp - // LastServerPosTime before the first UP (~:5340). Don't tune the 4 m - // threshold down without re-checking the unplaced-body case. - bool firstUpNpc = rmState.LastServerPosTime <= 0.0; - // Enqueue only if the canonical ordinary-object workset - // will consume the queue. Animation is optional in retail; - // LiveEntityAnimationScheduler advances a spatial - // RemoteMotion even when no LiveEntityAnimationState exists. - bool willBeDrTickedNpc = WillAdvanceRemoteMotion( - update.Guid, - rmState); - - if (firstUpNpc || !willBeDrTickedNpc - || distNpc > MaxPhysicsDistanceNpc - || bodyToTargetNpc > BodySnapThresholdNpc) - { - // Placement / far / large-correction: SNAP + clear queue. - rmState.Interp.Clear(); - rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; - } - else - { - // Near DR correction: enqueue the waypoint for the per-tick - // catch-up (Path B consumes it via ComputeOffset). - System.Numerics.Quaternion? immediateOrientation = - rmState.Interp.Enqueue( - worldPos, - rot, - isMovingTo: rmState.Movement.IsMovingTo(), - currentBodyPosition: rmState.Body.Position, - currentBodyOrientation: rmState.Body.Orientation); - if (immediateOrientation is { } closeOrientation) - rmState.Body.Orientation = closeOrientation; - } - } - } - // K-fix15 (2026-04-26): DON'T auto-clear airborne on UP. - // ACE broadcasts UPs during the arc (peak / mid-fall / land) - // at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on - // the FIRST UP after the jump, which: - // * restored Contact + OnWalkable, - // * removed the Gravity flag, - // * caused the next per-tick to stomp Velocity via - // apply_current_movement (reading InterpretedState = - // Ready, so Velocity.Z went to 0), - // …so the body got stuck at the server-broadcast apex Z, - // visibly hovering. The fix: leave Airborne true; the - // per-tick post-resolve logic detects an actual landing - // (resolveResult.IsOnGround && Velocity.Z <= 0) and clears - // it then. Mirrors how PlayerMovementController re-grounds - // the local player at the bottom of its arc. - // - // The position-snap above is still authoritative — if ACE - // says the body is at Z=68 mid-arc, we render Z=68. But we - // continue integrating gravity from there, so the body - // proceeds along the parabolic path between UPs. - // Adopt the server's cell ID as the transition starting cell. - // Retail authoritatively hard-snaps cell membership here too; our - // per-tick ResolveWithTransition sweep then advances CheckCellId - // as the sphere crosses cells and writes the new cell back into - // rmState.CellId so the NEXT frame starts in the correct cell. - rmState.CellId = p.LandblockId; - - // Near UpdatePosition orientation is carried by the same complete - // interpolation Frame as translation. Placement, airborne, and - // far-correction branches above install the authoritative Frame - // directly. Sticky still receives the shared Frame afterward and - // may replace it while armed. - rmState.LastServerPos = worldPos; - rmState.LastServerPosTime = nowSec; - - if (rmState.HasServerVelocity - && !snapSuppressedByStick - && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity)) - { - // NPC/monster remotes: PlanFromVelocity cycle selection from - // UP-derived velocity (ACE broadcasts NPC motion patterns the - // UM stream alone doesn't cover). Player remotes return early - // inside — their cycles are UM-driven only per retail (L.2g - // S5; DEV-2 deleted). Unification of NPCs onto the - // CMotionInterp funnel is S6. - // - // D2 (Commit A 2026-05-03): tag whether the velocity feeding - // ApplyServerControlledVelocityCycle is wire-explicit or - // synthesized from position deltas (the common case). - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") - { - string velSrc = update.Velocity is null ? "synth" : "wire"; - System.Console.WriteLine( - $"[UPCYCLE_SRC] guid={update.Guid:X8} src={velSrc}"); - } - ApplyServerControlledVelocityCycle( - update.Guid, - aeForVelocity, - rmState, - rmState.ServerVelocity); - } - - // #184: sync the NPC shadow to the resolved/placed body (NOT the raw - // server pos — the raw-pos sync was RETIRED for players too in Slice 2b) - // so collision == render and the de-overlap isn't snapped away each UP. - // Covers the first UP (before any DR tick) and no-Sequencer NPCs (which - // the per-tick loop skips). The per-tick loop keeps it current between - // UPs, pose-gated. rmState.CellId is the server cell adopted above. - // The root frame is committed before collision publication, as in - // retail SetPositionInternal. - entity.SetPosition(rmState.Body.Position); - entity.ParentCellId = rmState.CellId; - entity.Rotation = rmState.Body.Orientation; - AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote( - _liveEntities, - positionRecord, - entity, - rmState, - acceptedPositionAuthorityVersion, - () => _remotePhysicsUpdater.SyncRemoteShadowToBody( - entity.Id, - rmState, - _liveCenterX, - _liveCenterY)); - } - - // F751 is only a notification gate; the accepted Position may arrive - // before or after it. Canonical physics above always consumes the - // packet first. The presentation coordinator exposes exactly one - // sequence-correlated destination without reordering that state. - if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Apply - && update.Guid == _playerServerGuid - && _teleportTransit.OfferDestination( - update.TeleportSequence, - timestamps.TeleportAdvanced, - update, - out var teleportDestination)) - { - AimTeleportDestination(teleportDestination); - } - } - - // Retail teleport transit: the 7-state TAS drives portal/view-plane presentation, holds the - // player in PortalSpace until the destination is resident (TeleportWorldReady), then - // fires Place (materialize) and FireLoginComplete (regain control + ack the server). - // Replaces the old TeleportArrivalController hold/place machine. private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new(); private readonly TeleportViewPlaneController _teleportViewPlane = new(); private readonly AcDream.App.Streaming.TeleportTransitCoordinator< @@ -7531,11 +5409,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // pre-collapsed onto the spawn landblock; a camera-derived observer far from // it trips ExitDungeonExpand and unloads the dungeon before it can hydrate — // the player is never placed and login hangs with no dungeon. Previously - // _lastLivePlayerLandblockId was set by ANY entity, so a dungeon-local NPC + // The last-player-landblock hint was once set by ANY entity, so a dungeon-local NPC // kept this branch on the dungeon; once it was filtered to the player guid // (line ~4507), a not-yet-arrived player UP dropped to the camera branch. // The fly camera is the OFFLINE observer only. - if (_lastLivePlayerLandblockId is { } lid) + if (_liveEntityNetworkUpdates?.LastLivePlayerLandblockId is { } lid) { observerCx = (int)((lid >> 24) & 0xFFu); observerCy = (int)((lid >> 16) & 0xFFu); @@ -9340,27 +7218,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext LastFrameProfile: _frameProfiler.LastReport); } - private bool RunRemoteTeleportHook( - uint serverGuid, - uint localEntityId, - Func isCurrent) - { - _remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote); - EntityPhysicsHost? host = - _liveEntities?.TryGetPhysicsHost(serverGuid, out var registered) == true - ? registered as EntityPhysicsHost - : null; - return AcDream.App.Physics.RemoteTeleportHook.Execute( - new AcDream.App.Physics.RemoteTeleportHookActions( - CancelMoveTo: error => remote?.Movement.CancelMoveTo(error), - UnStick: () => host?.PositionManager.UnStick(), - StopInterpolating: () => remote?.Interp.Clear(), - UnConstrain: () => host?.PositionManager.UnConstrain(), - NotifyTeleported: () => host?.NotifyTeleported(), - ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)), - isCurrent); - } - /// /// Advances the live object's retail UseTime phase before inbound packet /// dispatch. Final root/part poses and their animation hooks are published @@ -11613,8 +9470,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw), setHeading: (h, _) => pcMoveTo.Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h), - getOwnRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius, - getOwnHeight: () => GetSetupCylinder(_playerServerGuid, playerEntity).Height, + getOwnRadius: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Radius, + getOwnHeight: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Height, contact: () => pcMoveTo.BodyInContact, isInterpolating: () => false, getVelocity: () => pcMoveTo.BodyVelocity, @@ -11669,12 +9526,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext playerRecord.WorldEntity?.Position ?? pcMoveTo.Position, pcMoveTo.BodyOrientation), getVelocity: () => pcMoveTo.BodyVelocity, - getRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius, + getRadius: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Radius, inContact: () => pcMoveTo.BodyInContact, minterpMaxSpeed: () => pcMoveTo.Motion.GetMaxSpeed(), curTime: () => pcMoveTo.SimTimeSeconds, physicsTimerTime: () => pcMoveTo.SimTimeSeconds, - getObjectA: ResolvePhysicsHost, + getObjectA: _liveEntityMotionBindings.ResolvePhysicsHost, // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head: // MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade // relay); the host chains the PositionManager leg after it. @@ -11855,7 +9712,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // legacy Resolve above supplies the already-ported AdjustPosition + // floor snap; this call supplies the missing object-aware ring search. var (placementRadius, placementHeight) = - GetSetupCylinder(_playerServerGuid, playerEntity); + _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity); if (placementRadius < 0.05f) { placementRadius = 0.48f; diff --git a/src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs b/src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs new file mode 100644 index 00000000..ce197103 --- /dev/null +++ b/src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs @@ -0,0 +1,48 @@ +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; + +namespace AcDream.App.World; + +internal interface ILiveEntitySameGenerationUpdateSink +{ + void OnDescription(uint ownerGuid, PhysicsSpawnData description); + void OnAppearance(ObjDescEvent.Parsed appearance); + void OnParent(CreateParentUpdate parent); + void OnPosition(WorldSession.EntityPositionUpdate position); + void OnPickup(PickupEvent.Parsed pickup); + void OnMovement(WorldSession.EntityMotionUpdate movement); + void OnState(SetState.Parsed state); + void OnVector(VectorUpdate.Parsed vector); +} + +/// +/// Pins the equal-generation CObjectMaint::CreateObject tail order from +/// retail SmartBox::HandleCreateObject @ 0x00454C80. The leading +/// PhysicsDesc effect-profile refresh is the isolated AP-119 adaptation; the +/// retail tail then remains ObjDesc, one Position relation, Movement, State, +/// and Vector. Each channel still owns an independent timestamp gate. +/// +internal static class LiveEntitySameGenerationUpdateRouter +{ + public static void Apply( + SameGenerationCreateObjectEvents refresh, + ILiveEntitySameGenerationUpdateSink sink) + { + ArgumentNullException.ThrowIfNull(sink); + sink.OnDescription(refresh.Appearance.Guid, refresh.Description); + sink.OnAppearance(refresh.Appearance); + + if (refresh.Parent is { } parent) + sink.OnParent(parent); + else if (refresh.Position is { } position) + sink.OnPosition(position); + else if (refresh.Pickup is { } pickup) + sink.OnPickup(pickup); + + if (refresh.Movement is { } movement) + sink.OnMovement(movement); + sink.OnState(refresh.State); + sink.OnVector(refresh.Vector); + } +} diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerImmediatePositionTests.cs b/tests/AcDream.App.Tests/Input/LocalPlayerImmediatePositionTests.cs new file mode 100644 index 00000000..8f7ee008 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/LocalPlayerImmediatePositionTests.cs @@ -0,0 +1,114 @@ +using System.Buffers.Binary; +using System.Net; +using System.Numerics; +using AcDream.App.Input; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; + +namespace AcDream.App.Tests.Input; + +public sealed class LocalPlayerImmediatePositionTests +{ + [Fact] + public void ForcePositionAcknowledgement_SendsOneExactAutonomousPositionAndStampsIt() + { + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000)); + session.PublishAcceptedLocalPhysicsTimestamps( + instance: 11, + serverControlledMove: 12, + teleport: 13, + forcePosition: 14); + var sent = new List(); + session.GameActionCapture = sent.Add; + + PlayerMovementController player = GroundedPlayer(); + Position preTurnCanonical = player.CellPosition; + Quaternion rotation = Quaternion.Normalize( + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.7f)); + player.SetBodyOrientation(rotation); + Assert.NotEqual(rotation, preTurnCanonical.Frame.Orientation); + Assert.True(player.TryGetOutboundPosition(out Position outboundPosition)); + var outbound = new LocalPlayerOutboundController((_, _, _, _, _, _) => { }); + + outbound.SendImmediatePosition(session, player); + + byte[] body = Assert.Single(sent); + Assert.Equal(56, body.Length); + Assert.Equal(AutonomousPosition.GameActionOpcode, U32(body, 0)); + Assert.Equal(1u, U32(body, 4)); + Assert.Equal(AutonomousPosition.AutonomousPositionAction, U32(body, 8)); + Assert.Equal(outboundPosition.ObjCellId, U32(body, 12)); + Assert.Equal(outboundPosition.Frame.Origin.X, F32(body, 16)); + Assert.Equal(outboundPosition.Frame.Origin.Y, F32(body, 20)); + Assert.Equal(outboundPosition.Frame.Origin.Z, F32(body, 24)); + Assert.Equal(rotation.W, F32(body, 28), precision: 6); + Assert.Equal(rotation.X, F32(body, 32), precision: 6); + Assert.Equal(rotation.Y, F32(body, 36), precision: 6); + Assert.Equal(rotation.Z, F32(body, 40), precision: 6); + Assert.Equal((ushort)11, U16(body, 44)); + Assert.Equal((ushort)12, U16(body, 46)); + Assert.Equal((ushort)13, U16(body, 48)); + Assert.Equal((ushort)14, U16(body, 50)); + Assert.Equal((byte)1, body[52]); + Assert.True(player.TryGetOutboundPosition(out Position currentOutbound)); + Assert.False(player.ShouldSendPositionEvent( + currentOutbound, + player.ContactPlane, + player.SimTimeSeconds)); + } + + [Fact] + public void ImmediateAcknowledgement_RequiresSessionPlayerAndGroundContact() + { + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000)); + var sent = new List(); + session.GameActionCapture = sent.Add; + var outbound = new LocalPlayerOutboundController((_, _, _, _, _, _) => { }); + PlayerMovementController grounded = GroundedPlayer(); + + outbound.SendImmediatePosition(null, grounded); + outbound.SendImmediatePosition(session, null); + outbound.SendImmediatePosition( + session, + new PlayerMovementController(new PhysicsEngine())); + + Assert.Empty(sent); + } + + private static PlayerMovementController GroundedPlayer() + { + var engine = new PhysicsEngine(); + var heights = new byte[81]; + Array.Fill(heights, (byte)50); + var heightTable = new float[256]; + for (int i = 0; i < heightTable.Length; i++) + heightTable[i] = i; + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + var player = new PlayerMovementController(engine); + player.SetPosition( + new Vector3(96f, 97f, 50f), + 0xA9B40001u, + new Vector3(96f, 97f, 50f)); + Assert.True(player.CanSendPositionEvent); + return player; + } + + private static uint U32(byte[] body, int offset) => + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(offset, 4)); + + private static ushort U16(byte[] body, int offset) => + BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(offset, 2)); + + private static float F32(byte[] body, int offset) => + BitConverter.Int32BitsToSingle( + BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(offset, 4))); +} diff --git a/tests/AcDream.App.Tests/Physics/DeferredLiveEntityMotionRuntimeBindingsTests.cs b/tests/AcDream.App.Tests/Physics/DeferredLiveEntityMotionRuntimeBindingsTests.cs new file mode 100644 index 00000000..b66385f2 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/DeferredLiveEntityMotionRuntimeBindingsTests.cs @@ -0,0 +1,85 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Physics; + +public sealed class DeferredLiveEntityMotionRuntimeBindingsTests +{ + [Fact] + public void EveryReverseEdgeFailsBeforeBind() + { + var bridge = new DeferredLiveEntityMotionRuntimeBindings(); + var entity = Entity(); + + Assert.Throws(() => + bridge.GetSetupCylinder(1, entity)); + Assert.Throws(() => + bridge.RouteServerMoveTo(null!, 0, default)); + Assert.Throws(() => + bridge.StickToObjectFromWire(null, 1)); + Assert.Throws(() => + bridge.ClearTargetForHiddenEntity(1)); + Assert.Throws(() => + bridge.ResolvePhysicsHost(1)); + } + + [Fact] + public void BindIsSingleAssignmentAndAllCallsReachExactOwner() + { + var bridge = new DeferredLiveEntityMotionRuntimeBindings(); + var owner = new RecordingBindings(); + bridge.Bind(owner); + + Assert.Equal((2f, 3f), bridge.GetSetupCylinder(1, Entity())); + Assert.True(bridge.RouteServerMoveTo(null!, 4, default)); + bridge.StickToObjectFromWire(null, 5); + bridge.ClearTargetForHiddenEntity(6); + Assert.Null(bridge.ResolvePhysicsHost(7)); + + Assert.Equal( + ["cylinder:1", "moveto:4", "stick:5", "hidden:6", "host:7"], + owner.Calls); + Assert.Throws(() => + bridge.Bind(new RecordingBindings())); + } + + private static WorldEntity Entity() => new() + { + Id = 1, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private sealed class RecordingBindings : ILiveEntityMotionRuntimeBindings + { + public List Calls { get; } = []; + public (float Radius, float Height) GetSetupCylinder(uint guid, WorldEntity entity) + { + Calls.Add($"cylinder:{guid}"); + return (2f, 3f); + } + public bool RouteServerMoveTo( + MovementManager movement, + uint cellId, + WorldSession.EntityMotionUpdate update) + { + Calls.Add($"moveto:{cellId}"); + return true; + } + public void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid) => + Calls.Add($"stick:{targetGuid}"); + public void ClearTargetForHiddenEntity(uint guid) => Calls.Add($"hidden:{guid}"); + public IPhysicsObjHost? ResolvePhysicsHost(uint guid) + { + Calls.Add($"host:{guid}"); + return null; + } + } +} diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs new file mode 100644 index 00000000..b6885109 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs @@ -0,0 +1,408 @@ +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; + +namespace AcDream.App.Tests.Physics; + +public sealed class LiveEntityInboundAuthorityGateTests +{ + private const uint Guid = 0x50000021u; + + [Fact] + public void Motion_StrictFreshnessAndWrapAreOwnedBeforePresentation() + { + LiveEntityRuntime runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(Guid, instance: 7, movement: 0xFFFE)); + var published = new List(); + var gate = new LiveEntityInboundAuthorityGate( + runtime, + (_, timestamps) => published.Add(timestamps)); + + Assert.False(gate.TryAcceptMotion( + Motion(instance: 7, movement: 0xFFFE, server: 1), + retainPayload: true, + out _, + out bool equalAccepted)); + Assert.False(equalAccepted); + + Assert.True(gate.TryAcceptMotion( + Motion(instance: 7, movement: 1, server: 1), + retainPayload: true, + out AcceptedMotionNetworkUpdate wrapped, + out bool wrapAccepted)); + Assert.True(wrapAccepted); + Assert.Equal((ulong)2, wrapped.Record.MovementAuthorityVersion); + Assert.Single(published); + + Assert.False(gate.TryAcceptMotion( + Motion(instance: 7, movement: 1, server: 1), + retainPayload: true, + out _, + out bool duplicateAccepted)); + Assert.False(duplicateAccepted); + Assert.Single(published); + } + + [Fact] + public void AutonomousLocalMotion_ConsumesTimestampButDoesNotPublishPayload() + { + LiveEntityRuntime runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(Guid, instance: 1)); + int publishCount = 0; + var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => publishCount++); + + Assert.False(gate.TryAcceptMotion( + Motion(instance: 1, movement: 2, server: 1, autonomous: true), + retainPayload: false, + out _, + out bool timestampAccepted)); + + Assert.True(timestampAccepted); + Assert.Equal(1, publishCount); + Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + Assert.Equal((ulong)1, record.MovementAuthorityVersion); + } + + [Fact] + public void Vector_InvalidPayloadDoesNotConsumeSequenceAndDuplicateIsRejected() + { + LiveEntityRuntime runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(Guid, instance: 2)); + var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); + var update = new VectorUpdate.Parsed( + Guid, + new Vector3(1f, 2f, 3f), + Vector3.Zero, + InstanceSequence: 2, + VectorSequence: 2); + + Assert.False(gate.TryAcceptVector(update, payloadIsValid: false, out _)); + Assert.True(gate.TryAcceptVector( + update, + payloadIsValid: true, + out AcceptedVectorNetworkUpdate accepted)); + Assert.Equal((ulong)2, accepted.VectorAuthorityVersion); + Assert.False(gate.TryAcceptVector(update, payloadIsValid: true, out _)); + } + + [Fact] + public void Vector_WrappedSequenceIsAccepted() + { + LiveEntityRuntime runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(Guid, instance: 2, vector: 0xFFFE)); + var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); + + Assert.True(gate.TryAcceptVector( + new VectorUpdate.Parsed( + Guid, + Vector3.One, + Vector3.Zero, + InstanceSequence: 2, + VectorSequence: 1), + payloadIsValid: true, + out _)); + } + + [Fact] + public void State_EqualIsRejectedAndWrappedSequenceIsAccepted() + { + LiveEntityRuntime runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(Guid, instance: 3, state: 0xFFFE)); + var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); + + Assert.False(gate.TryAcceptState( + new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 3, 0xFFFE), + out _)); + Assert.True(gate.TryAcceptState( + new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 3, 1), + out AcceptedStateNetworkUpdate accepted)); + Assert.Equal((ulong)2, accepted.StateAuthorityVersion); + Assert.False(gate.TryAcceptState( + new SetState.Parsed(Guid, 0, 3, 1), + out _)); + } + + [Fact] + public void Position_UnknownLocalHintAndResetDoNotConsumeFuturePacket() + { + LiveEntityRuntime runtime = Runtime(); + int publishCount = 0; + var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => publishCount++); + WorldSession.EntityPositionUpdate update = Position( + Guid, + instance: 4, + position: 2, + cell: 0xAABB0001u); + + Assert.False(gate.TryAcceptPosition( + update, + Guid, + Quaternion.Identity, + Vector3.Zero, + payloadIsValid: true, + out _)); + Assert.Equal(0xAABB0001u, gate.LastLivePlayerLandblockId); + Assert.Equal(0, publishCount); + + runtime.RegisterLiveEntity(Spawn(Guid, instance: 4, position: 1)); + Assert.True(gate.TryAcceptPosition( + update, + Guid, + Quaternion.Identity, + Vector3.Zero, + payloadIsValid: true, + out AcceptedPositionNetworkUpdate accepted)); + Assert.Equal(PositionTimestampDisposition.Apply, accepted.TimestampDisposition); + Assert.Equal((ulong)2, accepted.PositionAuthorityVersion); + Assert.Equal(1, publishCount); + + gate.ResetSessionState(); + Assert.Null(gate.LastLivePlayerLandblockId); + } + + [Fact] + public void Position_InvalidPayloadDoesNotConsumeSequence() + { + LiveEntityRuntime runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, position: 1)); + var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); + WorldSession.EntityPositionUpdate update = Position(Guid, 5, 2, 0x01010001u); + + Assert.False(gate.TryAcceptPosition( + update, Guid, Quaternion.Identity, Vector3.Zero, false, out _)); + Assert.True(gate.TryAcceptPosition( + update, Guid, Quaternion.Identity, Vector3.Zero, true, out _)); + Assert.False(gate.TryAcceptPosition( + update, Guid, Quaternion.Identity, Vector3.Zero, true, out _)); + } + + [Fact] + public void Position_WrappedSequenceIsAccepted() + { + LiveEntityRuntime runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, position: 0xFFFE)); + var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); + + Assert.True(gate.TryAcceptPosition( + Position(Guid, 5, 1, 0x01010002u), + Guid, + Quaternion.Identity, + Vector3.Zero, + payloadIsValid: true, + out _)); + } + + [Fact] + public void Vector_NewerSameIncarnationPacketInvalidatesCapturedAuthority() + { + LiveEntityRuntime runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, vector: 1)); + var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); + Assert.True(gate.TryAcceptVector( + new VectorUpdate.Parsed(Guid, Vector3.One, Vector3.Zero, 5, 2), + payloadIsValid: true, + out AcceptedVectorNetworkUpdate accepted)); + + Assert.True(runtime.TryApplyVector( + new VectorUpdate.Parsed(Guid, Vector3.UnitX, Vector3.Zero, 5, 3), + out _)); + Assert.False(runtime.IsCurrentVectorAuthority( + accepted.Record, + accepted.VectorAuthorityVersion)); + } + + [Fact] + public void State_NewerSameIncarnationPacketInvalidatesCapturedAuthority() + { + LiveEntityRuntime runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, state: 1)); + var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); + Assert.True(gate.TryAcceptState( + new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 5, 2), + out AcceptedStateNetworkUpdate accepted)); + + Assert.True(runtime.TryApplyState( + new SetState.Parsed(Guid, 0, 5, 3), + out _)); + Assert.False(runtime.IsCurrentStateAuthority( + accepted.Record, + accepted.StateAuthorityVersion)); + } + + [Fact] + public void Motion_PublisherReplacementInvalidatesCapturedIncarnation() + { + LiveEntityRuntime runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(Guid, instance: 6)); + var gate = new LiveEntityInboundAuthorityGate( + runtime, + (_, _) => runtime.RegisterLiveEntity(Spawn(Guid, instance: 7))); + + Assert.False(gate.TryAcceptMotion( + Motion(instance: 6, movement: 2, server: 1), + retainPayload: true, + out _, + out bool timestampAccepted)); + Assert.True(timestampAccepted); + Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord replacement)); + Assert.Equal((ushort)7, replacement.Generation); + } + + [Fact] + public void Position_PublisherNewerChannelInvalidatesCapturedAuthority() + { + LiveEntityRuntime runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(Guid, instance: 8, position: 1)); + var nested = Position(Guid, 8, 3, 0x01010002u); + LiveEntityInboundAuthorityGate? gate = null; + gate = new LiveEntityInboundAuthorityGate( + runtime, + (_, _) => runtime.TryApplyPosition( + nested, + isLocalPlayer: true, + forcePositionRotation: Quaternion.Identity, + currentLocalVelocity: Vector3.Zero, + out _, + out _, + out _)); + + Assert.False(gate.TryAcceptPosition( + Position(Guid, 8, 2, 0x01010001u), + Guid, + Quaternion.Identity, + Vector3.Zero, + payloadIsValid: true, + out _)); + Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + Assert.Equal((ulong)3, record.PositionAuthorityVersion); + } + + [Fact] + public void Position_PublisherNewerVectorPreservesIndependentPositionAuthority() + { + LiveEntityRuntime runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(Guid, instance: 9, position: 1, vector: 1)); + var gate = new LiveEntityInboundAuthorityGate( + runtime, + (_, _) => runtime.TryApplyVector( + new VectorUpdate.Parsed( + Guid, + Vector3.UnitX, + Vector3.Zero, + InstanceSequence: 9, + VectorSequence: 2), + out _)); + + Assert.True(gate.TryAcceptPosition( + Position(Guid, 9, 2, 0x01010003u), + Guid, + Quaternion.Identity, + Vector3.Zero, + payloadIsValid: true, + out AcceptedPositionNetworkUpdate accepted)); + Assert.True(runtime.IsCurrentPositionAuthority( + accepted.Record, + accepted.PositionAuthorityVersion)); + Assert.False(runtime.IsCurrentVelocityAuthority( + accepted.Record, + accepted.VelocityAuthorityVersion)); + } + + private static LiveEntityRuntime Runtime() => new( + new GpuWorldState(), + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + + private static WorldSession.EntityMotionUpdate Motion( + ushort instance, + ushort movement, + ushort server, + bool autonomous = false) => new( + Guid, + default, + instance, + movement, + server, + autonomous); + + private static WorldSession.EntityPositionUpdate Position( + uint guid, + ushort instance, + ushort position, + uint cell) => new( + guid, + new CreateObject.ServerPosition( + cell, 10f, 11f, 12f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: instance, + PositionSequence: position, + TeleportSequence: 0, + ForcePositionSequence: 0); + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort instance, + ushort position = 1, + ushort movement = 1, + ushort state = 1, + ushort vector = 1) + { + var serverPosition = new CreateObject.ServerPosition( + 0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + position, + movement, + state, + vector, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: instance); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + 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, + "fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: instance, + MovementSequence: movement, + ServerControlSequence: 1, + PositionSequence: position, + Physics: physics); + } +} diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs new file mode 100644 index 00000000..1936f0e7 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs @@ -0,0 +1,96 @@ +using AcDream.App.Physics; + +namespace AcDream.App.Tests.Physics; + +public sealed class LiveEntityNetworkBranchRoutingTests +{ + [Fact] + public void Vector_ProjectileStopsCanonicalAndOrdinaryRoutes() + { + var calls = new List(); + + LiveEntityVectorRoute route = LiveEntityVectorRouter.Route( + () => { calls.Add("projectile"); return true; }, + () => { calls.Add("canonical"); return true; }, + () => calls.Add("ordinary")); + + Assert.Equal(LiveEntityVectorRoute.Projectile, route); + Assert.Equal(["projectile"], calls); + } + + [Fact] + public void Vector_CanonicalBodyRunsOnlyAfterProjectileDeclines() + { + var calls = new List(); + + LiveEntityVectorRoute route = LiveEntityVectorRouter.Route( + () => { calls.Add("projectile"); return false; }, + () => { calls.Add("canonical"); return true; }, + () => calls.Add("ordinary")); + + Assert.Equal(LiveEntityVectorRoute.CanonicalBody, route); + Assert.Equal(["projectile", "canonical"], calls); + } + + [Fact] + public void Vector_OrdinaryRemoteIsTheLastFallback() + { + var calls = new List(); + + LiveEntityVectorRoute route = LiveEntityVectorRouter.Route( + () => { calls.Add("projectile"); return false; }, + () => { calls.Add("canonical"); return false; }, + () => calls.Add("ordinary")); + + Assert.Equal(LiveEntityVectorRoute.OrdinaryRemote, route); + Assert.Equal(["projectile", "canonical", "ordinary"], calls); + } + + [Fact] + public void ForcePosition_BlipsAndAcknowledgesExactlyOnce() + { + int currentChecks = 0; + int blips = 0; + int acknowledgements = 0; + + bool completed = LocalForcePositionTransaction.Apply( + isForcePosition: true, + () => { currentChecks++; return true; }, + () => blips++, + () => acknowledgements++); + + Assert.True(completed); + Assert.Equal(2, currentChecks); + Assert.Equal(1, blips); + Assert.Equal(1, acknowledgements); + } + + [Fact] + public void ForcePosition_AcknowledgementInvalidationStopsTheTail() + { + bool current = true; + int acknowledgements = 0; + + bool completed = LocalForcePositionTransaction.Apply( + isForcePosition: true, + () => current, + () => { }, + () => { acknowledgements++; current = false; }); + + Assert.False(completed); + Assert.Equal(1, acknowledgements); + } + + [Fact] + public void OrdinaryPositionDoesNotBlipOrAcknowledge() + { + int calls = 0; + + Assert.True(LocalForcePositionTransaction.Apply( + isForcePosition: false, + () => { calls++; return false; }, + () => calls++, + () => calls++)); + Assert.Equal(0, calls); + } +} diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs index 96503f07..ab53bcac 100644 --- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -30,6 +30,23 @@ public sealed class GameWindowLiveEntityCompositionTests [InlineData("OnLiveEntityPruned")] [InlineData("TearDownLiveEntityRuntimeComponents")] [InlineData("CreateLiveEntityRuntimeTeardownPlan")] + [InlineData("RouteSameGenerationCreateObject")] + [InlineData("OnLiveMotionUpdated")] + [InlineData("OnLivePositionUpdated")] + [InlineData("OnLiveVectorUpdated")] + [InlineData("OnLiveStateUpdated")] + [InlineData("EnsureRemoteMotionBindings")] + [InlineData("ResolvePhysicsHost")] + [InlineData("GetSetupCylinder")] + [InlineData("RouteServerMoveTo")] + [InlineData("StickToObjectFromWire")] + [InlineData("ClearTargetForHiddenEntity")] + [InlineData("ApplyServerControlledVelocityCycle")] + [InlineData("RunRemoteTeleportHook")] + [InlineData("SendImmediateLocalPositionEvent")] + [InlineData("DispatchRemoteInboundMotion")] + [InlineData("CreateRemoteMotion")] + [InlineData("WillAdvanceRemoteMotion")] public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName) { Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation)); @@ -52,6 +69,10 @@ public sealed class GameWindowLiveEntityCompositionTests [InlineData(typeof(LiveEntityHydrationController))] [InlineData(typeof(DatLiveEntityProjectionMaterializer))] [InlineData(typeof(LiveEntityRuntimeTeardownController))] + [InlineData(typeof(LiveEntityNetworkUpdateController))] + [InlineData(typeof(LiveEntityMotionRuntimeController))] + [InlineData(typeof(LiveEntityInboundAuthorityGate))] + [InlineData(typeof(DeferredLiveEntityMotionRuntimeBindings))] public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType) { foreach (FieldInfo field in helperType.GetFields( diff --git a/tests/AcDream.App.Tests/World/LiveEntitySameGenerationUpdateRouterTests.cs b/tests/AcDream.App.Tests/World/LiveEntitySameGenerationUpdateRouterTests.cs new file mode 100644 index 00000000..d63245e6 --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntitySameGenerationUpdateRouterTests.cs @@ -0,0 +1,94 @@ +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; + +namespace AcDream.App.Tests.World; + +public sealed class LiveEntitySameGenerationUpdateRouterTests +{ + [Theory] + [InlineData(0, "parent")] + [InlineData(1, "position")] + [InlineData(2, "pickup")] + [InlineData(3, null)] + public void Apply_PreservesRetailTailOrderAndExclusiveSpatialBranch( + int spatialKind, + string? expectedSpatial) + { + var sink = new RecordingSink(); + SameGenerationCreateObjectEvents refresh = new( + Description: default, + Appearance: default, + Parent: spatialKind == 0 ? default(CreateParentUpdate?) : null, + Position: spatialKind == 1 + ? default(WorldSession.EntityPositionUpdate?) + : null, + Pickup: spatialKind == 2 ? default(PickupEvent.Parsed?) : null, + Movement: default(WorldSession.EntityMotionUpdate?), + State: default, + Vector: default); + + // Nullable default(T?) has no value; explicitly wrap the selected + // zero-valued fixture so the branch itself remains observable. + refresh = refresh with + { + Parent = spatialKind == 0 + ? (CreateParentUpdate?)new CreateParentUpdate() + : null, + Position = spatialKind == 1 + ? (WorldSession.EntityPositionUpdate?)new WorldSession.EntityPositionUpdate() + : null, + Pickup = spatialKind == 2 + ? (PickupEvent.Parsed?)new PickupEvent.Parsed() + : null, + Movement = new WorldSession.EntityMotionUpdate(), + }; + + LiveEntitySameGenerationUpdateRouter.Apply(refresh, sink); + + var expected = new List { "description", "appearance" }; + if (expectedSpatial is not null) + expected.Add(expectedSpatial); + expected.AddRange(["movement", "state", "vector"]); + Assert.Equal(expected, sink.Events); + } + + [Fact] + public void ParentTakesPrecedenceWhenMalformedFixtureOffersAllSpatialForms() + { + var sink = new RecordingSink(); + var refresh = new SameGenerationCreateObjectEvents( + default, + default, + new CreateParentUpdate(), + new WorldSession.EntityPositionUpdate(), + new PickupEvent.Parsed(), + null, + default, + default); + + LiveEntitySameGenerationUpdateRouter.Apply(refresh, sink); + + Assert.Equal( + ["description", "appearance", "parent", "state", "vector"], + sink.Events); + } + + private sealed class RecordingSink : ILiveEntitySameGenerationUpdateSink + { + public List Events { get; } = []; + public void OnDescription(uint ownerGuid, PhysicsSpawnData description) => + Events.Add("description"); + public void OnAppearance(ObjDescEvent.Parsed appearance) => + Events.Add("appearance"); + public void OnParent(CreateParentUpdate parent) => Events.Add("parent"); + public void OnPosition(WorldSession.EntityPositionUpdate position) => + Events.Add("position"); + public void OnPickup(PickupEvent.Parsed pickup) => Events.Add("pickup"); + public void OnMovement(WorldSession.EntityMotionUpdate movement) => + Events.Add("movement"); + public void OnState(SetState.Parsed state) => Events.Add("state"); + public void OnVector(VectorUpdate.Parsed vector) => Events.Add("vector"); + } +} From 2b10e91e07bba712cad2267a688242fda4c1a179 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 19:29:04 +0200 Subject: [PATCH 52/98] docs(plan): complete GameWindow slice 4 --- AGENTS.md | 16 +++++----- CLAUDE.md | 16 +++++----- docs/architecture/acdream-architecture.md | 6 ++-- docs/architecture/code-structure.md | 21 ++++++++---- docs/plans/2026-04-11-roadmap.md | 16 ++++++++-- docs/plans/2026-05-12-milestones.md | 10 +++--- ...ewindow-slice-4-live-entity-integration.md | 32 ++++++++++++++++--- 7 files changed, 81 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 898b51c1..b372b44a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,17 +115,17 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 6,714 tests pass / 5 skip. +Release builds; 6,940 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. -Selection/interaction Slice 1, live-animation-presentation Slice 2, and -complete live-session ownership Slice 3 are landed. `LiveSessionController` -now owns the exact resolve/connect/enter/tick/reconnect/graceful-close -generation; GameWindow has no parallel session/router/command fields or old -lifecycle bodies. `GameWindow` is 14,310 lines, 274 fields, and 190 methods. -Slice 4 extracts live-entity App integration next; later slices finish -landblock presentation, update/render orchestration, and composition cleanup. +Selection/interaction Slice 1, live-animation-presentation Slice 2, complete +live-session ownership Slice 3, and live-entity App integration Slice 4 are +landed. Focused hydration, teardown, inbound-authority, network-update, and +motion-runtime owners now sit over canonical `LiveEntityRuntime` without a +second GUID dictionary. `GameWindow` is 10,301 lines, 267 fields, and 163 +methods. Slice 5 extracts landblock presentation next; later slices finish +update/render orchestration and composition cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/CLAUDE.md b/CLAUDE.md index a1743ce6..44e9d230 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,17 +113,17 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 6,714 tests pass / 5 skip. +Release builds; 6,940 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. -Selection/interaction Slice 1, live-animation-presentation Slice 2, and -complete live-session ownership Slice 3 are landed. `LiveSessionController` -now owns the exact resolve/connect/enter/tick/reconnect/graceful-close -generation; GameWindow has no parallel session/router/command fields or old -lifecycle bodies. `GameWindow` is 14,310 lines, 274 fields, and 190 methods. -Slice 4 extracts live-entity App integration next; later slices finish -landblock presentation, update/render orchestration, and composition cleanup. +Selection/interaction Slice 1, live-animation-presentation Slice 2, complete +live-session ownership Slice 3, and live-entity App integration Slice 4 are +landed. Focused hydration, teardown, inbound-authority, network-update, and +motion-runtime owners now sit over canonical `LiveEntityRuntime` without a +second GUID dictionary. `GameWindow` is 10,301 lines, 267 fields, and 163 +methods. Slice 5 extracts landblock presentation next; later slices finish +update/render orchestration and composition cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 1b2c5e75..743ab0a5 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -838,8 +838,10 @@ Current movement/collision ownership: shared retail inventory-request owner; Slice 2 moved final animation presentation into `LiveEntityAnimationPresenter`; Slice 3 moved the complete session generation into `LiveSessionController` plus its focused host and - routers. Slice 4 live-entity App integration is next. The reconciled ledger - and sequence live in `docs/architecture/code-structure.md`. This decision + routers; Slice 4 moved hydration, teardown, inbound authority, network + updates, and reusable motion-runtime policy over canonical + `LiveEntityRuntime`. Slice 5 landblock presentation is next. The reconciled + ledger and sequence live in `docs/architecture/code-structure.md`. This decision does not reclassify the remaining L.2 conformance work or Track LH. Full `GameEntity` type aggregation remains a separate, higher-risk target after diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 17608be2..a9ddf3cd 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -1,7 +1,7 @@ # acdream — code structure & extraction sequence **Status:** Living document. Created 2026-05-16; implementation reconciliation -completed 2026-07-21; Slices 1–3 landed the same day. This is the active +completed 2026-07-21; Slices 1–4 landed the same day. This is the active structural program before new M4 subsystems enter the App layer. **Purpose:** Describe the desired structural state of the App layer, explain the rules we've adopted, and lay out the safe extraction @@ -26,6 +26,7 @@ baseline cf50ee3d 15,723 lines / 278 fields / 205 method after Slice 1 14,912 lines / 278 fields / 191 methods after Slice 2 14,546 lines / 277 fields / 190 methods after Slice 3 14,310 lines / 274 fields / 190 methods +after Slice 4 10,301 lines / 267 fields / 163 methods ``` `GameWindow` is the single object that: @@ -393,7 +394,7 @@ useful ordering seam, but its ownership status is **partial**. |---|---|---| | Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. | | Network session | **Complete** | `LiveSessionController` owns resolve/create/bind/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal; `LiveSessionLifecycleHost` is the narrow App composition boundary; `LiveSessionEventRouter` and `LiveSessionCommandRouter` own exact inbound/outbound lifetimes. `GameWindow` retains one controller field and focused domain sink factories only (`d9ccf8a6`, `6a5d9e2e`). | -| Live identity/lifetime | **Complete core owner** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, logical/spatial lifetime, and teardown. `GameWindow` still owns large hydration and wire-to-presentation adapter bodies. | +| Live identity/lifetime | **Complete App integration** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, and logical/spatial lifetime. `LiveEntityHydrationController`, `LiveEntityRuntimeTeardownController`, and `LiveEntityNetworkUpdateController` own DAT-backed hydration, retryable cleanup, and accepted Position/Vector/State/Movement presentation without a second GUID owner (`aa90c646`). | | Inbound/object-frame order | **Partial** | `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveEntityAnimationScheduler`, `LiveEntityAnimationPresenter`, static scheduler, remote/projectile updaters, and teleport owners are shipped. `AdvanceLiveObjectRuntimeCore` retains the visible cross-owner order until Slice 6. | | World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. | | Retained gameplay UI | **Mostly complete feature ownership** | `RetailUiRuntime` and focused panel/controllers own layout and behavior. `GameWindow.OnLoad` still performs substantial service composition, which is allowed until the final composition cleanup. | @@ -500,11 +501,10 @@ client processes exited normally. `GameWindow` fell another 236 lines and three fields to 14,310 lines / 274 fields / 190 methods. Slice 4 is next. -#### Slice 4 — extract live-entity App integration +#### Slice 4 — extract live-entity App integration — COMPLETE 2026-07-21 Detailed execution ledger: -[`docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md`](../plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md) -(active). +[`docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md`](../plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md). This is two owners, not one replacement god object: @@ -534,8 +534,15 @@ subtree recovery now use independent authority transactions and preserve the same logical/runtime identities. Checkpoint E is complete in `f38822c4`: Delete, visibility prune, retryable exact-incarnation component cleanup, and derived remote stop-observation lifetime now have focused owners. Checkpoint F -(Position/Vector/State/Movement and equal-generation CreateObject routing) is -active. +completed in `aa90c646`: accepted Position/Vector/State/Movement and the +equal-generation CreateObject tail now route through focused owners. +Independent authority/version captures survive reentrant callbacks and GUID +reuse; shared motion-runtime policy is one-way bound and no extracted owner +keeps a second identity dictionary. ForcePosition serializes and stamps one +exact canonical outbound Position. Three corrected-diff reviews, the complete +6,940-test Release suite, and the 310-second seven-destination connected R6 +route all passed. `GameWindow` is 10,301 lines / 267 fields / 163 methods. +Slice 5 is active next. #### Slice 5 — extract landblock presentation diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 9deb537e..9909f504 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -38,9 +38,19 @@ tests with five intentional skips. Its connected gate passed capped login, five travel/revisit checkpoints, graceful close, and uncapped fresh-process reconnect in 306 seconds. -**Next:** Slice 4 extracts live-entity App integration into focused hydration -and network-update controllers over the existing canonical -`LiveEntityRuntime`, without adding a second GUID or resource owner. +Slice 4 live-entity App integration landed 2026-07-21: focused hydration, +retryable teardown, inbound authority, network-update, equal-generation +CreateObject, and reusable motion-runtime owners now sit over the canonical +`LiveEntityRuntime`. No extracted owner adds a second GUID dictionary. +`GameWindow` is now 10,301 lines, 267 fields, and 163 methods; the Release suite +passes 6,940 tests with five intentional skips. The 310-second connected R6 +gate passed seven destinations/revisits, movement/jump/combat exercises, and +graceful close. + +**Next:** Slice 5 extracts DAT-backed landblock build, publication, demotion, +and retirement into `LandblockPresentationPipeline` while preserving +`StreamingController` residency decisions and `GpuWorldState` spatial +ownership. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 51a2bb13..f26351e9 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -17,10 +17,12 @@ layer, the active structural prerequisite is the behavior-preserving `GameWindow` decomposition in [`docs/architecture/code-structure.md`](../architecture/code-structure.md). The 2026-07-21 audit baseline was 15,723 lines, 278 fields, and 205 methods. -Selection/interaction Slice 1, live-animation-presentation Slice 2, and -complete live-session ownership Slice 3 are complete. `GameWindow` is now -14,310 lines, 274 fields, and 190 methods. Slice 4 — live-entity App -integration — is active next. +Selection/interaction Slice 1, live-animation-presentation Slice 2, complete +live-session ownership Slice 3, and live-entity App integration Slice 4 are +complete. `GameWindow` is now 10,301 lines, 267 fields, and 163 methods. The +full Release suite passes 6,940 tests / 5 skips, and Slice 4's seven-destination +connected R6 route passed in 310 seconds. Slice 5 — landblock presentation — +is active next. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md index 5fb2ed19..1c82c42c 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md @@ -1,6 +1,6 @@ # GameWindow Slice 4 — live-entity App integration -**Status:** Active 2026-07-21. +**Status:** Complete 2026-07-21. **Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 4. **Baseline:** `ed9dc366`; `GameWindow.cs` is 14,310 lines, 274 fields, and 190 methods before this slice. Release baseline: 6,714 passed / 5 skipped. @@ -30,10 +30,10 @@ them inside a mechanical extraction. identity across leave-world/re-entry. Landed in `fe551496`; 6,877 Release tests passed / 5 skipped after three clean retail, architecture, and adversarial re-reviews on 2026-07-21. -- [ ] E — Delete/prune and retryable exact-incarnation teardown ownership. -- [ ] F — Position/Vector/State/Movement controller, same-generation CreateObject +- [x] E — Delete/prune and retryable exact-incarnation teardown ownership. +- [x] F — Position/Vector/State/Movement controller, same-generation CreateObject tail, independent authority gates, and direct inbound cutover. -- [ ] G — `GameWindow` cleanup, three-agent review cycle, full Release suite, +- [x] G — `GameWindow` cleanup, three-agent review cycle, full Release suite, connected gates, documentation, and durable memory closeout. ## 1. Outcome and non-goals @@ -343,6 +343,20 @@ skips and no errors; all three corrected-diff re-reviews were clean. - Route session callbacks directly and remove old GameWindow handlers. - Full Release build/test; review cycle. +Completed in `aa90c646`. `LiveEntityNetworkUpdateController` now owns accepted +Position, Vector, State, and Movement routing; +`LiveEntitySameGenerationUpdateRouter` owns the retail equal-generation +CreateObject tail. `LiveEntityInboundAuthorityGate` pins the exact record, +independent channel authority, and velocity authority before any reentrant App +callback. `LiveEntityMotionRuntimeController` owns the shared physics-host, +Setup, MoveTo/Sticky, Hidden-target, and server-controlled-cycle policy behind +a once-bound construction bridge. ForcePosition preserves retail's +blip-then-immediate-F7B1 order and stamps the exact Position snapshot serialized +on the wire. Focused tests cover stale/equal/wrapped timestamps, callback +replacement, GUID reuse, projectile/body/remote Vector precedence, mixed +same-generation routing, bridge binding, and the exact 56-byte autonomous +position payload. All three corrected-diff re-reviews were clean. + ### G — closeout - Structural tests prove both controllers lack identity dictionaries and @@ -358,6 +372,16 @@ skips and no errors; all three corrected-diff re-reviews were clean. ledger, `AGENTS.md`/`CLAUDE.md` when current-state pointers change, and durable GameWindow/live-runtime memory. +Completed 2026-07-21. `GameWindow` fell from the Slice-3 baseline of 14,310 +lines to 10,301 lines; its top-level field and method declarations are now 267 +and 163. Release build completed with zero warnings/errors and the full suite +passed 6,940 tests with five intentional skips. The 310-second connected R6 +route passed login, Caul/Sawato/Rynthid/Aerlinthe/Sawato-revisit/Holtburg/ +Caul-return materialization, turn sampling, three locomotion/jump/combat +exercises, and graceful close. Live-server population churn, previously known +missing DAT VFX diagnostics, and empty world-edge landblocks remained warnings, +not lifecycle failures. Slice 5 is the next structural owner boundary. + ## 5. Adversarial acceptance matrix Automated acceptance includes: From 40012510640a560242b0951bd054089cdec34d36 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 19:33:07 +0200 Subject: [PATCH 53/98] docs(plan): define GameWindow slice 5 --- docs/architecture/code-structure.md | 4 + ...mewindow-slice-5-landblock-presentation.md | 362 ++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index a9ddf3cd..c32b9a1b 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -546,6 +546,10 @@ Slice 5 is active next. #### Slice 5 — extract landblock presentation +Detailed execution ledger: +[`docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md`](../plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md) +(active). + Create `LandblockPresentationPipeline` around the existing immutable `LandblockBuild` transaction. It owns DAT build context, scenery/EnvCell construction, mesh/collision/light publication, demotion/full-retirement, and diff --git a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md new file mode 100644 index 00000000..e274be97 --- /dev/null +++ b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md @@ -0,0 +1,362 @@ +# GameWindow Slice 5 — landblock presentation + +**Status:** Active 2026-07-21. +**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 5. +**Baseline:** `2b10e91e`; `GameWindow.cs` is 10,301 lines, 267 fields, and +163 methods. Release baseline: 6,940 passed / 5 skipped. The connected R6 +baseline passed seven destinations/revisits, three movement/jump/combat +exercises, and graceful close in 310 seconds. +**Behavior rule:** Preserve the accepted two-tier streaming quality, immutable +per-job EnvCell transaction, render-readiness barrier, collision/light/script/ +plugin lifetime, and retryable detach-first retirement. This is an ownership +move. Any proven defect needed to make that boundary safe gets its own oracle, +test, and bisectable commit rather than being hidden in a mechanical move. + +## Progress ledger + +- [ ] A — freeze production order and introduce explicit build/publication + seams plus structural guards. +- [ ] B — capture one immutable world-origin snapshot per load and extract the + serialized DAT-backed `LandblockBuildFactory`. +- [ ] C — extract terrain, visibility, EnvCell, building, and render-pinning + publication. +- [ ] D — extract physics/cache/collision publication and cross-landblock + reflood ownership. +- [ ] E — extract static lighting, translucency, default-script, and plugin + presentation; compose the small `LandblockPresentationPipeline`. +- [ ] F — move retryable demotion/full-retirement presentation under the + pipeline without changing `LandblockRetirementCoordinator`'s ledger. +- [ ] G — cut `StreamingController` and `GameWindow` directly to the pipeline; + delete old bodies, scratch state, and callback facades. +- [ ] H — three-agent corrected-diff review, full Release suite, deterministic + lifecycle gate, seven-destination resource soak, documentation, and durable + memory closeout. + +Every checked checkpoint is committed as a bisectable architectural unit. +Documentation records the exact commit and accepted test count as work lands. + +## 1. Outcome and non-goals + +At slice exit, `GameWindow` only composes and retains the landblock presentation +owner. It no longer builds a landblock from DAT records, hydrates scenery or +interiors, constructs a physics DAT bundle, publishes terrain/cells/collision/ +lights/plugins, or advances presentation retirement. + +The final owner graph is deliberately not a replacement god object: + +```text +StreamingController (tier, region, generation, priority, budget) + │ accepted Loaded / Promoted / Unloaded result + ▼ +LandblockPresentationPipeline (transaction order only) + ├── LandblockBuildFactory worker-only DAT transaction + ├── LandblockRenderPublisher terrain/cells/EnvCell/building/render pins + ├── LandblockPhysicsPublisher terrain/cell/portal/static collision + ├── LandblockStaticPresentation light/translucency/script/plugin projection + └── LandblockRetirementCoordinator detach-first retry ledger + │ + ▼ +GpuWorldState (spatial buckets, pending live projections, tier, AABB) +``` + +`StreamingController` remains the sole desired-tier and stale-generation +authority. `GpuWorldState` remains the sole spatial-bucket and retained-live- +projection owner. `LandblockStreamer` remains the sole worker queue. Existing +renderers, physics/cache registries, lighting, scripts, plugin state, and +`WorldRevealCoordinator` remain independent owners. + +This slice does not change Near/Far radii, mesh or texture quality, world view +distance, portal visibility, GPU frame fences, the draw graph, live-entity +identity, or the update/render phase order. It does not re-port algorithms +already inventoried under `Rendering/Wb` or introduce another DAT reader. + +## 2. Retail, WorldBuilder, and shipped oracles + +The extraction preserves the following retail order and already-shipped +adaptations: + +- `LScape::grab_visible_cells @ 0x00504EC0`: initialize buildings and gather + visible cells before static/dynamic activation; +- `CLandBlock::init_buildings @ 0x0052FD80`; +- `CLandBlock::grab_visible_cells @ 0x0052F460`; +- `CLandBlock::init_static_objs @ 0x00530A40` and + `CLandBlock::init_dyn_objs @ 0x0052F3B0`; +- `CLandBlock::destroy_static_objects @ 0x0052FA50`, + `CLandBlock::Destroy @ 0x0052FAA0`, and + `CLandBlock::release_all @ 0x0052FCF0` for static/building/cell teardown; +- `CObjCell::init_objects @ 0x0052B420` → + `CPhysicsObj::recalc_cross_cells @ 0x00515A30`; acdream's per-landblock + `RefloodLandblock` remains the registered asynchronous adaptation; +- `PView::InitCell @ 0x005A4B70` and + `CEnvCell::find_visible_child_cell @ 0x0052DC50`; +- `CBuildingObj::find_building_collisions @ 0x006B5300`; +- `CPhysicsObj::add_shadows_to_cells @ 0x00514AE0`; +- `SmartBox::UseTime @ 0x00455410`; acdream's shared reveal coordinator is the + asynchronous equivalent of retail's synchronous cell readiness; +- [`worldbuilder-inventory.md`](../architecture/worldbuilder-inventory.md), + especially the one-job/one-complete-EnvCell-transaction and sole-DAT-reader + rules; +- the current render and two-tier streaming digests, including logical + withdrawal before fence-delayed GPU reuse and the prohibition on reducing + view distance as an optimization; +- [`2026-07-13-envcell-landblock-transaction.md`](../research/2026-07-13-envcell-landblock-transaction.md) + and + [`2026-07-18-retail-texture-resource-lifetime-pseudocode.md`](../research/2026-07-18-retail-texture-resource-lifetime-pseudocode.md). + +The named retail decomp is the oracle for lifecycle ordering. The extracted +WorldBuilder code is the oracle for already-ported terrain, scenery, EnvCell, +mesh, and texture mechanics. ACE and the retained WorldBuilder reference tree +are cross-checks, not new dependencies. + +## 3. Load-bearing invariants + +### 3.1 Worker build + +1. One `Load` captures landblock ID, job kind, generation, and the accepted + world-origin center on the update thread. The worker never reads the + update-thread-confined `LiveWorldOriginState`. +2. All DAT reads for one build execute under the existing single-reader gate. + The worker performs CPU work only: no GL call, renderer/physics publication, + registry mutation, or shared pending cell bag. +3. Far reads the heightmap only and emits no static entities, EnvCells, or + physics bundle. Near/Promote emits hydrated stabs, procedural scenery, + interiors, a complete `PhysicsDatBundle`, and one immutable + `EnvCellLandblockBuild`. +4. Every valid EnvCell is present in the visibility transaction even when it + has zero drawable shell submeshes. Geometry IDs retain their current + deterministic arithmetic. +5. Missing records keep the current skip/null semantics. A failed build has no + externally visible partial publication. + +### 3.2 Publication + +For an accepted Near load or promotion, the transaction order remains: + +1. publish terrain and rebase it to the captured origin; +2. commit the complete cell-visibility snapshot and AABB; +3. populate physics data and publish terrain/cells/portals/buildings; +4. commit building registries and the EnvCell renderer transaction; +5. publish static BSP/cylinder collision, lights, translucency, and DAT-static + plugin snapshots; reflood the landblock; +6. publish/merge the canonical spatial bucket in `GpuWorldState`, acquiring + ordinary and synthetic EnvCell render IDs and activating static defaults; +7. only after the pins exist, replay schema-aware EnvCell mesh preparation; +8. only after spatial publication, restore retained live projections. + +Render readiness means Near tier plus real uploaded ownership for every +required ordinary and synthetic EnvCell render ID. Dictionary publication is +not readiness. Duplicate Near completion is a no-op; stale Far cannot erase a +Near layer. Promotion-before-base and the accepted Near-completion-while-Far- +desired synthesis remain `StreamingController` policy. + +### 3.3 Demotion and retirement + +1. `GpuWorldState.DetachNearLayer` or `DetachLandblock` commits first. No + presentation cleanup failure can resurrect spatial membership. +2. Near→Far keeps terrain and all live projections, but removes DAT statics, + static scripts/classification, lights/translucency/plugins, indoor physics, + cell visibility, building registries, and EnvCells. +3. Full retirement additionally releases terrain and full landblock physics. + Persistent player projection is rescued and still-live server projections + are parked for rebucketing with the same identity. +4. The retryable order is exactly: mesh references → static scripts → + classification → entity lights → entity translucency → DAT-static plugin + projection → terrain for Full only → physics remove/demote → cell visibility + → building registry → EnvCell renderer. +5. Successful stages and entity cursors never replay. Same-ID replacement is + fenced until every required stage converges, including across controller or + radius reconfiguration. +6. GPU range/layer reuse remains fence-delayed by `GpuFrameFlightController`. + +## 4. Architecture and interfaces + +### 4.1 Immutable build request + +Introduce a request equivalent to: + +```csharp +public readonly record struct LandblockBuildRequest( + uint LandblockId, + LandblockStreamJobKind Kind, + ulong Generation, + int OriginCenterX, + int OriginCenterY); +``` + +The generation remains for matching and diagnostics; the builder never uses it +to decide residency. Results carry the same origin snapshot so apply cannot +combine worker geometry from one center with render-thread placement from a +later center. + +### 4.2 DAT build factory + +`LandblockBuildFactory` receives `IDatReaderWriter`, the one shared DAT gate, +the immutable height table, and worker-safe mesh/physics-cache collaborators. +Its public surface is one operation: + +```csharp +LandblockBuild? Build(in LandblockBuildRequest request); +``` + +The extracted `BuildPhysicsDatBundle`, stab hydration, scenery generation, +interior/EnvCell construction, and terrain sampling remain private focused +helpers. No method accepts `GameWindow`, `LiveWorldOriginState`, GL objects, or +presentation registries. + +### 4.3 Focused publishers + +`LandblockRenderPublisher` owns terrain slot publication, AABB, cell +visibility, building registries, EnvCell commit, static render pin/preparation, +and a typed immutable diagnostics snapshot. + +`LandblockPhysicsPublisher` owns cache population, terrain/cell/portal/building +physics, static multipart BSP and exclusive Setup-cylinder/sphere fallback, +demotion/full removal, and final cross-cell reflood. + +`LandblockStaticPresentationPublisher` owns idempotent static light and +translucency replacement, static script activation/removal at the existing +spatial registration boundary, and DAT-static plugin spawn/removal projection. + +The pipeline calls concrete publishers in the fixed order. Publish/retire +interfaces expose no DAT reader. Diagnostics move with the owner and the Debug +UI reads a snapshot rather than `GameWindow` scratch counters. + +### 4.4 Policy cutover + +`StreamingController` keeps result validation and calls the pipeline only after +generation/tier/duplicate/retirement-fence checks. It must not gain DAT, GL, +physics, plugin, light, or live-GUID state. `GpuWorldState` keeps its buckets, +pending Near/render-ID parking, and live rescue. Existing hidden presentation +side effects may be made explicit only when tests prove identical ordering and +balance. + +`GameWindow` retains construction/wiring plus one pipeline field. Structural +tests forbid these methods from returning: + +```text +BuildLandblockForStreaming +BuildLandblockForStreamingLocked +BuildPhysicsDatBundle +BuildSceneryEntitiesForStreaming +BuildInteriorEntitiesForStreaming +EnsureEnvCellMeshesAfterPin +ApplyLoadedTerrain +ApplyLoadedTerrainLocked +AdvanceLandblockPresentationRetirement +``` + +## 5. Checkpoint execution + +### A — characterization and seams + +- Add owner-call trace tests around the current production order and payload + multiplicity. +- Introduce immutable build/publication request/result seams and diagnostics + snapshots without changing the live call graph. +- Add dependency/reflection guards: no origin-state read from worker, no DAT in + publish/retire interfaces, no residency/generation map in the pipeline. + +### B — origin snapshot and build factory + +- Capture origin with every enqueued load; preserve it through priority queue, + completion, promotion, and Far synthesis. +- Move the complete serialized DAT build verbatim into + `LandblockBuildFactory`; keep `DatCollection` access behind + `IDatReaderWriter` and the shared gate. +- Test Far/Near payload shape, repeat-build determinism, EnvCell completeness, + physics-DAT closure, missing records, lock serialization, and zero DAT reads + during publication. + +### C — render publication + +- Extract terrain upload/rebase, CellVisibility, AABB, building registry, + EnvCell renderer commit, render-ID pin/preparation, and diagnostics. +- Test visibility-only cells, zero-submesh EnvCells, promotion-before-base, + real render readiness, reapply idempotency, and release balance. + +### D — physics publication + +- Extract physics cache commit, terrain/cell/portal/building setup, static + multipart BSP/cylinder registration, reflood, demote, and full removal. +- Use two adjacent landblocks to prove one retirement cannot erase a neighbor's + seam collision. Pin Far terrain-only, Near reapply, promotion, demotion, and + full unload symmetry. + +### E — static presentation and transaction coordinator + +- Extract static lights/translucency/plugin projection and make existing static + script ownership explicit without replaying defaults. +- Compose the small `LandblockPresentationPipeline`; add exact order, + multiplicity, rollback/blocked-publication, and owner-balance tests. + +### F — retirement + +- Move production retirement callbacks under the pipeline while keeping + `LandblockRetirementCoordinator` as the exact retry ledger. +- Inject failure before/after each boundary; prove only unfinished suffixes and + exact entity cursors resume. Test Near and Full requirements separately. + +### G — direct cutover and cleanup + +- Route `LandblockStreamer` build and `StreamingController` apply/retire calls + directly to the new owners. +- Delete the old GameWindow bodies, callback facades, building-registry and + apply-diagnostic scratch state that now have focused owners. +- Add structural source tests and remeasure GameWindow lines/fields/methods. + +### H — closeout + +- Run focused App/Core/Net tests after each checkpoint. +- After each code checkpoint, run three independent read-only reviews: retail/ + WorldBuilder conformance, architecture/integration, and adversarial failure + analysis. Fix confirmed findings and repeat review until clean. +- Run `dotnet build AcDream.slnx -c Release` and the full Release test suite. +- Run deterministic first-login/portal lifecycle automation and the connected + seven-destination resource soak. Verify retirement/preparation queues drain, + residence matches the region, resource counts remain bounded, and graceful + close succeeds. +- Update the divergence register pointers in IA-14, AD-2, AD-22, and any other + row whose cited ownership moved. Keep AD-6, AD-24, AP-31, and TS-52 unless a + separate retail mechanism actually lands. +- Update architecture, milestones, roadmap, issues if needed, synchronized + `AGENTS.md`/`CLAUDE.md`, and durable decomposition/render memory. + +## 6. Acceptance matrix + +Automated acceptance includes: + +- Far heightmap-only and complete immutable Near/Promote payloads; +- deterministic static/scenery/interior IDs, bounds, offsets, cells, and shell + geometry IDs across repeated builds; +- single-reader DAT gate and no publish-time DAT access; +- exact owner-call sequence for load, promotion, Far synthesis, demotion, full + unload, replacement, and failure retry; +- stale/duplicate completions invoke no presentation participant; +- complete render pins and real readiness before reveal; +- static lights/translucency/scripts/plugins and mesh references remain + balanced through load → reapply → demote → promote → full unload; +- adjacent-landblock collision survives one neighbor's retirement; +- first login with unknown origin starts no build, and stale placeholder/old- + center completions cannot publish or satisfy reveal; +- reflection/source gates keep moved methods and scratch ownership out of + `GameWindow` and keep policy/state out of the pipeline. + +Connected acceptance includes fresh login, outdoor travel, world-edge empty +blocks, dungeon travel, same-location revisit, turn sampling at every stop, +movement/jump/combat exercises, portal reveal, and graceful close. No view- +distance or quality reduction is an acceptable way to pass the resource gate. + +## 7. Known risks and explicit traps + +- A worker must not read a later live origin than the job it is building. +- A Near promotion may complete before its Far base; parking behavior and + required render IDs must survive unchanged. +- Duplicate Near/ForceReload must not stack lights, scripts, plugin events, + meshes, collision, or EnvCells. +- Demotion must never retire live entities or terrain. +- Publication must not be mistaken for uploaded render readiness. +- Failed retirement must survive controller/radius replacement. +- Do not move `WbDrawDispatcher` or any draw-graph body; that is Slice 7. +- Do not reintroduce worker-side `RegisterCell`, global pending renderer lists, + legacy rendering, or another DAT reader. +- Do not reduce Near/Far radii, texture detail, or mesh quality. From 4f965d0699a15a5f2b2c1da556fce2fd9353bb49 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 19:55:24 +0200 Subject: [PATCH 54/98] refactor(streaming): own landblock publication transactions --- src/AcDream.App/Rendering/GameWindow.cs | 21 +- src/AcDream.App/Streaming/GpuWorldState.cs | 169 ++++- .../LandblockPresentationPipeline.cs | 299 ++++++++ .../Streaming/StreamingController.cs | 223 +++--- .../LandblockPresentationPipelineTests.cs | 711 ++++++++++++++++++ 5 files changed, 1301 insertions(+), 122 deletions(-) create mode 100644 src/AcDream.App/Streaming/LandblockPresentationPipeline.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2e522bc7..50b6db03 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4608,7 +4608,26 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; if (_frameDiag) _applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0; - ApplyLoadedTerrainLocked(build, meshData); + try + { + ApplyLoadedTerrainLocked(build, meshData); + } + catch (AcDream.App.Streaming.StreamingMutationException) + { + throw; + } + catch (Exception error) + { + // This legacy body spans multiple concrete owners and cannot yet + // identify an exact failed suffix. Conservatively report that a + // prefix may have committed so the transaction coordinator never + // replays terrain/cells/collision/lights/plugin events. Slice-5 + // checkpoints C-E replace this wrapper with per-owner receipts. + throw new AcDream.App.Streaming.StreamingMutationException( + $"Landblock 0x{lb.LandblockId:X8} presentation failed after an unknown commit point.", + mutationCommitted: true, + error); + } if (_frameDiag) { _applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0; diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 23a05e2f..354e8392 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -8,6 +8,18 @@ using AcDream.Core.World; namespace AcDream.App.Streaming; +/// +/// Exact result of one committed landblock bucket mutation. Fallible mesh-pin +/// and static-script reconciliation consume this receipt separately, so a +/// callback failure cannot force the spatial mutation to replay. +/// +internal sealed record GpuLandblockSpatialPublication( + uint LandblockId, + LoadedLandblock Landblock, + IReadOnlyList AdditionalRenderIds, + IReadOnlyList StaticEntities, + bool RequiresActivation = true); + /// /// Render-thread-owned registry of currently-loaded landblocks and their /// entities. All mutation happens in @@ -619,12 +631,44 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery LandblockStreamTier tier = LandblockStreamTier.Near) { using MutationBatch mutation = BeginMutationBatch(); + GpuLandblockSpatialPublication publication = + CommitLandblockSpatialCore(landblock, additionalRenderIds, tier); + ActivateLandblockPresentation(publication); + } + + /// + /// Commits only canonical buckets, indexes, pending-live merge, tier, and + /// readiness-id ownership. The caller holds one outer + /// through the subsequent + /// call so live visibility + /// observers retain the shipped commit boundary. + /// + internal GpuLandblockSpatialPublication CommitLandblockSpatial( + LoadedLandblock landblock, + IEnumerable? additionalRenderIds, + LandblockStreamTier tier) => + CommitLandblockSpatialCore(landblock, additionalRenderIds, tier); + + private GpuLandblockSpatialPublication CommitLandblockSpatialCore( + LoadedLandblock landblock, + IEnumerable? additionalRenderIds, + LandblockStreamTier tier) + { + ArgumentNullException.ThrowIfNull(landblock); // A stale Far completion must never replace a newer Near entity layer. // StreamingController normally filters it before render-side apply; // keep the state boundary independently monotonic as well. if (tier == LandblockStreamTier.Far && IsNearTier(landblock.LandblockId)) - return; + { + LoadedLandblock retained = _loaded[landblock.LandblockId]; + return new GpuLandblockSpatialPublication( + retained.LandblockId, + retained, + Array.Empty(), + Array.Empty(), + RequiresActivation: false); + } // If pending live entities have been waiting for this landblock, // merge them into the render-thread-owned mutable entity bucket before @@ -668,26 +712,53 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _loaded[landblock.LandblockId] = landblock; AddLoadedLandblockToFlatView(landblock); _tierByLandblock[landblock.LandblockId] = tier; - if (_wbSpawnAdapter is not null) - _wbSpawnAdapter.OnLandblockLoaded( - _loaded[landblock.LandblockId], - mergedRenderIds); + return CreateSpatialPublication( + _loaded[landblock.LandblockId], + (IEnumerable?)mergedRenderIds ?? Array.Empty()); + } - // C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0). - // LiveEntityRuntime owns activation for live objects. This static-only - // filter avoids replaying their defaults when a pending projection is - // drained into a newly loaded landblock. - if (_entityScriptActivator is not null) + internal void ActivateLandblockPresentation( + GpuLandblockSpatialPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (!publication.RequiresActivation) + return; + if (!_loaded.TryGetValue(publication.LandblockId, out LoadedLandblock? current) + || !ReferenceEquals(current, publication.Landblock)) { - var loadedEntities = _loaded[landblock.LandblockId].Entities; - for (int i = 0; i < loadedEntities.Count; i++) - { - var e = loadedEntities[i]; - if (e.ServerGuid == 0) - _entityScriptActivator.OnCreate(e); - } + throw new InvalidOperationException( + $"Landblock 0x{publication.LandblockId:X8} changed before presentation activation."); } + _wbSpawnAdapter?.OnLandblockLoaded( + publication.Landblock, + publication.AdditionalRenderIds); + + // C.1.5b: fire DefaultScript for dat-hydrated entities only. + // EntityScriptActivator owns a retryable per-static acquisition ledger, + // so replaying this activation suffix after a later owner failure does + // not replay already-started defaults. + if (_entityScriptActivator is not null) + { + for (int i = 0; i < publication.StaticEntities.Count; i++) + _entityScriptActivator.OnCreate(publication.StaticEntities[i]); + } + } + + private static GpuLandblockSpatialPublication CreateSpatialPublication( + LoadedLandblock landblock, + IEnumerable additionalRenderIds) + { + ulong[] renderIds = additionalRenderIds as ulong[] + ?? additionalRenderIds.ToArray(); + WorldEntity[] staticEntities = landblock.Entities + .Where(static entity => entity.ServerGuid == 0) + .ToArray(); + return new GpuLandblockSpatialPublication( + landblock.LandblockId, + landblock, + renderIds, + staticEntities); } /// @@ -1228,11 +1299,50 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery IEnumerable? additionalRenderIds = null) { using MutationBatch mutation = BeginMutationBatch(); + GpuLandblockSpatialPublication? publication = + CommitEntitiesToExistingLandblockSpatialCore( + landblockId, + entities, + additionalRenderIds, + parkIfMissing: true); + if (publication is null) + return false; + + ActivateLandblockPresentation(publication); + return true; + } + + internal GpuLandblockSpatialPublication CommitEntitiesToExistingLandblockSpatial( + uint landblockId, + IReadOnlyList entities, + IEnumerable? additionalRenderIds) + { + GpuLandblockSpatialPublication? publication = + CommitEntitiesToExistingLandblockSpatialCore( + landblockId, + entities, + additionalRenderIds, + parkIfMissing: false); + return publication + ?? throw new InvalidOperationException( + $"Landblock 0x{landblockId:X8} is not resident for an accepted promotion."); + } + + private GpuLandblockSpatialPublication? CommitEntitiesToExistingLandblockSpatialCore( + uint landblockId, + IReadOnlyList entities, + IEnumerable? additionalRenderIds, + bool parkIfMissing) + { + ArgumentNullException.ThrowIfNull(entities); // A.5 T14 follow-up: canonicalize for symmetry with live projection placement. uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (!_loaded.TryGetValue(canonical, out var lb)) { + if (!parkIfMissing) + return null; + // Park as pending — same pattern as live projections for not-yet-loaded LBs. if (!_pendingByLandblock.TryGetValue(canonical, out var bucket)) { @@ -1263,7 +1373,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery } renderIds.UnionWith(additionalRenderIds); } - return false; + return null; } List resident = MutableEntities(lb); for (int i = 0; i < entities.Count; i++) @@ -1278,20 +1388,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery } _animatedIndexByLandblock.Remove(canonical); _tierByLandblock[canonical] = LandblockStreamTier.Near; - if (_wbSpawnAdapter is not null) - _wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical], additionalRenderIds); - - // C.1.5b: fire DefaultScript for each promoted dat-hydrated entity. - // All entities arriving via this path are atlas-tier by construction - // (the promotion path streams in dat-static scenery + EnvCell statics - // + stabs per the method's class doc-comment). - if (_entityScriptActivator is not null) - { - for (int i = 0; i < entities.Count; i++) - _entityScriptActivator.OnCreate(entities[i]); - } - - return true; + ulong[] effectiveRenderIds = additionalRenderIds?.ToArray() ?? Array.Empty(); + WorldEntity[] staticEntities = entities + .Where(static entity => entity.ServerGuid == 0) + .ToArray(); + return new GpuLandblockSpatialPublication( + canonical, + _loaded[canonical], + effectiveRenderIds, + staticEntities); } private void DrainVisibilityTransitions() diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs new file mode 100644 index 00000000..24fb3888 --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -0,0 +1,299 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Core.Terrain; +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +/// +/// Render-thread transaction coordinator for one accepted landblock streaming +/// result. Streaming policy (desired tier, generation, priority, and apply +/// budget) remains in ; spatial buckets remain +/// in . This owner fixes the presentation ordering +/// independently of those policies so the concrete render, physics, and static +/// publishers can be extracted behind it without returning callbacks to the +/// game window. +/// +/// +/// Retail establishes cells/buildings before static objects in +/// LScape::grab_visible_cells @ 0x00504EC0. Its release path separates +/// CLandBlock::release_all @ 0x0052FCF0 (objects and visible cells) from +/// later destruction in CLandBlock::Destroy @ 0x0052FAA0 (static objects +/// and buildings). Acdream's detach-first retry ledger is the existing +/// asynchronous lifetime adaptation. The injected publication operation +/// currently contains the already-shipped production transaction; later +/// Slice-5 checkpoints replace it with focused publishers. +/// +public sealed class LandblockPresentationPipeline +{ + private enum PublicationKind : byte + { + Loaded, + PromoteExisting, + PromoteSelfContained, + Far, + } + + private sealed class PublicationTransaction + { + public required PublicationKind Kind; + public required LandblockBuild Build; + public required LandblockMeshData MeshData; + public required uint LandblockId; + public LandblockStreamTier Tier; + public bool PresentationCommitted; + public bool SpatialCommitted; + public GpuLandblockSpatialPublication? SpatialPublication; + public bool SpatialPresentationCommitted; + public bool EnvCellReplayCommitted; + public bool LiveRecoveryCommitted; + } + + private readonly Action _publishBeforeSpatialCommit; + private readonly GpuWorldState _state; + private readonly Action? _onLandblockLoaded; + private readonly Action? _ensureEnvCellMeshes; + private readonly LandblockRetirementCoordinator _retirements; + private readonly Dictionary _publications = + new(ReferenceEqualityComparer.Instance); + + public LandblockPresentationPipeline( + Action publishBeforeSpatialCommit, + GpuWorldState state, + Action? onLandblockLoaded = null, + Action? ensureEnvCellMeshes = null, + LandblockRetirementCoordinator? retirementCoordinator = null, + Action? removeTerrain = null, + Action? demoteNearLayer = null) + { + ArgumentNullException.ThrowIfNull(publishBeforeSpatialCommit); + ArgumentNullException.ThrowIfNull(state); + + _publishBeforeSpatialCommit = publishBeforeSpatialCommit; + _state = state; + _onLandblockLoaded = onLandblockLoaded; + _ensureEnvCellMeshes = ensureEnvCellMeshes; + _retirements = retirementCoordinator + ?? LandblockRetirementCoordinator.CreateLegacy( + state, + removeTerrain, + demoteNearLayer); + } + + public int PendingRetirementCount => _retirements.PendingCount; + public int PendingPublicationCount => _publications.Count; + + public bool IsRetirementPending(uint landblockId) => + _retirements.IsPending(landblockId); + + public bool HasPendingPublication(LandblockStreamResult result) + { + ArgumentNullException.ThrowIfNull(result); + return _publications.ContainsKey(result); + } + + public IReadOnlyList GetPendingPublicationResults() => + _publications.Keys.ToArray(); + + public void ResumePublication(LandblockStreamResult result) + { + ArgumentNullException.ThrowIfNull(result); + if (!_publications.TryGetValue(result, out PublicationTransaction? transaction)) + return; + Advance(result, transaction); + } + + public void AdvanceRetirements() => _retirements.Advance(); + + public void BeginFullRetirement(uint landblockId) => + _retirements.BeginFull(landblockId); + + public void BeginNearLayerRetirement(uint landblockId) => + _retirements.BeginNearLayer(landblockId); + + public void PublishLoaded(LandblockStreamResult.Loaded loaded) + { + ArgumentNullException.ThrowIfNull(loaded); + PublicationTransaction transaction = GetOrCreate( + loaded, + PublicationKind.Loaded, + loaded.Build, + loaded.MeshData, + loaded.LandblockId, + loaded.Tier); + Advance(loaded, transaction); + } + + public void PublishPromoted( + LandblockStreamResult.Promoted promoted, + bool mergeIntoExistingLandblock) + { + ArgumentNullException.ThrowIfNull(promoted); + PublicationKind kind = mergeIntoExistingLandblock + ? PublicationKind.PromoteExisting + : PublicationKind.PromoteSelfContained; + PublicationTransaction transaction = GetOrCreate( + promoted, + kind, + promoted.Build, + promoted.MeshData, + promoted.LandblockId, + LandblockStreamTier.Near); + Advance(promoted, transaction); + } + + public void PublishAsFar( + LandblockStreamResult acceptedResult, + LandblockBuild completedBuild, + LandblockMeshData meshData) + { + ArgumentNullException.ThrowIfNull(acceptedResult); + ArgumentNullException.ThrowIfNull(completedBuild); + ArgumentNullException.ThrowIfNull(meshData); + + if (!_publications.TryGetValue(acceptedResult, out PublicationTransaction? transaction)) + { + LoadedLandblock completed = completedBuild.Landblock; + var farLandblock = new LoadedLandblock( + completed.LandblockId, + completed.Heightmap, + Array.Empty(), + PhysicsDatBundle.Empty); + transaction = new PublicationTransaction + { + Kind = PublicationKind.Far, + Build = new LandblockBuild(farLandblock), + MeshData = meshData, + LandblockId = farLandblock.LandblockId, + Tier = LandblockStreamTier.Far, + }; + _publications.Add(acceptedResult, transaction); + } + else if (transaction.Kind != PublicationKind.Far) + { + throw new InvalidOperationException( + $"Landblock publication 0x{transaction.LandblockId:X8} changed kind while pending."); + } + + Advance(acceptedResult, transaction); + } + + private PublicationTransaction GetOrCreate( + LandblockStreamResult result, + PublicationKind kind, + LandblockBuild build, + LandblockMeshData meshData, + uint landblockId, + LandblockStreamTier tier) + { + if (_publications.TryGetValue(result, out PublicationTransaction? existing)) + { + if (existing.Kind != kind + && !(existing.Kind is PublicationKind.PromoteExisting + or PublicationKind.PromoteSelfContained + && kind is PublicationKind.PromoteExisting + or PublicationKind.PromoteSelfContained)) + { + throw new InvalidOperationException( + $"Landblock publication 0x{existing.LandblockId:X8} changed kind while pending."); + } + return existing; + } + + var created = new PublicationTransaction + { + Kind = kind, + Build = build, + MeshData = meshData, + LandblockId = landblockId, + Tier = tier, + }; + _publications.Add(result, created); + return created; + } + + private void Advance( + LandblockStreamResult result, + PublicationTransaction transaction) + { + if (!transaction.PresentationCommitted) + { + try + { + _publishBeforeSpatialCommit(transaction.Build, transaction.MeshData); + } + catch (Exception error) + { + // The legacy GameWindow callback still spans several concrete + // owners and cannot report a committed prefix. Until Slice-5 + // checkpoints C-E replace it with individually ledgered + // publishers, its production wrapper conservatively reports a + // committed mutation. Test/compatibility callbacks retain the + // established strong-exception contract unless they explicitly + // report otherwise. + if (error is StreamingMutationException { MutationCommitted: true }) + _publications.Remove(result); + throw; + } + transaction.PresentationCommitted = true; + } + + if (!transaction.SpatialPresentationCommitted) + { + // Keep live-projection visibility publication at the shipped + // boundary: bucket mutation, pin/script reconciliation, then the + // outer spatial observer commit. If pinning throws, the receipt is + // retained and only that suffix retries. + using GpuWorldState.MutationBatch mutation = _state.BeginMutationBatch(); + if (!transaction.SpatialCommitted) + { + IEnumerable? renderIds = + transaction.Build.EnvCells?.Shells.Select(static shell => shell.GeometryId); + transaction.SpatialPublication = transaction.Kind switch + { + PublicationKind.Loaded + or PublicationKind.PromoteSelfContained + or PublicationKind.Far => + _state.CommitLandblockSpatial( + transaction.Build.Landblock, + renderIds, + transaction.Tier), + PublicationKind.PromoteExisting => + _state.CommitEntitiesToExistingLandblockSpatial( + transaction.LandblockId, + transaction.Build.Landblock.Entities, + renderIds), + _ => throw new InvalidOperationException( + $"Unknown landblock publication kind {transaction.Kind}."), + }; + transaction.SpatialCommitted = true; + } + + _state.ActivateLandblockPresentation( + transaction.SpatialPublication + ?? throw new InvalidOperationException( + "A committed spatial publication has no activation receipt.")); + transaction.SpatialPresentationCommitted = true; + } + + if (!transaction.EnvCellReplayCommitted) + { + if (transaction.Kind != PublicationKind.Far + && transaction.Build.EnvCells is { Shells.Length: > 0 } envCells) + { + _ensureEnvCellMeshes?.Invoke(envCells); + } + transaction.EnvCellReplayCommitted = true; + } + + if (!transaction.LiveRecoveryCommitted) + { + // Retained live projections can materialize only after the + // canonical bucket exists. Landblock load never reconstructs their + // identity or replays create-time resources. + _onLandblockLoaded?.Invoke(transaction.LandblockId); + transaction.LiveRecoveryCommitted = true; + } + + _publications.Remove(result); + } +} diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 69bb8760..9805d96f 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -21,24 +21,9 @@ public sealed class StreamingController private readonly Action _enqueueLoad; private readonly Action _enqueueUnload; private readonly Func> _drainCompletions; - private readonly Action _applyTerrain; private readonly Action? _clearPendingLoads; - private readonly LandblockRetirementCoordinator _retirements; + private readonly LandblockPresentationPipeline _presentation; - /// - /// #138: fired after a landblock's entity layer (re)loads — both the full - /// path (initial / dungeon-exit - /// expand) and the path - /// (Far→Near). GameWindow wires it to re-hydrate server objects whose - /// render entities were dropped by the collapse/demote but whose parsed - /// spawns it still retains. Receives the canonical landblock id. Null in - /// tests that don't exercise re-hydration. - /// - private readonly Action? _onLandblockLoaded; - // Invoked only after LandblockSpawnAdapter has pinned every synthetic - // EnvCell geometry id. This re-arms the schema-aware preparation path if - // an unowned cached mesh was evicted between worker schedule and publish. - private readonly Action? _ensureEnvCellMeshes; private readonly GpuWorldState _state; private StreamingRegion? _region; private RadiiReconfiguration? _pendingRadiiReconfiguration; @@ -194,16 +179,16 @@ public sealed class StreamingController _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; - _applyTerrain = applyTerrain; _clearPendingLoads = clearPendingLoads; - _onLandblockLoaded = onLandblockLoaded; - _ensureEnvCellMeshes = ensureEnvCellMeshes; _state = state; - _retirements = retirementCoordinator - ?? LandblockRetirementCoordinator.CreateLegacy( - state, - removeTerrain, - demoteNearLayer); + _presentation = new LandblockPresentationPipeline( + applyTerrain, + state, + onLandblockLoaded, + ensureEnvCellMeshes, + retirementCoordinator, + removeTerrain, + demoteNearLayer); NearRadius = nearRadius; FarRadius = farRadius; } @@ -249,6 +234,12 @@ public sealed class StreamingController if (nearRadius == NearRadius && farRadius == FarRadius) return; + // A staged old-window publication must either finish or fail before + // the desired-tier snapshot below is computed. Otherwise a landblock + // that becomes spatially resident during convergence is absent from + // the reconfiguration mutation ledger. + ConvergePendingPublications(); + if (_collapsed || _region is null) { NearRadius = nearRadius; @@ -348,14 +339,44 @@ public sealed class StreamingController private void DemoteLandblock(uint id) { uint canonical = (id & 0xFFFF0000u) | 0xFFFFu; - _retirements.BeginNearLayer(canonical); + _presentation.BeginNearLayerRetirement(canonical); } private void AdvanceGeneration() { + ConvergePendingPublications(); _generation = unchecked(_generation + 1); } + private void ConvergePendingPublications() + { + IReadOnlyList pending = + _presentation.GetPendingPublicationResults(); + if (pending.Count == 0) + return; + + try + { + for (int i = 0; i < pending.Count; i++) + _presentation.ResumePublication(pending[i]); + } + finally + { + // A completed staged result can still be present in the deferred + // FIFO that originally retained it. Remove only those exact + // objects; unrelated accepted completions keep their order. + _deferredApply.RemoveAll(result => + { + for (int i = 0; i < pending.Count; i++) + { + if (ReferenceEquals(result, pending[i])) + return !_presentation.HasPendingPublication(result); + } + return false; + }); + } + } + /// /// Advance one frame. / /// are landblock coordinates (0..255) of the current viewer — the camera @@ -382,7 +403,11 @@ public sealed class StreamingController // Presentation-owner failures never roll back spatial state. Resume // unfinished owner ledgers before accepting replacement publications. - _retirements.Advance(); + _presentation.AdvanceRetirements(); + // Complete an admitted publication before this frame can mutate its + // desired tier or spatial residence. A later recenter may then demote + // or retire the fully known owner set through the normal ledger. + ConvergePendingPublications(); uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); @@ -616,11 +641,12 @@ public sealed class StreamingController /// private void EnterDungeonCollapse(int cx, int cy, uint centerId) { - if (!_collapsed || _collapsedCenter != centerId) + bool logTransition = !_collapsed || _collapsedCenter != centerId; + if (logTransition) Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}"); + AdvanceGeneration(); _collapsed = true; _collapsedCenter = centerId; - AdvanceGeneration(); _clearPendingLoads?.Invoke(); _deferredApply.Clear(); _region = null; @@ -685,8 +711,8 @@ public sealed class StreamingController Console.WriteLine( $"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " + $"(was collapsed on 0x{_collapsedCenter:X8})"); - _collapsed = false; AdvanceGeneration(); + _collapsed = false; var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); foreach (var id in _state.LoadedLandblockIds) @@ -715,19 +741,20 @@ public sealed class StreamingController /// public void ForceReloadWindow() { - _collapsed = false; AdvanceGeneration(); + _collapsed = false; _clearPendingLoads?.Invoke(); _deferredApply.Clear(); // Commit the old region boundary before any presentation owner is - // advanced. New same-id publications are fenced by _retirements. + // advanced. New same-id publications are fenced by the presentation + // pipeline's retryable retirement ledger. _region = null; // Snapshot — RemoveLandblock mutates the loaded set we're iterating. var ids = new List(_state.LoadedLandblockIds); ForceReloadCount++; // [FRAME-DIAG] churn counter LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume foreach (var id in ids) - _retirements.BeginFull(id); + _presentation.BeginFullRetirement(id); } /// @@ -757,18 +784,41 @@ public sealed class StreamingController { var chunk = _drainCompletions(MaxCompletionsPerFrame); if (chunk.Count == 0) break; - foreach (var result in chunk) + for (int chunkIndex = 0; chunkIndex < chunk.Count; chunkIndex++) { + LandblockStreamResult result = chunk[chunkIndex]; // Reject obsolete hard-window work before it can occupy the // metered deferred queue. ApplyResult repeats this invariant // for direct callers and future drain paths. - if (IsStaleGeneration(result)) + if (IsStaleGeneration(result) + && !_presentation.HasPendingPublication(result)) continue; if (IsPublicationBlockedByRetirement(result)) _deferredApply.Add(result); else if (result is LandblockStreamResult.Unloaded || IsWithinPriorityRing(ResultLandblockId(result))) - ApplyResult(result); // free (unload) or behind-the-fade near ring + { + try + { + ApplyResult(result); // free (unload) or behind-the-fade near ring + } + catch + { + // The presentation pipeline retains its exact committed + // stage after the coarse presentation callback commits. + // Preserve it and every untouched completion from this + // already-drained chunk in original FIFO order. A + // presentation-stage failure is deliberately fail-fast + // until the focused publishers replace that callback; + // its result is not retryable, but the untouched suffix + // must still survive. + if (_presentation.HasPendingPublication(result)) + _deferredApply.Add(result); + for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++) + _deferredApply.Add(chunk[suffix]); + throw; + } + } else _deferredApply.Add(result); // a GPU-upload load — meter it in step 2 } @@ -798,9 +848,24 @@ public sealed class StreamingController // Advance read only after the apply commits. If it throws, the // finally block removes prior committed entries but retains this // exact result and the untouched tail for retry. - ApplyResult(result); - read++; - applied++; + try + { + ApplyResult(result); + read++; + applied++; + } + catch + { + // A suffix-stage failure keeps its pipeline receipt and + // must remain at this exact FIFO position. The still- + // monolithic presentation callback deliberately removes + // its receipt on failure because its internal committed + // prefix is unknown; consume only that failed result so a + // later frame cannot replay it automatically. + if (!_presentation.HasPendingPublication(result)) + read++; + throw; + } } } finally @@ -821,6 +886,13 @@ public sealed class StreamingController /// private void ApplyResult(LandblockStreamResult result) { + if (_presentation.HasPendingPublication(result)) + { + _presentation.ResumePublication(result); + ReconcileCompletedPendingPublication(result.LandblockId); + return; + } + if (IsStaleGeneration(result)) { // A worker can finish one old load/unload after a hard recenter. @@ -853,7 +925,9 @@ public sealed class StreamingController bool isNearCompletion = result is LandblockStreamResult.Promoted or LandblockStreamResult.Loaded { Tier: LandblockStreamTier.Near }; - if (isNearCompletion && _state.IsNearTier(result.LandblockId)) + if (isNearCompletion + && _state.IsNearTier(result.LandblockId) + && !_presentation.HasPendingPublication(result)) { // Normal recenter can enqueue a second high-priority Near job // while an earlier one is already in flight. The streamer @@ -878,10 +952,10 @@ public sealed class StreamingController switch (result) { case LandblockStreamResult.Loaded loaded: - ApplyAsFar(loaded.Build, loaded.MeshData); + _presentation.PublishAsFar(loaded, loaded.Build, loaded.MeshData); break; case LandblockStreamResult.Promoted promoted: - ApplyAsFar(promoted.Build, promoted.MeshData); + _presentation.PublishAsFar(promoted, promoted.Build, promoted.MeshData); break; } } @@ -900,16 +974,7 @@ public sealed class StreamingController // layer that now owns the landblock. break; } - _applyTerrain(loaded.Build, loaded.MeshData); - _state.AddLandblock( - loaded.Landblock, - loaded.Build.EnvCells?.Shells.Select(shell => shell.GeometryId), - loaded.Tier); - EnsureEnvCellMeshesAfterPin(loaded.Build); - // #138: after the landblock is in _loaded (so live projection - // hot-paths), restore any retained server objects ACE won't - // re-send. Fired AFTER AddLandblock, never before. - _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId); + _presentation.PublishLoaded(loaded); break; case LandblockStreamResult.Promoted promoted: // PromoteToNear carries a complete build and mesh because the @@ -917,28 +982,12 @@ public sealed class StreamingController // that Far job never started, publish this as the real Near // landblock; if the base is already resident, merge only the // Near layer so existing live projections retain identity. - _applyTerrain(promoted.Build, promoted.MeshData); - var promotedRenderIds = - promoted.Build.EnvCells?.Shells.Select(shell => shell.GeometryId); - if (_state.IsLoaded(promoted.LandblockId)) - { - _state.AddEntitiesToExistingLandblock( - promoted.LandblockId, - promoted.Entities, - promotedRenderIds); - } - else - { - _state.AddLandblock( - promoted.Landblock, - promotedRenderIds, - LandblockStreamTier.Near); - } - EnsureEnvCellMeshesAfterPin(promoted.Build); - _onLandblockLoaded?.Invoke(promoted.LandblockId); + _presentation.PublishPromoted( + promoted, + mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId)); break; case LandblockStreamResult.Unloaded unloaded: - _retirements.BeginFull(unloaded.LandblockId); + _presentation.BeginFullRetirement(unloaded.LandblockId); break; case LandblockStreamResult.Failed failed: Console.WriteLine( @@ -951,25 +1000,21 @@ public sealed class StreamingController } } - private void ApplyAsFar(LandblockBuild completedBuild, LandblockMeshData meshData) + private void ReconcileCompletedPendingPublication(uint landblockId) { - var completed = completedBuild.Landblock; - var farLandblock = new LoadedLandblock( - completed.LandblockId, - completed.Heightmap, - Array.Empty(), - PhysicsDatBundle.Empty); - var farBuild = new LandblockBuild(farLandblock); + if (_region is null + || !_region.TryGetDesiredTier(landblockId, out LandblockStreamTier desiredTier)) + { + if (_state.IsLoaded(landblockId)) + _presentation.BeginFullRetirement(landblockId); + return; + } - _applyTerrain(farBuild, meshData); - _state.AddLandblock(farLandblock, tier: LandblockStreamTier.Far); - _onLandblockLoaded?.Invoke(farLandblock.LandblockId); - } - - private void EnsureEnvCellMeshesAfterPin(LandblockBuild build) - { - if (build.EnvCells is { Shells.Length: > 0 } envCells) - _ensureEnvCellMeshes?.Invoke(envCells); + if (desiredTier == LandblockStreamTier.Far + && _state.IsNearTier(landblockId)) + { + _presentation.BeginNearLayerRetirement(landblockId); + } } private bool IsStaleGeneration(LandblockStreamResult result) => @@ -978,7 +1023,7 @@ public sealed class StreamingController private bool IsPublicationBlockedByRetirement(LandblockStreamResult result) => result is LandblockStreamResult.Loaded or LandblockStreamResult.Promoted - && _retirements.IsPending(result.LandblockId); + && _presentation.IsRetirementPending(result.LandblockId); /// /// Returns the landblock id associated with . diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs new file mode 100644 index 00000000..fc89089f --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -0,0 +1,711 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockPresentationPipelineTests +{ + [Fact] + public void Loaded_PreservesPresentationSpatialPinReplayAndLiveRecoveryOrder() + { + const uint landblockId = 0x2020FFFFu; + var calls = new List(); + var meshes = new RecordingMeshAdapter(calls); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + LandblockBuild build = BuildWithShell(landblockId); + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (accepted, _) => + { + Assert.Same(build, accepted); + Assert.False(state.IsLoaded(landblockId)); + calls.Add("presentation"); + }, + state, + onLandblockLoaded: id => + { + Assert.Equal(landblockId, id); + Assert.True(state.IsLoaded(id)); + calls.Add("live-recovery"); + }, + ensureEnvCellMeshes: envCells => + { + Assert.Same(build.EnvCells, envCells); + Assert.True(state.IsLoaded(landblockId)); + calls.Add("mesh-replay-after-pin"); + }); + + pipeline.PublishLoaded(new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + build, + EmptyMesh())); + + Assert.Equal( + ["presentation", "pin", "mesh-replay-after-pin", "live-recovery"], + calls); + Assert.True(state.IsNearTier(landblockId)); + } + + [Fact] + public void Promoted_ExistingFar_MergesNearLayerBeforeReplayAndRecovery() + { + const uint landblockId = 0x3030FFFFu; + var calls = new List(); + var meshes = new RecordingMeshAdapter(calls); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + state.AddLandblock( + new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty(), + PhysicsDatBundle.Empty), + tier: LandblockStreamTier.Far); + LandblockBuild build = BuildWithShell(landblockId, Entity(7)); + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => calls.Add("presentation"), + state, + onLandblockLoaded: _ => + { + Assert.True(state.IsNearTier(landblockId)); + Assert.Contains(state.Entities, entity => entity.Id == 7); + calls.Add("live-recovery"); + }, + ensureEnvCellMeshes: _ => + { + Assert.True(state.IsNearTier(landblockId)); + Assert.Contains(state.Entities, entity => entity.Id == 7); + calls.Add("mesh-replay-after-pin"); + }); + + pipeline.PublishPromoted(new LandblockStreamResult.Promoted( + landblockId, + build, + EmptyMesh()), + mergeIntoExistingLandblock: true); + + Assert.Equal( + ["presentation", "pin", "mesh-replay-after-pin", "live-recovery"], + calls); + } + + [Fact] + public void PublishAsFar_StripsNearPayloadAndSkipsEnvCellReplay() + { + const uint landblockId = 0x4040FFFFu; + var state = new GpuWorldState(); + LandblockBuild source = BuildWithShell(landblockId, Entity(9)); + LandblockBuild? published = null; + int replayCount = 0; + int recoveryCount = 0; + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (build, _) => published = build, + state, + onLandblockLoaded: _ => recoveryCount++, + ensureEnvCellMeshes: _ => replayCount++); + + var accepted = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + source, + EmptyMesh()); + pipeline.PublishAsFar(accepted, source, accepted.MeshData); + + Assert.NotNull(published); + Assert.Empty(published.Landblock.Entities); + Assert.Same(PhysicsDatBundle.Empty, published.Landblock.PhysicsDats); + Assert.Null(published.EnvCells); + Assert.True(state.IsLoaded(landblockId)); + Assert.False(state.IsNearTier(landblockId)); + Assert.Equal(0, replayCount); + Assert.Equal(1, recoveryCount); + } + + [Fact] + public void EnvCellReplayFailure_ResumesSuffixWithoutReplayingCommittedPrefix() + { + const uint landblockId = 0x5151FFFFu; + var calls = new List(); + var state = new GpuWorldState(new LandblockSpawnAdapter( + new RecordingMeshAdapter(calls))); + bool failReplayOnce = true; + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => calls.Add("presentation"), + state, + onLandblockLoaded: _ => calls.Add("live-recovery"), + ensureEnvCellMeshes: _ => + { + calls.Add("mesh-replay"); + if (failReplayOnce) + { + failReplayOnce = false; + throw new InvalidOperationException("injected replay failure"); + } + }); + LandblockBuild build = BuildWithShell(landblockId); + var result = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + build, + EmptyMesh()); + + Assert.Throws(() => pipeline.PublishLoaded(result)); + Assert.True(state.IsNearTier(landblockId)); + Assert.True(pipeline.HasPendingPublication(result)); + + pipeline.PublishLoaded(result); + + Assert.Equal( + ["presentation", "pin", "mesh-replay", "mesh-replay", "live-recovery"], + calls); + Assert.False(pipeline.HasPendingPublication(result)); + } + + [Fact] + public void PromotionReplayFailure_RetainsOriginalSelfContainedModeAcrossRetry() + { + const uint landblockId = 0x5252FFFFu; + var calls = new List(); + var state = new GpuWorldState(new LandblockSpawnAdapter( + new RecordingMeshAdapter(calls))); + bool failReplayOnce = true; + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => calls.Add("presentation"), + state, + onLandblockLoaded: _ => calls.Add("live-recovery"), + ensureEnvCellMeshes: _ => + { + calls.Add("mesh-replay"); + if (failReplayOnce) + { + failReplayOnce = false; + throw new InvalidOperationException("injected replay failure"); + } + }); + LandblockBuild build = BuildWithShell(landblockId, Entity(17)); + var result = new LandblockStreamResult.Promoted( + landblockId, + build, + EmptyMesh()); + + Assert.Throws(() => pipeline.PublishPromoted( + result, + mergeIntoExistingLandblock: false)); + Assert.True(state.IsNearTier(landblockId)); + + // Current residency changed after the first attempt. The transaction + // must retain the original self-contained publication command rather + // than switching policy mid-retry. + pipeline.PublishPromoted(result, mergeIntoExistingLandblock: true); + + Assert.Equal( + ["presentation", "pin", "mesh-replay", "mesh-replay", "live-recovery"], + calls); + Assert.Single(state.Entities, entity => entity.Id == 17); + } + + [Fact] + public void SpatialPinFailure_ControllerRetriesExactCompletionWithoutReplayingPresentation() + { + const uint landblockId = 0x6060FFFFu; + var meshes = new FailFirstPreparedPinAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var outbox = new Queue(); + LandblockBuild build = BuildWithShell(landblockId); + outbox.Enqueue(new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + build, + EmptyMesh())); + int presentationCalls = 0; + int replayCalls = 0; + int liveRecoveryCalls = 0; + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => Drain(outbox, max), + applyTerrain: (_, _) => presentationCalls++, + state, + nearRadius: 0, + farRadius: 0, + onLandblockLoaded: _ => liveRecoveryCalls++, + ensureEnvCellMeshes: _ => replayCalls++); + + Assert.Throws(() => controller.Tick(0x60, 0x60)); + Assert.True(state.IsNearTier(landblockId)); + Assert.Equal(1, presentationCalls); + Assert.Equal(0, replayCalls); + Assert.Equal(0, liveRecoveryCalls); + + controller.Tick(0x60, 0x60); + + Assert.Equal(1, presentationCalls); + Assert.Equal(2, meshes.PinAttempts); + Assert.Equal(1, replayCalls); + Assert.Equal(1, liveRecoveryCalls); + } + + [Fact] + public void PromotionPinFailure_RetriesActivationWithoutAppendingEntitiesAgain() + { + const uint landblockId = 0x6161FFFFu; + var meshes = new FailFirstPreparedPinAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + state.AddLandblock( + new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty()), + tier: LandblockStreamTier.Far); + int presentationCalls = 0; + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => presentationCalls++, + state); + WorldEntity promotedEntity = Entity(31); + LandblockBuild build = BuildWithShell(landblockId, promotedEntity); + var result = new LandblockStreamResult.Promoted( + landblockId, + build, + EmptyMesh()); + + Assert.Throws(() => pipeline.PublishPromoted( + result, + mergeIntoExistingLandblock: true)); + Assert.Single(state.Entities, entity => ReferenceEquals(entity, promotedEntity)); + + pipeline.PublishPromoted(result, mergeIntoExistingLandblock: true); + + Assert.Equal(1, presentationCalls); + Assert.Equal(2, meshes.PinAttempts); + Assert.Single(state.Entities, entity => ReferenceEquals(entity, promotedEntity)); + Assert.False(pipeline.HasPendingPublication(result)); + } + + [Fact] + public void LoadedPinFailure_RetryPreservesPendingLiveProjectionMergedAtSpatialCommit() + { + const uint landblockId = 0x6262FFFFu; + var meshes = new FailFirstPreparedPinAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var pendingLive = new WorldEntity + { + Id = 77, + ServerGuid = 0x80000077u, + SourceGfxObjOrSetupId = 0x01000077u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [new MeshRef(0x01000077u, Matrix4x4.Identity)], + }; + state.PlaceLiveEntityProjection(0x62620100u, pendingLive); + LandblockBuild build = BuildWithShell(landblockId, Entity(32)); + var result = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + build, + EmptyMesh()); + var pipeline = new LandblockPresentationPipeline( + static (_, _) => { }, + state); + + Assert.Throws(() => pipeline.PublishLoaded(result)); + Assert.Contains(state.Entities, entity => ReferenceEquals(entity, pendingLive)); + Assert.Equal(0, state.PendingLiveEntityCount); + + pipeline.PublishLoaded(result); + + Assert.Contains(state.Entities, entity => ReferenceEquals(entity, pendingLive)); + Assert.Single(state.Entities, entity => ReferenceEquals(entity, pendingLive)); + Assert.Equal(0, state.PendingLiveEntityCount); + Assert.Equal(2, meshes.PinAttempts); + } + + [Fact] + public void PriorityFailure_PreservesFailingResultAndUntouchedDrainedSuffix() + { + const uint priorityId = 0x7070FFFFu; + const uint laterId = 0x7071FFFFu; + const uint unloadId = 0x1010FFFFu; + var meshes = new FailFirstPreparedPinAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + state.AddLandblock(new LoadedLandblock( + unloadId, + new LandBlock(), + Array.Empty())); + var outbox = new Queue( + [ + new LandblockStreamResult.Loaded( + priorityId, + LandblockStreamTier.Near, + BuildWithShell(priorityId), + EmptyMesh()), + new LandblockStreamResult.Loaded( + laterId, + LandblockStreamTier.Near, + BuildWithShell(laterId), + EmptyMesh()), + new LandblockStreamResult.Unloaded(unloadId), + ]); + var calls = new List(); + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => Drain(outbox, max), + applyTerrain: (build, _) => calls.Add($"publish:{build.LandblockId:X8}"), + state, + nearRadius: 0, + farRadius: 2, + removeTerrain: id => calls.Add($"unload:{id:X8}")); + controller.PriorityLandblockId = priorityId; + + Assert.Throws(() => controller.Tick(0x70, 0x70)); + Assert.Empty(outbox); + Assert.False(state.IsLoaded(laterId)); + Assert.True(state.IsLoaded(unloadId)); + + controller.PriorityLandblockId = 0; + controller.Tick(0x70, 0x70); + + Assert.True(state.IsLoaded(priorityId)); + Assert.True(state.IsLoaded(laterId)); + Assert.False(state.IsLoaded(unloadId)); + Assert.Equal( + [ + $"publish:{priorityId:X8}", + $"publish:{laterId:X8}", + $"unload:{unloadId:X8}", + ], + calls); + } + + [Fact] + public void HardRecenter_ConvergesPendingSpatialPublicationBeforeRetiringIt() + { + const uint landblockId = 0x8080FFFFu; + var meshes = new FailFirstPreparedPinAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var outbox = new Queue(); + var result = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + BuildWithShell(landblockId), + EmptyMesh()); + outbox.Enqueue(result); + int presentationCalls = 0; + int replayCalls = 0; + int recoveryCalls = 0; + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => Drain(outbox, max), + applyTerrain: (_, _) => presentationCalls++, + state, + nearRadius: 0, + farRadius: 0, + onLandblockLoaded: _ => recoveryCalls++, + ensureEnvCellMeshes: _ => replayCalls++); + + Assert.Throws(() => controller.Tick(0x80, 0x80)); + Assert.True(state.IsNearTier(landblockId)); + + controller.ForceReloadWindow(); + + Assert.False(state.IsLoaded(landblockId)); + Assert.Equal(1, presentationCalls); + Assert.Equal(1, replayCalls); + Assert.Equal(1, recoveryCalls); + Assert.Equal(2, meshes.PinAttempts); + } + + [Fact] + public void DesiredNearToFarChurn_ResumesOriginalPublicationThenDemotes() + { + const uint landblockId = 0x9090FFFFu; + var calls = new List(); + var state = new GpuWorldState(new LandblockSpawnAdapter( + new RecordingMeshAdapter(calls))); + var outbox = new Queue(); + var result = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + BuildWithShell(landblockId, Entity(23)), + EmptyMesh()); + outbox.Enqueue(result); + bool failReplayOnce = true; + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => Drain(outbox, max), + applyTerrain: (_, _) => calls.Add("presentation"), + state, + nearRadius: 0, + farRadius: 2, + demoteNearLayer: _ => calls.Add("demote"), + ensureEnvCellMeshes: _ => + { + calls.Add("mesh-replay"); + if (failReplayOnce) + { + failReplayOnce = false; + throw new InvalidOperationException("injected replay failure"); + } + }); + + Assert.Throws(() => controller.Tick(0x90, 0x90)); + Assert.True(state.IsNearTier(landblockId)); + + controller.Tick(0x90, 0x93); + + Assert.True(state.IsLoaded(landblockId)); + Assert.False(state.IsNearTier(landblockId)); + Assert.DoesNotContain(state.Entities, entity => entity.Id == 23); + Assert.Equal(1, calls.Count(call => call == "presentation")); + Assert.Equal(2, calls.Count(call => call == "mesh-replay")); + Assert.Contains("demote", calls); + } + + [Fact] + public void CoarsePresentationFailure_IsFailFastAndNotRetainedForAutomaticReplay() + { + const uint landblockId = 0xA0A0FFFFu; + var state = new GpuWorldState(); + int presentationCalls = 0; + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => + { + presentationCalls++; + throw new StreamingMutationException( + "injected committed coarse failure", + mutationCommitted: true); + }, + state); + var result = new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + BuildWithShell(landblockId), + EmptyMesh()); + + Assert.Throws(() => pipeline.PublishLoaded(result)); + + Assert.Equal(1, presentationCalls); + Assert.False(pipeline.HasPendingPublication(result)); + Assert.Equal(0, pipeline.PendingPublicationCount); + Assert.False(state.IsLoaded(landblockId)); + } + + [Fact] + public void DeferredCommittedPresentationFailure_DropsOnlyFailedResultAndPreservesTail() + { + const uint failedId = 0xB0B0FFFFu; + const uint tailId = 0xB0B1FFFFu; + var outbox = new Queue( + [ + new LandblockStreamResult.Loaded( + failedId, + LandblockStreamTier.Near, + new LandblockBuild(new LoadedLandblock( + failedId, + new LandBlock(), + Array.Empty())), + EmptyMesh()), + new LandblockStreamResult.Loaded( + tailId, + LandblockStreamTier.Near, + new LandblockBuild(new LoadedLandblock( + tailId, + new LandBlock(), + Array.Empty())), + EmptyMesh()), + ]); + int failedCalls = 0; + int tailCalls = 0; + var state = new GpuWorldState(); + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => Drain(outbox, max), + applyTerrain: (build, _) => + { + if (build.LandblockId == failedId) + { + failedCalls++; + throw new StreamingMutationException( + "injected committed presentation failure", + mutationCommitted: true); + } + tailCalls++; + }, + state, + nearRadius: 0, + farRadius: 2); + + Assert.Throws(() => controller.Tick(0xB0, 0xB0)); + Assert.Equal(1, failedCalls); + Assert.Equal(0, tailCalls); + Assert.Equal(1, controller.DeferredApplyBacklog); + + controller.Tick(0xB0, 0xB0); + + Assert.Equal(1, failedCalls); + Assert.Equal(1, tailCalls); + Assert.False(state.IsLoaded(failedId)); + Assert.True(state.IsLoaded(tailId)); + Assert.Equal(0, controller.DeferredApplyBacklog); + } + + [Fact] + public void RetirementFacade_DetachesFirstAndReportsPendingLedger() + { + const uint landblockId = 0x5050FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty())); + bool failOnce = true; + var retirements = new LandblockRetirementCoordinator( + state, + ticket => + { + ticket.RunOnce( + LandblockRetirementStage.EntityLighting, + () => + { + if (failOnce) + { + failOnce = false; + throw new InvalidOperationException("injected failure"); + } + }); + }, + _ => LandblockRetirementStage.EntityLighting); + var pipeline = new LandblockPresentationPipeline( + static (_, _) => { }, + state, + retirementCoordinator: retirements); + + pipeline.BeginFullRetirement(landblockId); + + Assert.False(state.IsLoaded(landblockId)); + Assert.True(pipeline.IsRetirementPending(landblockId)); + Assert.Equal(1, pipeline.PendingRetirementCount); + + pipeline.AdvanceRetirements(); + + Assert.False(pipeline.IsRetirementPending(landblockId)); + Assert.Equal(0, pipeline.PendingRetirementCount); + } + + [Fact] + public void Pipeline_DoesNotOwnStreamingPolicyOrLiveIdentityMaps() + { + string[] forbiddenFragments = + [ + "generation", + "desiredtier", + "streamingregion", + "serverguid", + "liveentity", + ]; + + string[] fields = typeof(LandblockPresentationPipeline) + .GetFields(System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.NonPublic) + .Select(field => $"{field.Name}:{field.FieldType.FullName}") + .ToArray(); + + foreach (string field in fields) + foreach (string forbidden in forbiddenFragments) + Assert.DoesNotContain(forbidden, field, StringComparison.OrdinalIgnoreCase); + } + + private static LandblockBuild BuildWithShell( + uint landblockId, + params WorldEntity[] entities) + { + uint cellId = (landblockId & 0xFFFF0000u) | 0x0100u; + var shell = new EnvCellShellPlacement( + cellId, + 0x2_0000_0001UL, + 0x0D000001u, + 1, + ImmutableArray.Empty, + Vector3.Zero, + Quaternion.Identity, + Matrix4x4.Identity, + new WbBoundingBox(Vector3.Zero, Vector3.One), + new WbBoundingBox(Vector3.Zero, Vector3.One)); + var envCells = new EnvCellLandblockBuild( + landblockId, + Array.Empty(), + [shell]); + return new LandblockBuild( + new LoadedLandblock( + landblockId, + new LandBlock(), + entities, + PhysicsDatBundle.Empty), + envCells); + } + + private static WorldEntity Entity(uint id) => new() + { + Id = id, + SourceGfxObjOrSetupId = 0x01000000u + id, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static LandblockMeshData EmptyMesh() => new( + Array.Empty(), + Array.Empty()); + + private static IReadOnlyList Drain( + Queue queue, + int max) + { + var drained = new List(max); + while (drained.Count < max && queue.TryDequeue(out LandblockStreamResult? result)) + drained.Add(result); + return drained; + } + + private sealed class RecordingMeshAdapter(List calls) : IWbMeshAdapter + { + public void IncrementRefCount(ulong id) + { + } + + public void PinPreparedRenderData(ulong id) => calls.Add("pin"); + + public void DecrementRefCount(ulong id) + { + } + } + + private sealed class FailFirstPreparedPinAdapter : IWbMeshAdapter + { + public int PinAttempts { get; private set; } + + public void IncrementRefCount(ulong id) + { + } + + public void PinPreparedRenderData(ulong id) + { + PinAttempts++; + if (PinAttempts == 1) + throw new InvalidOperationException("injected pin failure"); + } + + public void DecrementRefCount(ulong id) + { + } + } +} From 2216a02de5f4bb9df552b37b3045e4ccd384426a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 19:57:13 +0200 Subject: [PATCH 55/98] docs(plan): record landblock transaction checkpoint --- .../2026-07-21-gamewindow-slice-5-landblock-presentation.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md index e274be97..eadfca80 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md +++ b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md @@ -16,6 +16,12 @@ test, and bisectable commit rather than being hidden in a mechanical move. - [ ] A — freeze production order and introduce explicit build/publication seams plus structural guards. + - A1 landed in `4f965d06`: accepted results now enter one staged + `LandblockPresentationPipeline` transaction; `GpuWorldState` returns an + exact spatial-publication receipt before retryable render-pin/script + activation; retries preserve FIFO suffixes, pending-live merges, and + promotion identity across policy/generation changes. Three corrected-diff + reviews are clean. Release gate: 6,955 passed / 5 skipped. - [ ] B — capture one immutable world-origin snapshot per load and extract the serialized DAT-backed `LandblockBuildFactory`. - [ ] C — extract terrain, visibility, EnvCell, building, and render-pinning From 090b0354ffd101e0357d1656a4c3ab1c75f0470e Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 20:12:56 +0200 Subject: [PATCH 56/98] refactor(streaming): capture landblock build origin --- src/AcDream.App/Rendering/GameWindow.cs | 73 ++-- src/AcDream.App/Streaming/LandblockBuild.cs | 3 +- .../Streaming/LandblockBuildRequest.cs | 36 ++ .../LandblockPresentationPipeline.cs | 4 +- .../Streaming/LandblockStreamJob.cs | 7 +- .../Streaming/LandblockStreamer.cs | 132 ++++++-- .../Streaming/LandblockBuildOriginTests.cs | 314 ++++++++++++++++++ .../LandblockPresentationPipelineTests.cs | 7 +- 8 files changed, 522 insertions(+), 54 deletions(-) create mode 100644 src/AcDream.App/Streaming/LandblockBuildRequest.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 50b6db03..ac7aca12 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2491,8 +2491,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so // it can call LandblockMesh.Build without a dat read — _heightTable and // _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9). - _streamer = new AcDream.App.Streaming.LandblockStreamer( - loadLandblock: (id, kind) => BuildLandblockForStreaming(id, kind), + _streamer = AcDream.App.Streaming.LandblockStreamer.CreateForRequests( + loadLandblock: BuildLandblockForStreaming, buildMeshOrNull: (id, lb) => { if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null) @@ -2508,7 +2508,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _streamer.Start(); _streamingController = new AcDream.App.Streaming.StreamingController( - enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation), + enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad( + new AcDream.App.Streaming.LandblockBuildRequest( + id, + kind, + generation, + new AcDream.App.Streaming.LandblockBuildOrigin( + _liveCenterX, + _liveCenterY))), enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation), drainCompletions: _streamer.DrainCompletions, applyTerrain: ApplyLoadedTerrain, @@ -3792,8 +3799,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// early-out at the source. /// private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming( - uint landblockId, - AcDream.App.Streaming.LandblockStreamJobKind kind) + AcDream.App.Streaming.LandblockBuildRequest request) { if (_dats is null) return null; @@ -3816,31 +3822,31 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { long waitedMs = sw.ElapsedMilliseconds; sw.Restart(); - var built = BuildLandblockForStreamingLocked(landblockId, kind); + var built = BuildLandblockForStreamingLocked(request); AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( - "BUILD", landblockId, - $"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={kind}"); + "BUILD", request.LandblockId, + $"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}"); return built; } } lock (_datLock) { - return BuildLandblockForStreamingLocked(landblockId, kind); + return BuildLandblockForStreamingLocked(request); } } private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreamingLocked( - uint landblockId, - AcDream.App.Streaming.LandblockStreamJobKind kind) + AcDream.App.Streaming.LandblockBuildRequest request) { if (_dats is null) return null; + uint landblockId = request.LandblockId; // ISSUE #54: far-tier early-out — heightmap only, empty entities. // Skips the LandBlockInfo dat read AND all entity hydration (stabs // + buildings) AND the SceneryGenerator AND interior cells. Cuts // worker-thread cost per far-tier LB from ~tens of ms to a single // dat read. - if (kind == AcDream.App.Streaming.LandblockStreamJobKind.LoadFar) + if (request.Kind == AcDream.App.Streaming.LandblockStreamJobKind.LoadFar) { var heightmapOnly = _dats.Get(landblockId); if (heightmapOnly is null) return null; @@ -3849,7 +3855,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext landblockId, heightmapOnly, System.Array.Empty(), - AcDream.Core.World.PhysicsDatBundle.Empty)); // far tier: no cells/buildings/entities + AcDream.Core.World.PhysicsDatBundle.Empty), + Origin: request.Origin); // far tier: no cells/buildings/entities } var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId); @@ -3858,8 +3865,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext int lbX = (int)((landblockId >> 24) & 0xFFu); int lbY = (int)((landblockId >> 16) & 0xFFu); var worldOffset = new System.Numerics.Vector3( - (lbX - _liveCenterX) * 192f, - (lbY - _liveCenterY) * 192f, + (lbX - request.Origin.CenterX) * 192f, + (lbY - request.Origin.CenterY) * 192f, 0f); // Hydrate the stabs: same logic as the old OnLoad preload. Each stab @@ -3926,9 +3933,18 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Task 8: merge stabs + scenery + interior into one entity list. var merged = new List(hydrated); - merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY)); + merged.AddRange(BuildSceneryEntitiesForStreaming( + baseLoaded, + lbX, + lbY, + request.Origin)); var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId); - merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY, envCellBuild)); + merged.AddRange(BuildInteriorEntitiesForStreaming( + landblockId, + lbX, + lbY, + request.Origin, + envCellBuild)); // datLock fix: pre-read (under the worker's _datLock) every dat the apply // consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER @@ -3941,7 +3957,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext baseLoaded.Heightmap, merged, physicsDats), - completedEnvCells); + completedEnvCells, + request.Origin); } private void EnsureEnvCellMeshesAfterPin( @@ -4036,7 +4053,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// LoadedLandblock instead of iterating worldView.Landblocks. /// private List BuildSceneryEntitiesForStreaming( - AcDream.Core.World.LoadedLandblock lb, int lbX, int lbY) + AcDream.Core.World.LoadedLandblock lb, + int lbX, + int lbY, + AcDream.App.Streaming.LandblockBuildOrigin origin) { var result = new List(); if (_dats is null || _heightTable is null) return result; @@ -4069,8 +4089,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext if (spawns.Count == 0) return result; var lbOffset = new System.Numerics.Vector3( - (lbX - _liveCenterX) * 192f, - (lbY - _liveCenterY) * 192f, + (lbX - origin.CenterX) * 192f, + (lbY - origin.CenterY) * 192f, 0f); // Per-landblock id namespace. The top nibble identifies procedural @@ -4265,6 +4285,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext uint landblockId, int lbX, int lbY, + AcDream.App.Streaming.LandblockBuildOrigin origin, AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild) { var result = new List(); @@ -4274,8 +4295,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext if (lbInfo is null || lbInfo.NumCells == 0) return result; var lbOffset = new System.Numerics.Vector3( - (lbX - _liveCenterX) * 192f, - (lbY - _liveCenterY) * 192f, + (lbX - origin.CenterX) * 192f, + (lbY - origin.CenterY) * 192f, 0f); // Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator @@ -4654,8 +4675,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext int lbX = (int)lbXu; int lbY = (int)lbYu; var origin = new System.Numerics.Vector3( - (lbX - _liveCenterX) * 192f, - (lbY - _liveCenterY) * 192f, + (lbX - build.Origin.CenterX) * 192f, + (lbY - build.Origin.CenterY) * 192f, 0f); // Phase A.5 T15/T16: route through AddLandblockWithMesh — the named @@ -4820,7 +4841,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { // #146 (2026-06-24): clear this landblock's prior building cache so // the loop re-bases each building's STREAMING-RELATIVE WorldTransform - // with the CURRENT _liveCenter (origin, recomputed above per apply). + // with this completion's captured origin (recomputed above per apply). // CacheBuilding's per-cell first-wins then applies fresh within this // pass — mirrors terrain's per-apply WorldOffset re-base (AddLandblock). // Without it, CacheBuilding's idempotent guard locks the transform at diff --git a/src/AcDream.App/Streaming/LandblockBuild.cs b/src/AcDream.App/Streaming/LandblockBuild.cs index 0f4485de..cc17ff99 100644 --- a/src/AcDream.App/Streaming/LandblockBuild.cs +++ b/src/AcDream.App/Streaming/LandblockBuild.cs @@ -10,7 +10,8 @@ namespace AcDream.App.Streaming; /// public sealed record LandblockBuild( LoadedLandblock Landblock, - EnvCellLandblockBuild? EnvCells = null) + EnvCellLandblockBuild? EnvCells = null, + LandblockBuildOrigin Origin = default) { public uint LandblockId => Landblock.LandblockId; } diff --git a/src/AcDream.App/Streaming/LandblockBuildRequest.cs b/src/AcDream.App/Streaming/LandblockBuildRequest.cs new file mode 100644 index 00000000..315753f4 --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockBuildRequest.cs @@ -0,0 +1,36 @@ +namespace AcDream.App.Streaming; + +/// +/// Immutable world-origin center captured by the update thread when a +/// landblock load is admitted. Worker construction and render publication use +/// this same value, so a later recenter cannot combine two coordinate frames. +/// +public readonly record struct LandblockBuildOrigin +{ + public LandblockBuildOrigin(int centerX, int centerY) + { + CenterX = centerX; + CenterY = centerY; + IsSpecified = true; + } + + public int CenterX { get; } + public int CenterY { get; } + + /// + /// Distinguishes a captured origin at the valid map coordinate (0,0) + /// from an old compatibility request that carries no origin at all. + /// + public bool IsSpecified { get; } +} + +/// +/// Complete immutable input to one CPU-side landblock build. +/// is carried for matching and diagnostics only; +/// residency policy remains owned by . +/// +public readonly record struct LandblockBuildRequest( + uint LandblockId, + LandblockStreamJobKind Kind, + ulong Generation, + LandblockBuildOrigin Origin); diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 24fb3888..5f815cb2 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -161,7 +161,9 @@ public sealed class LandblockPresentationPipeline transaction = new PublicationTransaction { Kind = PublicationKind.Far, - Build = new LandblockBuild(farLandblock), + Build = new LandblockBuild( + farLandblock, + Origin: completedBuild.Origin), MeshData = meshData, LandblockId = farLandblock.LandblockId, Tier = LandblockStreamTier.Far, diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs index 51bcfc9d..17017cd2 100644 --- a/src/AcDream.App/Streaming/LandblockStreamJob.cs +++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs @@ -15,7 +15,12 @@ public abstract record LandblockStreamJob(uint LandblockId) public sealed record Load( uint LandblockId, LandblockStreamJobKind Kind, - ulong Generation = 0) : LandblockStreamJob(LandblockId); + ulong Generation = 0, + LandblockBuildOrigin Origin = default) : LandblockStreamJob(LandblockId) + { + public LandblockBuildRequest Request => + new(LandblockId, Kind, Generation, Origin); + } public sealed record Unload( uint LandblockId, ulong Generation = 0) : LandblockStreamJob(LandblockId); diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index f06fac01..84f2e9c8 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -52,7 +52,8 @@ public sealed class LandblockStreamer : IDisposable /// public const int DefaultDrainBatchSize = 4; - private readonly Func _loadLandblock; + private readonly Func _loadLandblock; + private readonly bool _supportsRequestOrigin; private readonly Func _buildMeshOrNull; private readonly Channel _inbox; private readonly Channel _outbox; @@ -65,17 +66,17 @@ public sealed class LandblockStreamer : IDisposable private bool _disposeCompleted; /// - /// Primary ctor — the factory takes the job's - /// so it can branch on far-tier vs near-tier and skip entity hydration on far-tier - /// loads (heightmap-only). See ISSUE #54: prior to this signature the worker always - /// called the full-load path and stripped entities at the output, wasting per-LB - /// LandBlockInfo + SceneryGenerator work. + /// Primary constructor. The factory receives the complete immutable + /// request, including job kind and enqueue-time world origin. ISSUE #54 + /// uses the kind to skip entity hydration for heightmap-only far loads. /// - public LandblockStreamer( - Func loadLandblock, - Func? buildMeshOrNull = null) + private LandblockStreamer( + Func loadLandblock, + Func? buildMeshOrNull, + bool supportsRequestOrigin) { _loadLandblock = loadLandblock; + _supportsRequestOrigin = supportsRequestOrigin; // Default: no mesh build (returns null → Failed result). Production // wires in LandblockMesh.Build via the T12 construction site. _buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null); @@ -85,6 +86,32 @@ public sealed class LandblockStreamer : IDisposable new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); } + /// + /// Creates the request-aware production streamer. A named factory keeps + /// legacy one-parameter loader lambdas source-compatible without an + /// ambiguous constructor overload. + /// + public static LandblockStreamer CreateForRequests( + Func loadLandblock, + Func? buildMeshOrNull = null) => + new(loadLandblock, buildMeshOrNull, supportsRequestOrigin: true); + + /// + /// Compatibility constructor for build factories that predate the + /// immutable request/origin seam. + /// + public LandblockStreamer( + Func loadLandblock, + Func? buildMeshOrNull = null) + : this( + request => loadLandblock(request.LandblockId, request.Kind) is { } build + ? build + : null, + buildMeshOrNull, + supportsRequestOrigin: false) + { + } + /// /// Compatibility constructor for loaders that have no App-owned cell payload. /// Production uses the overload so render state @@ -94,10 +121,11 @@ public sealed class LandblockStreamer : IDisposable Func loadLandblock, Func? buildMeshOrNull = null) : this( - (id, kind) => loadLandblock(id, kind) is { } landblock - ? new LandblockBuild(landblock) + request => loadLandblock(request.LandblockId, request.Kind) is { } landblock + ? new LandblockBuild(landblock, Origin: request.Origin) : null, - buildMeshOrNull) + buildMeshOrNull, + supportsRequestOrigin: false) { } @@ -105,16 +133,17 @@ public sealed class LandblockStreamer : IDisposable /// Back-compat overload — wraps a kind-agnostic factory so existing test code /// that doesn't care about the JobKind branch keeps compiling. The wrapper /// ignores the kind and calls the factory once per LB regardless of tier. - /// New production code should use the primary 2-arg ctor. + /// New production code should use . /// public LandblockStreamer( Func loadLandblock, Func? buildMeshOrNull = null) : this( - (id, _) => loadLandblock(id) is { } landblock - ? new LandblockBuild(landblock) + request => loadLandblock(request.LandblockId) is { } landblock + ? new LandblockBuild(landblock, Origin: request.Origin) : null, - buildMeshOrNull) + buildMeshOrNull, + supportsRequestOrigin: false) { } @@ -152,11 +181,47 @@ public sealed class LandblockStreamer : IDisposable uint landblockId, LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear, ulong generation = 0) + { + if (_supportsRequestOrigin) + { + throw new InvalidOperationException( + "Request-aware landblock loaders require an explicit captured origin."); + } + EnqueueLoad(new LandblockBuildRequest( + landblockId, + kind, + generation, + default)); + } + + /// + /// Non-blocking enqueue of the exact request captured by the update + /// thread. The worker and completion retain its origin unchanged. + /// + public void EnqueueLoad(LandblockBuildRequest request) { if (System.Threading.Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(LandblockStreamer)); - AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}"); - WriteJob(new LandblockStreamJob.Load(landblockId, kind, generation)); + if (_supportsRequestOrigin && !request.Origin.IsSpecified) + { + throw new InvalidOperationException( + "Request-aware landblock loaders require a specified captured origin."); + } + if (!_supportsRequestOrigin && request.Origin.IsSpecified) + { + throw new InvalidOperationException( + "This compatibility landblock loader cannot consume a non-default build origin. " + + "Use LandblockStreamer.CreateForRequests."); + } + AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( + "ENQ", + request.LandblockId, + $"kind={request.Kind} origin=({request.Origin.CenterX:X2},{request.Origin.CenterY:X2})"); + WriteJob(new LandblockStreamJob.Load( + request.LandblockId, + request.Kind, + request.Generation, + request.Origin)); } /// @@ -358,13 +423,21 @@ public sealed class LandblockStreamer : IDisposable // factory returns far-tier with entities anyway. try { - var build = _loadLandblock(load.LandblockId, load.Kind); + var build = _loadLandblock(load.Request); if (build is null) { _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( load.LandblockId, "LandblockLoader.Load returned null", load.Generation)); break; } + if (build.Origin != load.Origin) + { + _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( + load.LandblockId, + $"Landblock build origin {build.Origin} did not match request origin {load.Origin}", + load.Generation)); + break; + } var lb = build.Landblock; if (load.Kind == LandblockStreamJobKind.PromoteToNear) { @@ -388,18 +461,29 @@ public sealed class LandblockStreamer : IDisposable } var tier = load.Kind == LandblockStreamJobKind.LoadFar ? LandblockStreamTier.Far : LandblockStreamTier.Near; - if (tier == LandblockStreamTier.Far && lb.Entities.Count > 0) + if (tier == LandblockStreamTier.Far) { // Belt-and-suspenders: factory should have skipped // entity hydration for LoadFar. If it didn't, fail // loud in Debug builds and strip in Release. - System.Diagnostics.Debug.Assert(false, - $"Far-tier factory should skip entity hydration; got {lb.Entities.Count} entities for LB 0x{load.LandblockId:X8}"); + bool hasNearPayload = + lb.Entities.Count > 0 || + build.EnvCells is not null || + lb.PhysicsDats is { } physicsDats && + (physicsDats.Info is not null || + physicsDats.EnvCells.Count > 0 || + physicsDats.Environments.Count > 0 || + physicsDats.Setups.Count > 0 || + physicsDats.GfxObjs.Count > 0); + System.Diagnostics.Debug.Assert( + !hasNearPayload, + $"Far-tier factory returned Near payload for LB 0x{load.LandblockId:X8}"); lb = new LoadedLandblock( lb.LandblockId, lb.Heightmap, - System.Array.Empty()); - build = new LandblockBuild(lb); + System.Array.Empty(), + PhysicsDatBundle.Empty); + build = new LandblockBuild(lb, Origin: build.Origin); } _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( load.LandblockId, tier, build, mesh, load.Generation)); diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs new file mode 100644 index 00000000..63cc5a67 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs @@ -0,0 +1,314 @@ +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockBuildOriginTests +{ + private const int SpinTimeoutMs = 2000; + private const int SpinStepMs = 10; + + [Fact] + public void ExplicitMapZeroOrigin_IsDistinctFromUnspecifiedCompatibilityOrigin() + { + var mapZero = new LandblockBuildOrigin(0, 0); + + Assert.True(mapZero.IsSpecified); + Assert.False(default(LandblockBuildOrigin).IsSpecified); + Assert.NotEqual(default, mapZero); + } + + [Fact] + public async Task RequestAwareLoad_CarriesExplicitMapZeroOriginThroughFactoryAndCompletion() + { + const uint landblockId = 0x0000FFFFu; + var capturedOrigin = new LandblockBuildOrigin(0, 0); + LandblockBuildRequest? observedRequest = null; + + using var streamer = LandblockStreamer.CreateForRequests( + loadLandblock: request => + { + observedRequest = request; + return EmptyBuild(request.LandblockId, request.Origin); + }, + buildMeshOrNull: (_, _) => EmptyMesh()); + streamer.EnqueueLoad(new LandblockBuildRequest( + landblockId, + LandblockStreamJobKind.LoadNear, + Generation: 77, + capturedOrigin)); + streamer.Start(); + + var loaded = Assert.IsType( + await DrainFirstAsync(streamer)); + + Assert.Equal( + new LandblockBuildRequest( + landblockId, + LandblockStreamJobKind.LoadNear, + 77, + capturedOrigin), + observedRequest); + Assert.Equal(capturedOrigin, loaded.Build.Origin); + Assert.Equal(77ul, loaded.Generation); + } + + [Fact] + public async Task RequestAwareLoad_WhenFactoryReturnsUnspecifiedOrigin_FailsAtMapZero() + { + using var streamer = LandblockStreamer.CreateForRequests( + loadLandblock: request => EmptyBuild(request.LandblockId, default)); + streamer.EnqueueLoad(new LandblockBuildRequest( + 0x0000FFFFu, + LandblockStreamJobKind.LoadNear, + Generation: 78, + new LandblockBuildOrigin(0, 0))); + streamer.Start(); + + var failed = Assert.IsType( + await DrainFirstAsync(streamer)); + + Assert.Contains("origin", failed.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal(78ul, failed.Generation); + } + + [Fact] + public void CompatibilityLoader_RejectsEvenExplicitMapZeroOriginBeforeQueueing() + { + using var streamer = new LandblockStreamer(loadLandblock: _ => null); + + Assert.Throws(() => streamer.EnqueueLoad( + new LandblockBuildRequest( + 0x0000FFFFu, + LandblockStreamJobKind.LoadNear, + Generation: 79, + new LandblockBuildOrigin(0, 0)))); + } + + [Fact] + public void RequestAwareLoader_RejectsOriginlessCompatibilityEnqueue() + { + using var streamer = LandblockStreamer.CreateForRequests( + loadLandblock: request => EmptyBuild(request.LandblockId, request.Origin)); + + Assert.Throws(() => + streamer.EnqueueLoad(0xA9B4FFFFu)); + } + + [Fact] + public void RequestAwareLoader_RejectsDirectRequestWithUnspecifiedOrigin() + { + using var streamer = LandblockStreamer.CreateForRequests( + loadLandblock: request => EmptyBuild(request.LandblockId, request.Origin)); + + Assert.Throws(() => streamer.EnqueueLoad( + new LandblockBuildRequest( + 0xA9B4FFFFu, + LandblockStreamJobKind.LoadNear, + Generation: 80, + Origin: default))); + } + + [Fact] + public async Task TwoQueuedLoads_RetainTheirDistinctOriginAndGeneration() + { + var observed = new List(); + var firstOrigin = new LandblockBuildOrigin(0xA9, 0xB4); + var secondOrigin = new LandblockBuildOrigin(0x71, 0xEC); + using var streamer = LandblockStreamer.CreateForRequests( + loadLandblock: request => + { + observed.Add(request); + return EmptyBuild(request.LandblockId, request.Origin); + }, + buildMeshOrNull: (_, _) => EmptyMesh()); + + streamer.EnqueueLoad(new LandblockBuildRequest( + 0xA9B4FFFFu, + LandblockStreamJobKind.LoadNear, + Generation: 80, + firstOrigin)); + streamer.EnqueueLoad(new LandblockBuildRequest( + 0x71ECFFFFu, + LandblockStreamJobKind.LoadNear, + Generation: 81, + secondOrigin)); + streamer.Start(); + + IReadOnlyList completions = + await DrainCountAsync(streamer, 2); + + Assert.Equal([firstOrigin, secondOrigin], observed.Select(request => request.Origin)); + Assert.Equal([80ul, 81ul], completions.Select(result => result.Generation)); + Assert.Equal( + [firstOrigin, secondOrigin], + completions + .Cast() + .Select(result => result.Build.Origin)); + } + + [Fact] + public async Task QueuedPromotion_SupersedesOldFarWithItsOwnOriginAndGeneration() + { + var observed = new List(); + var oldOrigin = new LandblockBuildOrigin(0xA9, 0xB4); + var newOrigin = new LandblockBuildOrigin(0x71, 0xEC); + const uint landblockId = 0x71ECFFFFu; + using var streamer = LandblockStreamer.CreateForRequests( + loadLandblock: request => + { + observed.Add(request); + return EmptyBuild(request.LandblockId, request.Origin); + }, + buildMeshOrNull: (_, _) => EmptyMesh()); + + streamer.EnqueueLoad(new LandblockBuildRequest( + landblockId, + LandblockStreamJobKind.LoadFar, + Generation: 82, + oldOrigin)); + streamer.EnqueueLoad(new LandblockBuildRequest( + landblockId, + LandblockStreamJobKind.PromoteToNear, + Generation: 83, + newOrigin)); + streamer.Start(); + + var promoted = Assert.IsType( + await DrainFirstAsync(streamer)); + + Assert.Equal([new LandblockBuildRequest( + landblockId, + LandblockStreamJobKind.PromoteToNear, + 83, + newOrigin)], observed); + Assert.Equal(newOrigin, promoted.Build.Origin); + Assert.Equal(83ul, promoted.Generation); + } + + [Fact] + public async Task FarLoad_StripsEnvCellsAndPhysicsEvenWhenEntityListIsAlreadyEmpty() + { + const uint landblockId = 0xA9B4FFFFu; + var origin = new LandblockBuildOrigin(0xA9, 0xB4); + var physics = new PhysicsDatBundle( + new LandBlockInfo(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary()); + var envCells = new EnvCellLandblockBuild( + landblockId, + Array.Empty(), + Array.Empty()); + using var streamer = LandblockStreamer.CreateForRequests( + loadLandblock: request => new LandblockBuild( + new LoadedLandblock( + request.LandblockId, + new LandBlock(), + Array.Empty(), + physics), + envCells, + request.Origin), + buildMeshOrNull: (_, _) => EmptyMesh()); + streamer.EnqueueLoad(new LandblockBuildRequest( + landblockId, + LandblockStreamJobKind.LoadFar, + Generation: 84, + origin)); + streamer.Start(); + + var loaded = Assert.IsType( + await DrainFirstAsync(streamer)); + + Assert.Equal(LandblockStreamTier.Far, loaded.Tier); + Assert.Empty(loaded.Landblock.Entities); + Assert.Same(PhysicsDatBundle.Empty, loaded.Landblock.PhysicsDats); + Assert.Null(loaded.Build.EnvCells); + Assert.Equal(origin, loaded.Build.Origin); + } + + [Fact] + public void GameWindowBuildAndPublication_UseCapturedOriginInsteadOfLiveCenter() + { + string root = FindRepoRoot(); + string source = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + string buildSection = Slice( + source, + "private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(", + "private void AdvanceLandblockPresentationRetirement("); + string publicationSection = Slice( + source, + "private void ApplyLoadedTerrainLocked(", + "private void OnUpdate(double dt)"); + + Assert.Contains("request.Origin", buildSection, StringComparison.Ordinal); + Assert.DoesNotContain("_liveCenterX", buildSection, StringComparison.Ordinal); + Assert.DoesNotContain("_liveCenterY", buildSection, StringComparison.Ordinal); + Assert.Contains("build.Origin.CenterX", publicationSection, StringComparison.Ordinal); + Assert.Contains("build.Origin.CenterY", publicationSection, StringComparison.Ordinal); + Assert.DoesNotContain("_liveCenterX", publicationSection, StringComparison.Ordinal); + Assert.DoesNotContain("_liveCenterY", publicationSection, StringComparison.Ordinal); + } + + private static LandblockBuild EmptyBuild(uint landblockId, LandblockBuildOrigin origin) => + new( + new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty()), + Origin: origin); + + private static LandblockMeshData EmptyMesh() => + new(Array.Empty(), Array.Empty()); + + private static async Task DrainFirstAsync( + LandblockStreamer streamer) => + (await DrainCountAsync(streamer, 1))[0]; + + private static async Task> DrainCountAsync( + LandblockStreamer streamer, + int count) + { + var results = new List(count); + for (int i = 0; i < SpinTimeoutMs / SpinStepMs && results.Count < count; i++) + { + results.AddRange(streamer.DrainCompletions(count - results.Count)); + if (results.Count < count) + await Task.Delay(SpinStepMs); + } + + Assert.Equal(count, results.Count); + return results; + } + + private static string Slice(string source, string startMarker, string endMarker) + { + int start = source.IndexOf(startMarker, StringComparison.Ordinal); + Assert.True(start >= 0, $"Missing source marker: {startMarker}"); + int end = source.IndexOf(endMarker, start, StringComparison.Ordinal); + Assert.True(end > start, $"Missing source marker: {endMarker}"); + return source[start..end]; + } + + private static string FindRepoRoot() + { + string? dir = AppContext.BaseDirectory; + while (dir is not null) + { + if (File.Exists(Path.Combine(dir, "AcDream.slnx"))) + return dir; + dir = Directory.GetParent(dir)?.FullName; + } + + throw new DirectoryNotFoundException("Could not locate AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs index fc89089f..0357c1b2 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -100,7 +100,11 @@ public sealed class LandblockPresentationPipelineTests { const uint landblockId = 0x4040FFFFu; var state = new GpuWorldState(); - LandblockBuild source = BuildWithShell(landblockId, Entity(9)); + var origin = new LandblockBuildOrigin(0x40, 0x41); + LandblockBuild source = BuildWithShell(landblockId, Entity(9)) with + { + Origin = origin, + }; LandblockBuild? published = null; int replayCount = 0; int recoveryCount = 0; @@ -121,6 +125,7 @@ public sealed class LandblockPresentationPipelineTests Assert.Empty(published.Landblock.Entities); Assert.Same(PhysicsDatBundle.Empty, published.Landblock.PhysicsDats); Assert.Null(published.EnvCells); + Assert.Equal(origin, published.Origin); Assert.True(state.IsLoaded(landblockId)); Assert.False(state.IsNearTier(landblockId)); Assert.Equal(0, replayCount); From e4a9664f09f3957809856515a4e03316726a0265 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 20:13:25 +0200 Subject: [PATCH 57/98] docs(plan): complete landblock streaming seams --- ...1-gamewindow-slice-5-landblock-presentation.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md index eadfca80..5a55579c 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md +++ b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md @@ -14,7 +14,7 @@ test, and bisectable commit rather than being hidden in a mechanical move. ## Progress ledger -- [ ] A — freeze production order and introduce explicit build/publication +- [x] A — freeze production order and introduce explicit build/publication seams plus structural guards. - A1 landed in `4f965d06`: accepted results now enter one staged `LandblockPresentationPipeline` transaction; `GpuWorldState` returns an @@ -22,8 +22,16 @@ test, and bisectable commit rather than being hidden in a mechanical move. activation; retries preserve FIFO suffixes, pending-live merges, and promotion identity across policy/generation changes. Three corrected-diff reviews are clean. Release gate: 6,955 passed / 5 skipped. + - A2 landed in `090b0354`: every production load now captures a specified + immutable origin (including valid map origin `(0,0)`) and carries it through + worker construction, priority/supersession, completion, Far normalization, + and publication. Origin-less or mismatched request-aware work fails before + it can publish. Three corrected-diff reviews are clean. Release gate: 6,965 + passed / 5 skipped. - [ ] B — capture one immutable world-origin snapshot per load and extract the serialized DAT-backed `LandblockBuildFactory`. + - Origin capture is complete in A2; B now contains only the DAT-backed + factory extraction and its fixture/serialization gates. - [ ] C — extract terrain, visibility, EnvCell, building, and render-pinning publication. - [ ] D — extract physics/cache/collision publication and cross-landblock @@ -257,8 +265,9 @@ AdvanceLandblockPresentationRetirement - Add owner-call trace tests around the current production order and payload multiplicity. -- Introduce immutable build/publication request/result seams and diagnostics - snapshots without changing the live call graph. +- Introduce immutable build/publication request/result seams without changing + the live call graph. The typed apply-diagnostics snapshot moves with its + concrete render owner in C instead of creating a temporary A-stage owner. - Add dependency/reflection guards: no origin-state read from worker, no DAT in publish/retire interfaces, no residency/generation map in the pipeline. From 5af101b2f1df6fb5ea3f9ccdf8aa38c86b65f73a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 20:30:32 +0200 Subject: [PATCH 58/98] refactor(streaming): extract landblock build factory --- src/AcDream.App/Rendering/GameWindow.cs | 800 +---------------- .../Streaming/LandblockBuildFactory.cs | 808 ++++++++++++++++++ .../Streaming/LandblockBuildFactoryTests.cs | 509 +++++++++++ .../Streaming/LandblockBuildOriginTests.cs | 38 +- 4 files changed, 1361 insertions(+), 794 deletions(-) create mode 100644 src/AcDream.App/Streaming/LandblockBuildFactory.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ac7aca12..21b41a7a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -205,26 +205,23 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Phase A.1 hotfix / Phase A.5 T10: DatCollection is NOT thread-safe. // DatReaderWriter's DatBinReader uses a shared buffer position internally — // concurrent _dats.Get calls from the streaming worker thread (T11+) and - // the render thread (BuildLandblockForStreaming on the worker; - // ApplyLoadedTerrain + live-spawn handlers + animation ticks on the render + // the render thread (LandblockBuildFactory on the worker; live-spawn + // handlers + animation ticks on the render // thread) corrupt that state and produce half-populated LandBlock.Height[] // arrays, rendering as "a giant ball with spikes". All _dats.Get call // sites that can race with the streaming worker MUST hold this lock. // - // Worker-thread dat reads enter via the factory closures passed to - // LandblockStreamer at construction (loadLandblock + buildMeshOrNull). - // Those closures already acquire _datLock, so no additional wrapping is - // needed for reads inside BuildLandblockForStreamingLocked / - // BuildSceneryEntitiesForStreaming / BuildInteriorEntitiesForStreaming. - // Render-thread paths (ApplyLoadedTerrain and live hydration) already - // hold this lock via their outer wrappers; all remaining render-thread + // Worker-thread DAT reads enter through LandblockBuildFactory.Build, which + // holds _datLock for the complete CPU transaction. The terrain-mesh closure + // consumes that completed heightmap and performs no DAT read. Render-thread + // live hydration holds this lock via its outer wrapper; all remaining + // render-thread // _dats.Get calls run only when no worker dat read can be in flight (during // initialization or within the same lock scope). private readonly object _datLock = new(); - // Terrain build context shared across all streamed landblocks. Stored as - // fields so ApplyLoadedTerrain (render-thread callback) can call - // LandblockMesh.Build without re-deriving these each time. + // Terrain build context shared by the off-thread mesh-build closure across + // all streamed landblocks. private float[]? _heightTable; private AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx; // Was: Dictionary. ConcurrentDictionary so the off-thread @@ -2491,8 +2488,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so // it can call LandblockMesh.Build without a dat read — _heightTable and // _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9). + var landblockBuildFactory = new AcDream.App.Streaming.LandblockBuildFactory( + _dats!, + _datLock, + _heightTable!, + _physicsDataCache, + _options.DumpSceneryZ); _streamer = AcDream.App.Streaming.LandblockStreamer.CreateForRequests( - loadLandblock: BuildLandblockForStreaming, + loadLandblock: landblockBuildFactory.Build, buildMeshOrNull: (id, lb) => { if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null) @@ -3078,15 +3081,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext session.SendTurbineChatTo( roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - - private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY) - { - uint landblockX = (block.Id >> 24) & 0xFFu; - uint landblockY = (block.Id >> 16) & 0xFFu; - return AcDream.Core.Physics.TerrainSurface.SampleZFromHeightmap( - block.Height, heightTable, landblockX, landblockY, localX, localY); - } - /// /// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) + /// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette / @@ -3780,774 +3774,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext $"live: AdminEnvirons sound cue = {name} " + $"(0x{environChangeType:X2}) — audio binding pending"); } - - /// - /// Phase A.1: streaming load delegate, runs on the worker thread. - /// Reads the landblock from the dats, hydrates its stab entities (same - /// path as the old preload), and returns a fully-populated LoadedLandblock. - /// Thread-safe: uses only DatCollection reads (documented thread-safe by - /// DatReaderWriter) and pure CPU work. No GL calls here. - /// - /// MVP scope: stabs only. Scenery + interior added in Task 8. - /// - /// ISSUE #54 (post-A.5): far-tier loads (kind == LoadFar) skip - /// LandBlockInfo + scenery + interior hydration. They return only the - /// LandBlock heightmap dat record + an empty entity list — enough for - /// terrain-mesh build on the next phase. Near-tier loads run the full - /// path. This replaces Bug A's post-load entity strip in - /// with an - /// early-out at the source. - /// - private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming( - AcDream.App.Streaming.LandblockBuildRequest request) - { - if (_dats is null) return null; - - // Phase A.1 hotfix: hold the dat lock for the entire load. The worker - // thread mustn't read dats concurrently with the render thread's - // ApplyLoadedTerrain / live-spawn handlers. Hold time is bounded by - // the size of a single landblock's CPU-side build (tens of ms worst - // case), which blocks the render thread for at most that duration. - // This is the minimum correct behavior; a future pass can reduce - // contention by pre-building render-thread work on the worker. - // tp-probe (2026-06-22, REMOVABLE): measure lock-WAIT (the _datLock - // contention signal — large only when the render thread is hammering the - // lock during a CreateObject flood) AND lock-HOLD (the intrinsic build - // cost). Identical work in both branches; the probe branch only adds the - // stopwatch + log. No behavior change when ProbeTeleportEnabled is false. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled) - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - lock (_datLock) - { - long waitedMs = sw.ElapsedMilliseconds; - sw.Restart(); - var built = BuildLandblockForStreamingLocked(request); - AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( - "BUILD", request.LandblockId, - $"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}"); - return built; - } - } - lock (_datLock) - { - return BuildLandblockForStreamingLocked(request); - } - } - - private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreamingLocked( - AcDream.App.Streaming.LandblockBuildRequest request) - { - if (_dats is null) return null; - uint landblockId = request.LandblockId; - - // ISSUE #54: far-tier early-out — heightmap only, empty entities. - // Skips the LandBlockInfo dat read AND all entity hydration (stabs - // + buildings) AND the SceneryGenerator AND interior cells. Cuts - // worker-thread cost per far-tier LB from ~tens of ms to a single - // dat read. - if (request.Kind == AcDream.App.Streaming.LandblockStreamJobKind.LoadFar) - { - var heightmapOnly = _dats.Get(landblockId); - if (heightmapOnly is null) return null; - return new AcDream.App.Streaming.LandblockBuild( - new AcDream.Core.World.LoadedLandblock( - landblockId, - heightmapOnly, - System.Array.Empty(), - AcDream.Core.World.PhysicsDatBundle.Empty), - Origin: request.Origin); // far tier: no cells/buildings/entities - } - - var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId); - if (baseLoaded is null) return null; - - int lbX = (int)((landblockId >> 24) & 0xFFu); - int lbY = (int)((landblockId >> 16) & 0xFFu); - var worldOffset = new System.Numerics.Vector3( - (lbX - request.Origin.CenterX) * 192f, - (lbY - request.Origin.CenterY) * 192f, - 0f); - - // Hydrate the stabs: same logic as the old OnLoad preload. Each stab - // entity from LandblockLoader carries a SourceGfxObjOrSetupId that we - // expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build. - // GPU upload (EnsureUploaded) happens on the render thread in - // ApplyLoadedTerrain — NOT here. - var hydrated = new List(baseLoaded.Entities.Count); - foreach (var e in baseLoaded.Entities) - { - var meshRefs = new List(); - var stabBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); - - if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) - { - // Single GfxObj stab — identity part transform. - var gfx = _dats.Get(e.SourceGfxObjOrSetupId); - if (gfx is not null) - { - _physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx); - var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); - if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); - meshRefs.Add(new AcDream.Core.World.MeshRef( - e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity)); - } - } - else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) - { - // Multi-part Setup — flatten to per-part GfxObj refs. - var setup = _dats.Get(e.SourceGfxObjOrSetupId); - if (setup is not null) - { - _physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup); - var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); - foreach (var mr in flat) - { - var gfx = _dats.Get(mr.GfxObjId); - if (gfx is null) continue; - _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); - var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); - if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value); - meshRefs.Add(mr); - } - } - } - - if (meshRefs.Count == 0) continue; - - var entity = new AcDream.Core.World.WorldEntity - { - Id = e.Id, - SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId, - Position = e.Position + worldOffset, - Rotation = e.Rotation, - MeshRefs = meshRefs, - EffectCellId = e.EffectCellId, - IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag - BuildingShellAnchorCellId = e.BuildingShellAnchorCellId, - }; - if (stabBounds.TryGet(out var sbMin, out var sbMax)) - entity.SetLocalBounds(sbMin, sbMax); - hydrated.Add(entity); - } - - // Task 8: merge stabs + scenery + interior into one entity list. - var merged = new List(hydrated); - merged.AddRange(BuildSceneryEntitiesForStreaming( - baseLoaded, - lbX, - lbY, - request.Origin)); - var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId); - merged.AddRange(BuildInteriorEntitiesForStreaming( - landblockId, - lbX, - lbY, - request.Origin, - envCellBuild)); - - // datLock fix: pre-read (under the worker's _datLock) every dat the apply - // consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER - // `merged` so entity GfxObj/Setup ids are known. - var physicsDats = BuildPhysicsDatBundle(landblockId, merged); - var completedEnvCells = envCellBuild.Build(); - return new AcDream.App.Streaming.LandblockBuild( - new AcDream.Core.World.LoadedLandblock( - baseLoaded.LandblockId, - baseLoaded.Heightmap, - merged, - physicsDats), - completedEnvCells, - request.Origin); - } - private void EnsureEnvCellMeshesAfterPin( AcDream.App.Rendering.Wb.EnvCellLandblockBuild build) { if (_wbMeshAdapter?.MeshManager is { } meshManager) AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(build, meshManager); } - - /// - /// Pre-reads (under the worker's _datLock) every dat object - /// consumes, so the apply makes zero - /// DatCollection calls and the update thread never blocks on the - /// worker's lock. MIRRORS the apply's six read sites — gather and apply must - /// enumerate the same ids. Reads only; no build / registration. - /// - private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle( - uint landblockId, - System.Collections.Generic.IReadOnlyList entities) - { - var envCells = new System.Collections.Generic.Dictionary(); - var environments = new System.Collections.Generic.Dictionary(); - var setups = new System.Collections.Generic.Dictionary(); - var gfxObjs = new System.Collections.Generic.Dictionary(); - - // (1) LandBlockInfo - var lbInfo = _dats!.Get( - (landblockId & 0xFFFF0000u) | 0xFFFEu); - - if (lbInfo is not null) - { - // (2)+(3) EnvCell + Environment, per cell - if (lbInfo.NumCells > 0) - { - uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u; - for (uint offset = 0; offset < lbInfo.NumCells; offset++) - { - uint envCellId = firstCellId + offset; - var envCell = _dats.Get(envCellId); - if (envCell is null) continue; - envCells[envCellId] = envCell; - if (envCell.EnvironmentId == 0) continue; - uint envId = 0x0D000000u | envCell.EnvironmentId; - if (!environments.ContainsKey(envId)) - { - var environment = _dats.Get(envId); - if (environment is not null) environments[envId] = environment; - } - } - } - - // (4) building shell Setup, per building - foreach (var building in lbInfo.Buildings) - { - uint modelId = building.ModelId; - if ((modelId & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(modelId)) - { - var bldSetup = _dats.Get(modelId); - if (bldSetup is not null) setups[modelId] = bldSetup; - } - } - } - - // (5)+(6) entity GfxObj (BSP cache) + entity Setup (ShadowObjects parts/lights) - foreach (var entity in entities) - { - uint src = entity.SourceGfxObjOrSetupId; - if ((src & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(src)) - { - var s = _dats.Get(src); - if (s is not null) setups[src] = s; - } - foreach (var meshRef in entity.MeshRefs) - { - uint gid = meshRef.GfxObjId; - if ((gid & 0xFF000000u) != 0x01000000u) continue; - if (gfxObjs.ContainsKey(gid)) continue; - var gfx = _dats.Get(gid); - if (gfx is not null) gfxObjs[gid] = gfx; - } - } - - return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs); - } - - /// - /// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single - /// landblock on the worker thread. Pure CPU — no GL calls. - /// - /// Ported from the pre-streaming preload loop in GameWindow.OnLoad - /// (pre-Task-7 version, lines 329-405). Adapted to operate on a single - /// LoadedLandblock instead of iterating worldView.Landblocks. - /// - private List BuildSceneryEntitiesForStreaming( - AcDream.Core.World.LoadedLandblock lb, - int lbX, - int lbY, - AcDream.App.Streaming.LandblockBuildOrigin origin) - { - var result = new List(); - if (_dats is null || _heightTable is null) return result; - - var region = _dats.Get(0x13000000u); - if (region is null) return result; - - // Build a set of terrain vertex indices that have buildings on them, - // so the scenery generator can skip those cells (ACME conformance fix 4d). - HashSet? buildingCells = null; - var lbInfo = _dats.Get( - (lb.LandblockId & 0xFFFF0000u) | 0xFFFEu); - if (lbInfo is not null) - { - // Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are - // static scenery placeholders themselves (rocks, tree clusters) that - // retail does NOT use to suppress scenery generation. Including them - // here over-suppressed scenery in town landblocks. - buildingCells = new HashSet(); - foreach (var bldg in lbInfo.Buildings) - { - int cx = Math.Clamp((int)(bldg.Frame.Origin.X / 24f), 0, 8); - int cy = Math.Clamp((int)(bldg.Frame.Origin.Y / 24f), 0, 8); - buildingCells.Add(cx * 9 + cy); - } - } - - var spawns = AcDream.Core.World.SceneryGenerator.Generate( - _dats, region, lb.Heightmap, lb.LandblockId, buildingCells, _heightTable); - if (spawns.Count == 0) return result; - - var lbOffset = new System.Numerics.Vector3( - (lbX - origin.CenterX) * 192f, - (lbY - origin.CenterY) * 192f, - 0f); - - // Per-landblock id namespace. The top nibble identifies procedural - // scenery and the full X/Y bytes plus a 12-bit counter prevent dense - // DAT-generated landblocks from overflowing into a neighbour's range. - // See ProceduralSceneryIdAllocator for the audited 0x8XXYYIII layout. - uint lbXByte = (lb.LandblockId >> 24) & 0xFFu; - uint lbYByte = (lb.LandblockId >> 16) & 0xFFu; - uint sceneryCounter = 0; - - foreach (var spawn in spawns) - { - // Resolve the object to a mesh (same GfxObj/Setup logic as Stabs). - // Scale is baked into the root transform by wrapping each part's - // transform with a scale matrix. - var meshRefs = new List(); - var sceneryBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); - var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale); - - if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u) - { - var gfx = _dats.Get(spawn.ObjectId); - if (gfx is not null) - { - _physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx); - // Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain. - _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); - var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); - if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value); - meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat)); - } - } - else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u) - { - var setup = _dats.Get(spawn.ObjectId); - if (setup is not null) - { - _physicsDataCache.CacheSetup(spawn.ObjectId, setup); - var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); - foreach (var mr in flat) - { - var gfx = _dats.Get(mr.GfxObjId); - if (gfx is null) continue; - _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); - _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); - // Compose: part's own transform, then the spawn's scale. - var partXf = mr.PartTransform * scaleMat; - var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); - if (pb is not null) sceneryBounds.Add(partXf, pb.Value); - meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, partXf)); - } - } - } - - if (meshRefs.Count == 0) continue; - - // Sample terrain Z at (localX, localY) to lift scenery onto the - // ground. Add BaseLoc.Z from the scenery ObjectDesc (passed in as - // spawn.LocalPosition.Z) so meshes that specify a vertical offset - // from the ground (e.g., flowers at -0.1m, roots below terrain) - // settle properly. - float localX = spawn.LocalPosition.X; - float localY = spawn.LocalPosition.Y; - // Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ) - // — it uses the same AC2D render split-direction formula the - // TerrainModernRenderer uses for the visible terrain mesh. This - // guarantees trees are placed on the SAME Z height the player - // walks on. If physics hasn't registered this landblock yet, - // fall back to the local bilinear sample. - var worldPx = localX + lbOffset.X; - var worldPy = localY + lbOffset.Y; - // FIX (trees-in-sky, 2026-06-22): scenery ground-Z comes from THIS - // landblock's OWN heightmap — the same triangle-aware Z the player walks on - // (TerrainSurface.SampleZFromHeightmap, lock-step with physics per #48), - // scoped to the landblock being built. The former global - // _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at - // build time this landblock is NOT registered in physics yet, so that query - // could only return null (→ this same own-heightmap) or a STALE neighbor's - // height — the previous location's terrain, still registered after a teleport - // recenter (which drops only the single stale CENTER landblock, GameWindow - // :5444) until streaming unloads it — planting scenery at the old location's - // altitude (trees-in-sky, deltaZ up to +500m; confirmed via the - // [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every - // case, so the global query is removed (also drops its per-spawn cost). - float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY); - float finalZ = groundZ + spawn.LocalPosition.Z; - - // Issue #48 diagnostic. One log line per (spawn, rendered-mesh) - // disambiguates H1 (BaseLoc.Z / mesh-zMin per-species), H2 - // (physics-vs-bilinear sampler drift), and H3 (DIDDegrade slot 0). - // User identifies a floating tree visually, finds the matching - // line by world coords + gfx id, the data picks the hypothesis. - if (_options.DumpSceneryZ) - { - // groundZ now always comes from THIS landblock's own heightmap (the - // global physics query was removed — see the trees-in-sky fix above). - string source = "heightmap"; - foreach (var mr in meshRefs) - { - var dgfx = _dats.Get(mr.GfxObjId); - if (dgfx is null) continue; - - float zMin = float.PositiveInfinity, zMax = float.NegativeInfinity; - foreach (var v in dgfx.VertexArray.Vertices.Values) - { - if (v.Origin.Z < zMin) zMin = v.Origin.Z; - if (v.Origin.Z > zMax) zMax = v.Origin.Z; - } - if (float.IsPositiveInfinity(zMin)) { zMin = 0f; zMax = 0f; } - - // Per-part transform offset inside the setup (post-spawn-scale). - // For setup spawns this is Setup.PlacementFrames[Default].Frames[i] * - // spawn.Scale. For single-GfxObj spawns it's identity * spawn.Scale. - var partT = mr.PartTransform.Translation; - - bool hasDD = dgfx.Flags.HasFlag(DatReaderWriter.Enums.GfxObjFlags.HasDIDDegrade); - string ddInfo = string.Empty; - if (hasDD && dgfx.DIDDegrade != 0) - { - var ddi = _dats.Get(dgfx.DIDDegrade); - if (ddi is not null && ddi.Degrades.Count > 0) - { - uint slot0Id = (uint)ddi.Degrades[0].Id; - float slot0Min = 0f; - var slot0Gfx = _dats.Get(slot0Id); - if (slot0Gfx is not null && slot0Gfx.VertexArray.Vertices.Count > 0) - { - slot0Min = float.PositiveInfinity; - foreach (var v in slot0Gfx.VertexArray.Vertices.Values) - if (v.Origin.Z < slot0Min) slot0Min = v.Origin.Z; - if (float.IsPositiveInfinity(slot0Min)) slot0Min = 0f; - } - ddInfo = $" deg[0]=0x{slot0Id:X8} deg[0]ZMin={slot0Min:F3}"; - } - } - - // partWorldZMin = the lowest vertex of this part in world space. - // = finalZ (setup origin in world Z) + partT.Z (part offset) + zMin (mesh-local lowest vertex) - // If everything is right and the lowest part of the tree should - // touch the ground, we expect partWorldZMin <= groundZ for at - // least one part of a multi-part setup. - float partWorldZMin = finalZ + partT.Z + zMin; - - Console.WriteLine( - $"[scenery-z] lb=0x{lb.LandblockId:X8} root=0x{spawn.ObjectId:X8} gfx=0x{mr.GfxObjId:X8}" + - $" source={source}" + - $" world=({worldPx:F2},{worldPy:F2}) localXY=({localX:F2},{localY:F2})" + - $" groundZ={groundZ:F3} BaseLoc.Z={spawn.LocalPosition.Z:F3} finalZ={finalZ:F3}" + - $" partT=({partT.X:F2},{partT.Y:F2},{partT.Z:F3}) spawnScale={spawn.Scale:F3}" + - $" zRange=[{zMin:F3}..{zMax:F3}] partWorldZMin={partWorldZMin:F3} delta={partWorldZMin - groundZ:F3}" + - $" hasDIDDegrade={hasDD}{ddInfo}"); - } - } - - var hydrated = new AcDream.Core.World.WorldEntity - { - Id = AcDream.Core.World.ProceduralSceneryIdAllocator.Allocate( - lbXByte, - lbYByte, - ref sceneryCounter), - SourceGfxObjOrSetupId = spawn.ObjectId, - Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset, - Rotation = spawn.Rotation, - MeshRefs = meshRefs, - Scale = spawn.Scale, - EffectCellId = AcDream.Core.Physics.TerrainSurface.ComputeOutdoorCellId( - lb.LandblockId, - localX, - localY), - }; - if (sceneryBounds.TryGet(out var scbMin, out var scbMax)) - hydrated.SetLocalBounds(scbMin, scbMax); - result.Add(hydrated); - } - - return result; - } - - /// - /// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell - /// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and - /// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls. - /// - /// Portal cells and drawable shell placements are accumulated in the - /// transaction-local . The render thread - /// cannot observe this landblock until the streaming completion carries the - /// finished transaction to ApplyLoadedTerrain. - /// - /// Ported from pre-streaming preload lines 407-565. - /// - private List BuildInteriorEntitiesForStreaming( - uint landblockId, - int lbX, - int lbY, - AcDream.App.Streaming.LandblockBuildOrigin origin, - AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild) - { - var result = new List(); - if (_dats is null) return result; - - var lbInfo = _dats.Get((landblockId & 0xFFFF0000u) | 0xFFFEu); - if (lbInfo is null || lbInfo.NumCells == 0) return result; - - var lbOffset = new System.Numerics.Vector3( - (lbX - origin.CenterX) * 192f, - (lbY - origin.CenterY) * 192f, - 0f); - - // Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator - // for the full bit layout + history. Distinct from scenery (0x80000000+) and - // landblock stabs (0xC0000000+, ids from LandblockLoader). - // - // #119 ROOT-CAUSE FIX (2026-06-11): this used to be - // `0x40000000 | (landblockId & 0x00FFFF00)`, which for landblock keys 0xXXYYFFFF - // resolves to 0x40YYFF00 — the landblock X byte DISCARDED. Every landblock in a - // map Y-row produced the same id base, so interior statics collided across - // landblocks (Holtburg town A9B3's 9th stab == the AAB3 tower's 43-part spiral - // staircase, both 0x40B3FF09). The Tier-1 classification cache then served one - // entity's batches to the other (the cache hint at bucket-draw time was the - // player's landblock, identical for both) — the session-sticky "broken stairs + - // water barrel". - // - // #190 (2026-07-09): the fix above LEFT a documented residual — "counter overflow - // past 0xFF still bleeds into the lbY byte." That residual manifested for real: - // the Town Network hub (205 cells, one landblock) reached 277 interior entities - // after the #79/#93 A7.L1 light-carrier hydration fix, aliasing into the NEXT - // landblock's Y-byte (entity script/particle tracking is keyed on entity.Id - // directly — EntityScriptActivator — with no landblock-hint disambiguation, so - // the fountain's water-spray script silently stopped firing). Widened the - // counter budget 8→12 bits (256→4096); see InteriorEntityIdAllocator's doc for - // why this is safe (nothing decodes X/Y back out of an entity id). - uint interiorLbX = (landblockId >> 24) & 0xFFu; - uint interiorLbY = (landblockId >> 16) & 0xFFu; - uint localCounter = 0; - - uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u; - for (uint offset = 0; offset < lbInfo.NumCells; offset++) - { - uint envCellId = firstCellId + offset; - var envCell = _dats.Get(envCellId); - if (envCell is null) - { - // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix): - // every id in [0x0100, 0x0100+NumCells) is derived from LandBlockInfo and - // MUST exist in the cell dat — a null here is always a read anomaly. - Console.WriteLine($"[cell-miss] EnvCell 0x{envCellId:X8} null during interior hydration (NumCells={lbInfo.NumCells})"); - continue; - } - - // Phase 7.1: build and register room geometry for this EnvCell. - DatReaderWriter.Types.CellStruct? cellStruct = null; - if (envCell.EnvironmentId != 0) - { - var environment = _dats.Get(0x0D000000u | envCell.EnvironmentId); - if (environment is null) - { - // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix): - // a null Environment means this cell's WALLS are silently never - // registered while its static objects still draw — the exact - // white-walls geometry signature. - Console.WriteLine($"[cell-miss] Environment 0x{0x0D000000u | envCell.EnvironmentId:X8} null for EnvCell 0x{envCellId:X8} -> walls not registered"); - } - if (environment is not null - && environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct)) - { - // Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as - // WorldEntities with fake MeshRefs. CellMesh.Build remains the existing - // drawable-geometry predicate; the actual shell placement is now owned - // by this streaming job's EnvCellLandblockBuild transaction. - // Static objects inside the cell continue to flow through the dispatcher - // as WorldEntity records below — they have real GfxObj MeshRefs that work - // fine; EnvCellRenderer receives only the completed shell transaction. - // Transforms — needed by the portal-visibility cell (unlifted) AND the - // render/physics path. Computed for EVERY cell with a valid cellStruct, - // not just drawable ones. Keep the small render lift out of physics; retail - // BSP contact planes use the EnvCell origin verbatim. The lift constant is - // shared with every draw-space consumer of portal polygons (OutsideView - // gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130). - var physicsCellOrigin = envCell.Position.Origin + lbOffset; - var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( - 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); - var cellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); - var physicsCellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); - - // PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless - // of whether CellMesh.Build produced drawable sub-meshes. A portals-only - // pass-through connector (a ramp / stair / cellar mouth) yields 0 render - // sub-meshes but MUST be in the visibility graph so the flood can traverse it - // to the cells beyond — otherwise the flood lookup-misses the unregistered - // neighbour and the grey clear shows through the opening (#133: ramp - // neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier - // at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace] - // skip=lookup-miss). Retail keeps the whole landblock cell array resident - // before the flood runs; the cell-build transaction reads portals, NOT - // the render sub-meshes. The +0.02 m render lift is a DRAW concern only and - // is intentionally NOT fed into the visibility transform (#119-residual: the - // lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells). - var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); - envCellBuild.AddCell( - envCellId, - envCell, - cellStruct, - physicsCellOrigin, - physicsCellTransform, - cellOrigin, - cellTransform, - hasDrawableGeometry: cellSubMeshes.Count > 0); - } - } - - // Phase 2d: static objects inside the EnvCell. - foreach (var stab in envCell.StaticObjects) - { - // #119 decisive probe: HYDRATE-side dump for ACDREAM_DUMP_ENTITY- - // targeted stabs. This is the MOMENT MeshRefs are constructed — - // a degraded dat read here (setup null / placement frames short / - // part GfxObj null) permanently corrupts the entity (H-A), and - // nothing downstream ever rebuilds it. Inert when the set is empty. - bool dumpStab = AcDream.Core.Rendering.RenderingDiagnostics - .DumpEntitySourceIds.Contains(stab.Id); - int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0; - - // #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to - // nothing (GfxObj id 0) at any runtime distance, so retail's distance-based - // degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the - // WorldBuilder editor shows it at the origin. acdream's render path came from - // WB (no distance LOD), so without this skip it draws the marker forever (the - // red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs - // skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0). - if ((stab.Id & 0xFF000000u) == 0x01000000u - && AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id)) - continue; - - var meshRefs = new List(); - var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); - // #79/#93 (2026-07-09): a Setup-sourced stab whose sole visual part is a - // runtime-hidden marker (#136) flattens to zero mesh refs even though its - // Setup carries real Lights — a "light attach point" fixture (e.g. the Town - // Network fountain room's ceiling light, Setup 0x02000365). Track the dat - // Setup's Lights.Count here so the meshRefs==0 gate below doesn't also drop - // the entity that would otherwise carry those lights to the registration - // pass (GameWindow.cs ~7900). - int stabLightCount = 0; - if ((stab.Id & 0xFF000000u) == 0x01000000u) - { - var gfx = _dats.Get(stab.Id); - if (gfx is not null) - { - _physicsDataCache.CacheGfxObj(stab.Id, gfx); - _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); - var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); - if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); - meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); - } - else if (dumpStab) - { - Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} GFXOBJ-NULL -> entity dropped"); - } - } - else if ((stab.Id & 0xFF000000u) == 0x02000000u) - { - var setup = _dats.Get(stab.Id); - if (setup is not null) - { - _physicsDataCache.CacheSetup(stab.Id, setup); - stabLightCount = setup.Lights.Count; - var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); - if (dumpStab) - { - dumpSetupParts = setup.Parts.Count; - dumpPlacementFrames = setup.PlacementFrames.Count; - dumpFlattened = flat.Count; - } - foreach (var mr in flat) - { - // #136: skip an editor-only marker PART (retail hides it at runtime - // distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole - // part GfxObj 0x010028CA is such a marker — skipping it empties - // meshRefs and the whole stab drops below. - if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId)) - continue; - var gfx = _dats.Get(mr.GfxObjId); - if (gfx is null) - { - dumpDropped++; - if (dumpStab) - Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} part gfx=0x{mr.GfxObjId:X8} GFXOBJ-NULL -> part dropped"); - continue; - } - _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); - _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); - var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); - if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value); - meshRefs.Add(mr); - } - } - else if (dumpStab) - { - Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} SETUP-NULL -> entity dropped"); - } - } - - if (!AcDream.Core.Meshing.EntityHydrationRules.ShouldKeepEntity(meshRefs.Count, stabLightCount)) - { - if (dumpStab) - Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights=0 -> entity dropped"); - continue; - } - if (meshRefs.Count == 0 && dumpStab) - Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights={stabLightCount} -> KEPT as mesh-less light carrier"); - - // Stabs inside EnvCells are already in landblock-local coordinates - // (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would - // be wrong — see Phase 2d comment in the pre-streaming preload. - var worldPos = stab.Frame.Origin + lbOffset; - var worldRot = stab.Frame.Orientation; - - var hydrated = new AcDream.Core.World.WorldEntity - { - Id = AcDream.Core.World.InteriorEntityIdAllocator.Allocate( - interiorLbX, - interiorLbY, - ref localCounter), - SourceGfxObjOrSetupId = stab.Id, - Position = worldPos, - Rotation = worldRot, - MeshRefs = meshRefs, - ParentCellId = envCellId, - }; - if (interiorBounds.TryGet(out var ibMin, out var ibMax)) - hydrated.SetLocalBounds(ibMin, ibMax); - - if (dumpStab) - { - Console.WriteLine( - $"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} entId=0x{hydrated.Id:X8} " + - $"setupParts={dumpSetupParts} placementFrames={dumpPlacementFrames} flattened={dumpFlattened} " + - $"built={meshRefs.Count} dropped={dumpDropped} " + - $"pos=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2})"); - for (int i = 0; i < meshRefs.Count; i++) - { - var t = meshRefs[i].PartTransform.Translation; - Console.WriteLine($"[dump-entity] hyd-part[{i:D2}] gfx=0x{meshRefs[i].GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3})"); - } - } - - result.Add(hydrated); - } - } - - return result; - } - private void AdvanceLandblockPresentationRetirement( AcDream.App.Streaming.LandblockRetirementTicket ticket) { diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs new file mode 100644 index 00000000..1fc1903a --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs @@ -0,0 +1,808 @@ +using DatReaderWriter; +using AcDream.Content; + +namespace AcDream.App.Streaming; + +/// +/// Owns the complete CPU-side transaction for one streamed landblock. Every +/// DAT read for a build is serialized by the shared reader gate; no renderer, +/// GL, physics-world, residency, or live-origin state is reachable here. +/// The hydration and cell-registration order follows the extracted +/// WorldBuilder inventory and retail CLandBlock::init_static_objs +/// (0x00530A40), CLandBlock::grab_visible_cells (0x0052F460), +/// PView::InitCell (0x005A4B70), and +/// CObjCell::init_objects (0x0052B420). +/// See docs/architecture/worldbuilder-inventory.md. +/// +public sealed class LandblockBuildFactory +{ + private readonly IDatReaderWriter _dats; + private readonly object _datLock; + private readonly float[] _heightTable; + private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache; + private readonly bool _dumpSceneryZ; + + public LandblockBuildFactory( + IDatReaderWriter dats, + object datLock, + float[] heightTable, + AcDream.Core.Physics.PhysicsDataCache physicsDataCache, + bool dumpSceneryZ = false) + { + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); + ArgumentNullException.ThrowIfNull(heightTable); + if (heightTable.Length < 256) + throw new ArgumentException( + "The retail terrain height table must contain at least 256 entries.", + nameof(heightTable)); + _heightTable = (float[])heightTable.Clone(); + _physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache)); + _dumpSceneryZ = dumpSceneryZ; + } + + + /// + /// Streaming load delegate that runs on the worker thread. Reads the + /// landblock from the DATs, hydrates its entities, and returns a complete + /// CPU-side build. The shared reader gate serializes the full transaction; + /// no GL or update-thread state is accessed here. + /// + /// ISSUE #54 (post-A.5): far-tier loads (kind == LoadFar) skip + /// LandBlockInfo + scenery + interior hydration. They return only the + /// LandBlock heightmap dat record + an empty entity list — enough for + /// terrain-mesh build on the next phase. Near-tier loads run the full + /// path. This replaces Bug A's post-load entity strip in + /// with an + /// early-out at the source. + /// + public AcDream.App.Streaming.LandblockBuild? Build( + AcDream.App.Streaming.LandblockBuildRequest request) + { + if (!request.Origin.IsSpecified) + throw new ArgumentException( + "A landblock build requires a specified captured origin.", + nameof(request)); + + // DatCollection is the sole DAT reader in process, so each landblock + // must observe one serialized read transaction. Holding the shared + // gate through mesh/bounds hydration prevents another consumer from + // interleaving reader cursor/cache state with this build. + // tp-probe (2026-06-22, REMOVABLE): measure lock-WAIT (the _datLock + // contention signal — large only when the render thread is hammering the + // lock during a CreateObject flood) AND lock-HOLD (the intrinsic build + // cost). Identical work in both branches; the probe branch only adds the + // stopwatch + log. No behavior change when ProbeTeleportEnabled is false. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + lock (_datLock) + { + long waitedMs = sw.ElapsedMilliseconds; + sw.Restart(); + var built = BuildLocked(request); + AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( + "BUILD", request.LandblockId, + $"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}"); + return built; + } + } + lock (_datLock) + { + return BuildLocked(request); + } + } + + private AcDream.App.Streaming.LandblockBuild? BuildLocked( + AcDream.App.Streaming.LandblockBuildRequest request) + { + uint landblockId = request.LandblockId; + + // ISSUE #54: far-tier early-out — heightmap only, empty entities. + // Skips the LandBlockInfo dat read AND all entity hydration (stabs + // + buildings) AND the SceneryGenerator AND interior cells. Cuts + // worker-thread cost per far-tier LB from ~tens of ms to a single + // dat read. + if (request.Kind == AcDream.App.Streaming.LandblockStreamJobKind.LoadFar) + { + var heightmapOnly = _dats.Get(landblockId); + if (heightmapOnly is null) return null; + return new AcDream.App.Streaming.LandblockBuild( + new AcDream.Core.World.LoadedLandblock( + landblockId, + heightmapOnly, + System.Array.Empty(), + AcDream.Core.World.PhysicsDatBundle.Empty), + Origin: request.Origin); // far tier: no cells/buildings/entities + } + + var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId); + if (baseLoaded is null) return null; + + int lbX = (int)((landblockId >> 24) & 0xFFu); + int lbY = (int)((landblockId >> 16) & 0xFFu); + var worldOffset = new System.Numerics.Vector3( + (lbX - request.Origin.CenterX) * 192f, + (lbY - request.Origin.CenterY) * 192f, + 0f); + + // Hydrate the stabs: same logic as the old OnLoad preload. Each stab + // entity from LandblockLoader carries a SourceGfxObjOrSetupId that we + // expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build. + // GPU upload (EnsureUploaded) happens on the render thread in + // ApplyLoadedTerrain — NOT here. + var hydrated = new List(baseLoaded.Entities.Count); + foreach (var e in baseLoaded.Entities) + { + var meshRefs = new List(); + var stabBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); + + if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) + { + // Single GfxObj stab — identity part transform. + var gfx = _dats.Get(e.SourceGfxObjOrSetupId); + if (gfx is not null) + { + _physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx); + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); + meshRefs.Add(new AcDream.Core.World.MeshRef( + e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity)); + } + } + else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) + { + // Multi-part Setup — flatten to per-part GfxObj refs. + var setup = _dats.Get(e.SourceGfxObjOrSetupId); + if (setup is not null) + { + _physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup); + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + foreach (var mr in flat) + { + var gfx = _dats.Get(mr.GfxObjId); + if (gfx is null) continue; + _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value); + meshRefs.Add(mr); + } + } + } + + if (meshRefs.Count == 0) continue; + + var entity = new AcDream.Core.World.WorldEntity + { + Id = e.Id, + SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId, + Position = e.Position + worldOffset, + Rotation = e.Rotation, + MeshRefs = meshRefs, + EffectCellId = e.EffectCellId, + IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag + BuildingShellAnchorCellId = e.BuildingShellAnchorCellId, + }; + if (stabBounds.TryGet(out var sbMin, out var sbMax)) + entity.SetLocalBounds(sbMin, sbMax); + hydrated.Add(entity); + } + + // Task 8: merge stabs + scenery + interior into one entity list. + var merged = new List(hydrated); + merged.AddRange(BuildSceneryEntitiesForStreaming( + baseLoaded, + lbX, + lbY, + request.Origin)); + var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId); + merged.AddRange(BuildInteriorEntitiesForStreaming( + landblockId, + lbX, + lbY, + request.Origin, + envCellBuild)); + + // Pre-read every DAT object publication consumes. Build this after + // `merged` so all entity GfxObj/Setup ids are known. + var physicsDats = BuildPhysicsDatBundle(landblockId, merged); + var completedEnvCells = envCellBuild.Build(); + return new AcDream.App.Streaming.LandblockBuild( + new AcDream.Core.World.LoadedLandblock( + baseLoaded.LandblockId, + baseLoaded.Heightmap, + merged, + physicsDats), + completedEnvCells, + request.Origin); + } + + + + /// + /// Pre-reads every DAT object that landblock publication consumes, so the + /// update thread performs no DAT reads. Gather and publication must + /// enumerate the same identifiers. Reads only; no runtime registration. + /// + private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle( + uint landblockId, + System.Collections.Generic.IReadOnlyList entities) + { + var envCells = new System.Collections.Generic.Dictionary(); + var environments = new System.Collections.Generic.Dictionary(); + var setups = new System.Collections.Generic.Dictionary(); + var gfxObjs = new System.Collections.Generic.Dictionary(); + + // (1) LandBlockInfo + var lbInfo = _dats!.Get( + (landblockId & 0xFFFF0000u) | 0xFFFEu); + + if (lbInfo is not null) + { + // (2)+(3) EnvCell + Environment, per cell + if (lbInfo.NumCells > 0) + { + uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u; + for (uint offset = 0; offset < lbInfo.NumCells; offset++) + { + uint envCellId = firstCellId + offset; + var envCell = _dats.Get(envCellId); + if (envCell is null) continue; + envCells[envCellId] = envCell; + if (envCell.EnvironmentId == 0) continue; + uint envId = 0x0D000000u | envCell.EnvironmentId; + if (!environments.ContainsKey(envId)) + { + var environment = _dats.Get(envId); + if (environment is not null) environments[envId] = environment; + } + } + } + + // (4) building shell Setup, per building + foreach (var building in lbInfo.Buildings) + { + uint modelId = building.ModelId; + if ((modelId & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(modelId)) + { + var bldSetup = _dats.Get(modelId); + if (bldSetup is not null) setups[modelId] = bldSetup; + } + } + } + + // (5)+(6) entity GfxObj (BSP cache) + entity Setup (ShadowObjects parts/lights) + foreach (var entity in entities) + { + uint src = entity.SourceGfxObjOrSetupId; + if ((src & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(src)) + { + var s = _dats.Get(src); + if (s is not null) setups[src] = s; + } + foreach (var meshRef in entity.MeshRefs) + { + uint gid = meshRef.GfxObjId; + if ((gid & 0xFF000000u) != 0x01000000u) continue; + if (gfxObjs.ContainsKey(gid)) continue; + var gfx = _dats.Get(gid); + if (gfx is not null) gfxObjs[gid] = gfx; + } + } + + return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs); + } + + /// + /// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single + /// landblock on the worker thread. Pure CPU — no GL calls. + /// + /// Ported from the pre-streaming preload loop in GameWindow.OnLoad + /// (pre-Task-7 version, lines 329-405). Adapted to operate on a single + /// LoadedLandblock instead of iterating worldView.Landblocks. + /// + private List BuildSceneryEntitiesForStreaming( + AcDream.Core.World.LoadedLandblock lb, + int lbX, + int lbY, + AcDream.App.Streaming.LandblockBuildOrigin origin) + { + var result = new List(); + + var region = _dats.Get(0x13000000u); + if (region is null) return result; + + // Build a set of terrain vertex indices that have buildings on them, + // so the scenery generator can skip those cells (ACME conformance fix 4d). + HashSet? buildingCells = null; + var lbInfo = _dats.Get( + (lb.LandblockId & 0xFFFF0000u) | 0xFFFEu); + if (lbInfo is not null) + { + // Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are + // static scenery placeholders themselves (rocks, tree clusters) that + // retail does NOT use to suppress scenery generation. Including them + // here over-suppressed scenery in town landblocks. + buildingCells = new HashSet(); + foreach (var bldg in lbInfo.Buildings) + { + int cx = Math.Clamp((int)(bldg.Frame.Origin.X / 24f), 0, 8); + int cy = Math.Clamp((int)(bldg.Frame.Origin.Y / 24f), 0, 8); + buildingCells.Add(cx * 9 + cy); + } + } + + var spawns = AcDream.Core.World.SceneryGenerator.Generate( + _dats, region, lb.Heightmap, lb.LandblockId, buildingCells, _heightTable); + if (spawns.Count == 0) return result; + + var lbOffset = new System.Numerics.Vector3( + (lbX - origin.CenterX) * 192f, + (lbY - origin.CenterY) * 192f, + 0f); + + // Per-landblock id namespace. The top nibble identifies procedural + // scenery and the full X/Y bytes plus a 12-bit counter prevent dense + // DAT-generated landblocks from overflowing into a neighbour's range. + // See ProceduralSceneryIdAllocator for the audited 0x8XXYYIII layout. + uint lbXByte = (lb.LandblockId >> 24) & 0xFFu; + uint lbYByte = (lb.LandblockId >> 16) & 0xFFu; + uint sceneryCounter = 0; + + foreach (var spawn in spawns) + { + // Resolve the object to a mesh (same GfxObj/Setup logic as Stabs). + // Scale is baked into the root transform by wrapping each part's + // transform with a scale matrix. + var meshRefs = new List(); + var sceneryBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); + var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale); + + if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u) + { + var gfx = _dats.Get(spawn.ObjectId); + if (gfx is not null) + { + _physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx); + // Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain. + _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value); + meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat)); + } + } + else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u) + { + var setup = _dats.Get(spawn.ObjectId); + if (setup is not null) + { + _physicsDataCache.CacheSetup(spawn.ObjectId, setup); + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + foreach (var mr in flat) + { + var gfx = _dats.Get(mr.GfxObjId); + if (gfx is null) continue; + _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); + _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + // Compose: part's own transform, then the spawn's scale. + var partXf = mr.PartTransform * scaleMat; + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) sceneryBounds.Add(partXf, pb.Value); + meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, partXf)); + } + } + } + + if (meshRefs.Count == 0) continue; + + // Sample terrain Z at (localX, localY) to lift scenery onto the + // ground. Add BaseLoc.Z from the scenery ObjectDesc (passed in as + // spawn.LocalPosition.Z) so meshes that specify a vertical offset + // from the ground (e.g., flowers at -0.1m, roots below terrain) + // settle properly. + float localX = spawn.LocalPosition.X; + float localY = spawn.LocalPosition.Y; + // Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ) + // — it uses the same AC2D render split-direction formula the + // TerrainModernRenderer uses for the visible terrain mesh. This + // guarantees trees are placed on the SAME Z height the player + // walks on. If physics hasn't registered this landblock yet, + // fall back to the local bilinear sample. + var worldPx = localX + lbOffset.X; + var worldPy = localY + lbOffset.Y; + // FIX (trees-in-sky, 2026-06-22): scenery ground-Z comes from THIS + // landblock's OWN heightmap — the same triangle-aware Z the player walks on + // (TerrainSurface.SampleZFromHeightmap, lock-step with physics per #48), + // scoped to the landblock being built. The former global + // _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at + // build time this landblock is NOT registered in physics yet, so that query + // could only return null (→ this same own-heightmap) or a STALE neighbor's + // height — the previous location's terrain, still registered after a teleport + // recenter (which drops only the single stale CENTER landblock, GameWindow + // :5444) until streaming unloads it — planting scenery at the old location's + // altitude (trees-in-sky, deltaZ up to +500m; confirmed via the + // [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every + // case, so the global query is removed (also drops its per-spawn cost). + float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY); + float finalZ = groundZ + spawn.LocalPosition.Z; + + // Issue #48 diagnostic. One log line per (spawn, rendered-mesh) + // disambiguates H1 (BaseLoc.Z / mesh-zMin per-species), H2 + // (physics-vs-bilinear sampler drift), and H3 (DIDDegrade slot 0). + // User identifies a floating tree visually, finds the matching + // line by world coords + gfx id, the data picks the hypothesis. + if (_dumpSceneryZ) + { + // groundZ now always comes from THIS landblock's own heightmap (the + // global physics query was removed — see the trees-in-sky fix above). + string source = "heightmap"; + foreach (var mr in meshRefs) + { + var dgfx = _dats.Get(mr.GfxObjId); + if (dgfx is null) continue; + + float zMin = float.PositiveInfinity, zMax = float.NegativeInfinity; + foreach (var v in dgfx.VertexArray.Vertices.Values) + { + if (v.Origin.Z < zMin) zMin = v.Origin.Z; + if (v.Origin.Z > zMax) zMax = v.Origin.Z; + } + if (float.IsPositiveInfinity(zMin)) { zMin = 0f; zMax = 0f; } + + // Per-part transform offset inside the setup (post-spawn-scale). + // For setup spawns this is Setup.PlacementFrames[Default].Frames[i] * + // spawn.Scale. For single-GfxObj spawns it's identity * spawn.Scale. + var partT = mr.PartTransform.Translation; + + bool hasDD = dgfx.Flags.HasFlag(DatReaderWriter.Enums.GfxObjFlags.HasDIDDegrade); + string ddInfo = string.Empty; + if (hasDD && dgfx.DIDDegrade != 0) + { + var ddi = _dats.Get(dgfx.DIDDegrade); + if (ddi is not null && ddi.Degrades.Count > 0) + { + uint slot0Id = (uint)ddi.Degrades[0].Id; + float slot0Min = 0f; + var slot0Gfx = _dats.Get(slot0Id); + if (slot0Gfx is not null && slot0Gfx.VertexArray.Vertices.Count > 0) + { + slot0Min = float.PositiveInfinity; + foreach (var v in slot0Gfx.VertexArray.Vertices.Values) + if (v.Origin.Z < slot0Min) slot0Min = v.Origin.Z; + if (float.IsPositiveInfinity(slot0Min)) slot0Min = 0f; + } + ddInfo = $" deg[0]=0x{slot0Id:X8} deg[0]ZMin={slot0Min:F3}"; + } + } + + // partWorldZMin = the lowest vertex of this part in world space. + // = finalZ (setup origin in world Z) + partT.Z (part offset) + zMin (mesh-local lowest vertex) + // If everything is right and the lowest part of the tree should + // touch the ground, we expect partWorldZMin <= groundZ for at + // least one part of a multi-part setup. + float partWorldZMin = finalZ + partT.Z + zMin; + + Console.WriteLine( + $"[scenery-z] lb=0x{lb.LandblockId:X8} root=0x{spawn.ObjectId:X8} gfx=0x{mr.GfxObjId:X8}" + + $" source={source}" + + $" world=({worldPx:F2},{worldPy:F2}) localXY=({localX:F2},{localY:F2})" + + $" groundZ={groundZ:F3} BaseLoc.Z={spawn.LocalPosition.Z:F3} finalZ={finalZ:F3}" + + $" partT=({partT.X:F2},{partT.Y:F2},{partT.Z:F3}) spawnScale={spawn.Scale:F3}" + + $" zRange=[{zMin:F3}..{zMax:F3}] partWorldZMin={partWorldZMin:F3} delta={partWorldZMin - groundZ:F3}" + + $" hasDIDDegrade={hasDD}{ddInfo}"); + } + } + + var hydrated = new AcDream.Core.World.WorldEntity + { + Id = AcDream.Core.World.ProceduralSceneryIdAllocator.Allocate( + lbXByte, + lbYByte, + ref sceneryCounter), + SourceGfxObjOrSetupId = spawn.ObjectId, + Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset, + Rotation = spawn.Rotation, + MeshRefs = meshRefs, + Scale = spawn.Scale, + EffectCellId = AcDream.Core.Physics.TerrainSurface.ComputeOutdoorCellId( + lb.LandblockId, + localX, + localY), + }; + if (sceneryBounds.TryGet(out var scbMin, out var scbMax)) + hydrated.SetLocalBounds(scbMin, scbMax); + result.Add(hydrated); + } + + return result; + + } + + /// + /// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell + /// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and + /// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls. + /// + /// Portal cells and drawable shell placements are accumulated in the + /// transaction-local . The render thread + /// cannot observe this landblock until the streaming completion carries the + /// finished transaction to ApplyLoadedTerrain. + /// + /// Ported from pre-streaming preload lines 407-565. + /// + private List BuildInteriorEntitiesForStreaming( + uint landblockId, + int lbX, + int lbY, + AcDream.App.Streaming.LandblockBuildOrigin origin, + AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild) + { + var result = new List(); + + var lbInfo = _dats.Get((landblockId & 0xFFFF0000u) | 0xFFFEu); + if (lbInfo is null || lbInfo.NumCells == 0) return result; + + var lbOffset = new System.Numerics.Vector3( + (lbX - origin.CenterX) * 192f, + (lbY - origin.CenterY) * 192f, + 0f); + + // Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator + // for the full bit layout + history. Distinct from scenery (0x80000000+) and + // landblock stabs (0xC0000000+, ids from LandblockLoader). + // + // #119 ROOT-CAUSE FIX (2026-06-11): this used to be + // `0x40000000 | (landblockId & 0x00FFFF00)`, which for landblock keys 0xXXYYFFFF + // resolves to 0x40YYFF00 — the landblock X byte DISCARDED. Every landblock in a + // map Y-row produced the same id base, so interior statics collided across + // landblocks (Holtburg town A9B3's 9th stab == the AAB3 tower's 43-part spiral + // staircase, both 0x40B3FF09). The Tier-1 classification cache then served one + // entity's batches to the other (the cache hint at bucket-draw time was the + // player's landblock, identical for both) — the session-sticky "broken stairs + + // water barrel". + // + // #190 (2026-07-09): the fix above LEFT a documented residual — "counter overflow + // past 0xFF still bleeds into the lbY byte." That residual manifested for real: + // the Town Network hub (205 cells, one landblock) reached 277 interior entities + // after the #79/#93 A7.L1 light-carrier hydration fix, aliasing into the NEXT + // landblock's Y-byte (entity script/particle tracking is keyed on entity.Id + // directly — EntityScriptActivator — with no landblock-hint disambiguation, so + // the fountain's water-spray script silently stopped firing). Widened the + // counter budget 8→12 bits (256→4096); see InteriorEntityIdAllocator's doc for + // why this is safe (nothing decodes X/Y back out of an entity id). + uint interiorLbX = (landblockId >> 24) & 0xFFu; + uint interiorLbY = (landblockId >> 16) & 0xFFu; + uint localCounter = 0; + + uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u; + for (uint offset = 0; offset < lbInfo.NumCells; offset++) + { + uint envCellId = firstCellId + offset; + var envCell = _dats.Get(envCellId); + if (envCell is null) + { + // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix): + // every id in [0x0100, 0x0100+NumCells) is derived from LandBlockInfo and + // MUST exist in the cell dat — a null here is always a read anomaly. + Console.WriteLine($"[cell-miss] EnvCell 0x{envCellId:X8} null during interior hydration (NumCells={lbInfo.NumCells})"); + continue; + } + + // Phase 7.1: build and register room geometry for this EnvCell. + DatReaderWriter.Types.CellStruct? cellStruct = null; + if (envCell.EnvironmentId != 0) + { + var environment = _dats.Get(0x0D000000u | envCell.EnvironmentId); + if (environment is null) + { + // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix): + // a null Environment means this cell's WALLS are silently never + // registered while its static objects still draw — the exact + // white-walls geometry signature. + Console.WriteLine($"[cell-miss] Environment 0x{0x0D000000u | envCell.EnvironmentId:X8} null for EnvCell 0x{envCellId:X8} -> walls not registered"); + } + if (environment is not null + && environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct)) + { + // Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as + // WorldEntities with fake MeshRefs. CellMesh.Build remains the existing + // drawable-geometry predicate; the actual shell placement is now owned + // by this streaming job's EnvCellLandblockBuild transaction. + // Static objects inside the cell continue to flow through the dispatcher + // as WorldEntity records below — they have real GfxObj MeshRefs that work + // fine; EnvCellRenderer receives only the completed shell transaction. + // Transforms — needed by the portal-visibility cell (unlifted) AND the + // render/physics path. Computed for EVERY cell with a valid cellStruct, + // not just drawable ones. Keep the small render lift out of physics; retail + // BSP contact planes use the EnvCell origin verbatim. The lift constant is + // shared with every draw-space consumer of portal polygons (OutsideView + // gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130). + var physicsCellOrigin = envCell.Position.Origin + lbOffset; + var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( + 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); + var cellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); + var physicsCellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); + + // PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless + // of whether CellMesh.Build produced drawable sub-meshes. A portals-only + // pass-through connector (a ramp / stair / cellar mouth) yields 0 render + // sub-meshes but MUST be in the visibility graph so the flood can traverse it + // to the cells beyond — otherwise the flood lookup-misses the unregistered + // neighbour and the grey clear shows through the opening (#133: ramp + // neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier + // at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace] + // skip=lookup-miss). Retail keeps the whole landblock cell array resident + // before the flood runs; the cell-build transaction reads portals, NOT + // the render sub-meshes. The +0.02 m render lift is a DRAW concern only and + // is intentionally NOT fed into the visibility transform (#119-residual: the + // lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells). + var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); + envCellBuild.AddCell( + envCellId, + envCell, + cellStruct, + physicsCellOrigin, + physicsCellTransform, + cellOrigin, + cellTransform, + hasDrawableGeometry: cellSubMeshes.Count > 0); + } + } + + // Phase 2d: static objects inside the EnvCell. + foreach (var stab in envCell.StaticObjects) + { + // #119 decisive probe: HYDRATE-side dump for ACDREAM_DUMP_ENTITY- + // targeted stabs. This is the MOMENT MeshRefs are constructed — + // a degraded dat read here (setup null / placement frames short / + // part GfxObj null) permanently corrupts the entity (H-A), and + // nothing downstream ever rebuilds it. Inert when the set is empty. + bool dumpStab = AcDream.Core.Rendering.RenderingDiagnostics + .DumpEntitySourceIds.Contains(stab.Id); + int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0; + + // #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to + // nothing (GfxObj id 0) at any runtime distance, so retail's distance-based + // degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the + // WorldBuilder editor shows it at the origin. acdream's render path came from + // WB (no distance LOD), so without this skip it draws the marker forever (the + // red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs + // skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0). + if ((stab.Id & 0xFF000000u) == 0x01000000u + && AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id)) + continue; + + var meshRefs = new List(); + var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); + // #79/#93 (2026-07-09): a Setup-sourced stab whose sole visual part is a + // runtime-hidden marker (#136) flattens to zero mesh refs even though its + // Setup carries real Lights — a "light attach point" fixture (e.g. the Town + // Network fountain room's ceiling light, Setup 0x02000365). Track the dat + // Setup's Lights.Count here so the meshRefs==0 gate below doesn't also drop + // the entity that would otherwise carry those lights to the registration + // pass (GameWindow.cs ~7900). + int stabLightCount = 0; + if ((stab.Id & 0xFF000000u) == 0x01000000u) + { + var gfx = _dats.Get(stab.Id); + if (gfx is not null) + { + _physicsDataCache.CacheGfxObj(stab.Id, gfx); + _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); + meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); + } + else if (dumpStab) + { + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} GFXOBJ-NULL -> entity dropped"); + } + } + else if ((stab.Id & 0xFF000000u) == 0x02000000u) + { + var setup = _dats.Get(stab.Id); + if (setup is not null) + { + _physicsDataCache.CacheSetup(stab.Id, setup); + stabLightCount = setup.Lights.Count; + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + if (dumpStab) + { + dumpSetupParts = setup.Parts.Count; + dumpPlacementFrames = setup.PlacementFrames.Count; + dumpFlattened = flat.Count; + } + foreach (var mr in flat) + { + // #136: skip an editor-only marker PART (retail hides it at runtime + // distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole + // part GfxObj 0x010028CA is such a marker — skipping it empties + // meshRefs and the whole stab drops below. + if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId)) + continue; + var gfx = _dats.Get(mr.GfxObjId); + if (gfx is null) + { + dumpDropped++; + if (dumpStab) + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} part gfx=0x{mr.GfxObjId:X8} GFXOBJ-NULL -> part dropped"); + continue; + } + _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); + _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value); + meshRefs.Add(mr); + } + } + else if (dumpStab) + { + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} SETUP-NULL -> entity dropped"); + } + } + + if (!AcDream.Core.Meshing.EntityHydrationRules.ShouldKeepEntity(meshRefs.Count, stabLightCount)) + { + if (dumpStab) + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights=0 -> entity dropped"); + continue; + } + if (meshRefs.Count == 0 && dumpStab) + Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights={stabLightCount} -> KEPT as mesh-less light carrier"); + + // Stabs inside EnvCells are already in landblock-local coordinates + // (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would + // be wrong — see Phase 2d comment in the pre-streaming preload. + var worldPos = stab.Frame.Origin + lbOffset; + var worldRot = stab.Frame.Orientation; + + var hydrated = new AcDream.Core.World.WorldEntity + { + Id = AcDream.Core.World.InteriorEntityIdAllocator.Allocate( + interiorLbX, + interiorLbY, + ref localCounter), + SourceGfxObjOrSetupId = stab.Id, + Position = worldPos, + Rotation = worldRot, + MeshRefs = meshRefs, + ParentCellId = envCellId, + }; + if (interiorBounds.TryGet(out var ibMin, out var ibMax)) + hydrated.SetLocalBounds(ibMin, ibMax); + + if (dumpStab) + { + Console.WriteLine( + $"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} entId=0x{hydrated.Id:X8} " + + $"setupParts={dumpSetupParts} placementFrames={dumpPlacementFrames} flattened={dumpFlattened} " + + $"built={meshRefs.Count} dropped={dumpDropped} " + + $"pos=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2})"); + for (int i = 0; i < meshRefs.Count; i++) + { + var t = meshRefs[i].PartTransform.Translation; + Console.WriteLine($"[dump-entity] hyd-part[{i:D2}] gfx=0x{meshRefs[i].GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3})"); + } + } + + result.Add(hydrated); + } + } + + return result; + } + + + private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY) + { + uint landblockX = (block.Id >> 24) & 0xFFu; + uint landblockY = (block.Id >> 16) & 0xFFu; + return AcDream.Core.Physics.TerrainSurface.SampleZFromHeightmap( + block.Height, heightTable, landblockX, landblockY, localX, localY); + } + +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs new file mode 100644 index 00000000..62108bd3 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs @@ -0,0 +1,509 @@ +using System.Reflection; +using System.Numerics; +using AcDream.App.Streaming; +using AcDream.Content; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit.Sdk; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockBuildFactoryTests +{ + private const uint LandblockId = 0xA9B4FFFFu; + private const uint AdjacentLandblockId = 0xA9B5FFFFu; + private static readonly LandblockBuildOrigin HoltburgOrigin = new(0xA9, 0xB4); + + [Fact] + public void BuildFar_ReturnsOnlyHeightmapAndCapturedOrigin() + { + var dat = CreateDat(out RecordingDatProxy proxy); + var heightmap = new LandBlock { Id = LandblockId }; + proxy.Add(LandblockId, heightmap); + var factory = Factory(dat, new object()); + + LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadFar)); + + Assert.NotNull(result); + Assert.Same(heightmap, result.Landblock.Heightmap); + Assert.Empty(result.Landblock.Entities); + Assert.Same(PhysicsDatBundle.Empty, result.Landblock.PhysicsDats); + Assert.Null(result.EnvCells); + Assert.Equal(HoltburgOrigin, result.Origin); + Assert.Equal([(typeof(LandBlock), LandblockId)], proxy.Reads); + } + + [Fact] + public void Build_RejectsUnspecifiedOriginBeforeReadingDat() + { + var dat = CreateDat(out RecordingDatProxy proxy); + var factory = Factory(dat, new object()); + + Assert.Throws(() => factory.Build( + new LandblockBuildRequest( + LandblockId, + LandblockStreamJobKind.LoadFar, + Generation: 1, + Origin: default))); + Assert.Empty(proxy.Reads); + } + + [Fact] + public void Build_MissingHeightmapReturnsNullWithoutPartialPayload() + { + var dat = CreateDat(out RecordingDatProxy proxy); + var factory = Factory(dat, new object()); + + LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadFar)); + + Assert.Null(result); + Assert.Equal([(typeof(LandBlock), LandblockId)], proxy.Reads); + } + + [Fact] + public void BuildNear_MissingLandblockInfoReturnsCompleteEmptyNearPayload() + { + var dat = CreateDat(out RecordingDatProxy proxy); + proxy.Add(LandblockId, new LandBlock { Id = LandblockId }); + var factory = Factory(dat, new object()); + + LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadNear)); + + Assert.NotNull(result); + Assert.Empty(result.Landblock.Entities); + var envCells = Assert.IsType( + result.EnvCells); + Assert.Empty(envCells.VisibilityCells); + Assert.Empty(envCells.Shells); + var physics = Assert.IsType(result.Landblock.PhysicsDats); + Assert.Null(physics.Info); + Assert.Empty(physics.EnvCells); + } + + [Fact] + public void BuildNear_MissingEnvCellSkipsOnlyThatCellWithoutPartialVisibilityEntry() + { + var dat = CreateDat(out RecordingDatProxy proxy); + AddNearFixture(proxy, LandblockId, environmentId: 1, cellCount: 2); + uint missingCell = (LandblockId & 0xFFFF0000u) | 0x0101u; + proxy.Remove(missingCell); + var factory = Factory(dat, new object()); + + LandblockBuild? result = factory.Build(Request(LandblockStreamJobKind.LoadNear)); + + Assert.NotNull(result); + var envCells = Assert.IsType( + result.EnvCells); + Assert.Equal( + [(LandblockId & 0xFFFF0000u) | 0x0100u], + envCells.VisibilityCells.Select(cell => cell.CellId)); + var physics = Assert.IsType(result.Landblock.PhysicsDats); + Assert.Single(physics.EnvCells); + Assert.DoesNotContain(missingCell, physics.EnvCells.Keys); + } + + [Fact] + public async Task Build_SerializesCompleteNearTransactionsOnSharedGate() + { + var dat = CreateDat(out RecordingDatProxy proxy); + AddNearFixture(proxy, LandblockId, environmentId: 1); + AddNearFixture(proxy, AdjacentLandblockId, environmentId: 2); + object gate = new(); + var factory = Factory(dat, gate); + + Assert.NotNull(factory.Build(Request( + LandblockId, + LandblockStreamJobKind.LoadNear, + generation: 1, + HoltburgOrigin))); + (Type Type, uint Id)[] firstSequence = proxy.Reads.ToArray(); + proxy.ClearReads(); + Assert.NotNull(factory.Build(Request( + AdjacentLandblockId, + LandblockStreamJobKind.LoadNear, + generation: 1, + new LandblockBuildOrigin(0xA9, 0xB5)))); + (Type Type, uint Id)[] secondSequence = proxy.Reads.ToArray(); + proxy.ClearReads(); + proxy.ReadDelay = TimeSpan.FromMilliseconds(5); + + Task first = Task.Run(() => + factory.Build(Request( + LandblockId, + LandblockStreamJobKind.LoadNear, + generation: 2, + HoltburgOrigin))); + Task second = Task.Run(() => + factory.Build(Request( + AdjacentLandblockId, + LandblockStreamJobKind.LoadNear, + generation: 3, + new LandblockBuildOrigin(0xA9, 0xB5)))); + LandblockBuild?[] results = await Task.WhenAll(first, second); + + Assert.All(results, Assert.NotNull); + Assert.Equal(1, proxy.MaxConcurrentReads); + (Type Type, uint Id)[] combined = proxy.Reads.ToArray(); + Assert.True( + combined.SequenceEqual(firstSequence.Concat(secondSequence)) + || combined.SequenceEqual(secondSequence.Concat(firstSequence)), + "Near-build DAT reads interleaved instead of remaining one serialized transaction."); + } + + [Fact] + public async Task Build_UsesTheSuppliedSharedReaderGate() + { + var dat = CreateDat(out RecordingDatProxy proxy); + proxy.Add(LandblockId, new LandBlock { Id = LandblockId }); + object gate = new(); + var factory = Factory(dat, gate); + using var started = new ManualResetEventSlim(); + + Monitor.Enter(gate); + Task build; + try + { + build = Task.Run(() => + { + started.Set(); + return factory.Build(Request(LandblockStreamJobKind.LoadFar)); + }); + Assert.True(started.Wait(TimeSpan.FromSeconds(2))); + Assert.False(proxy.ReadObserved.Wait(TimeSpan.FromMilliseconds(100))); + } + finally + { + Monitor.Exit(gate); + } + + Assert.NotNull(await build); + Assert.True(proxy.ReadObserved.IsSet); + } + + [Theory] + [InlineData(LandblockStreamJobKind.LoadNear)] + [InlineData(LandblockStreamJobKind.PromoteToNear)] + public void Build_NearAndPromoteIncludeEveryValidZeroSubmeshVisibilityCell( + LandblockStreamJobKind kind) + { + var dat = CreateDat(out RecordingDatProxy proxy); + AddNearFixture(proxy, LandblockId, environmentId: 1, cellCount: 2); + var factory = Factory(dat, new object()); + + LandblockBuild? result = factory.Build(Request(kind)); + + Assert.NotNull(result); + var envCells = Assert.IsType( + result.EnvCells); + var physics = Assert.IsType(result.Landblock.PhysicsDats); + Assert.Equal( + [(LandblockId & 0xFFFF0000u) | 0x0100u, (LandblockId & 0xFFFF0000u) | 0x0101u], + envCells.VisibilityCells.Select(cell => cell.CellId)); + Assert.Empty(envCells.Shells); + Assert.NotNull(physics.Info); + Assert.Equal(2, physics.EnvCells.Count); + Assert.Single(physics.Environments); + } + + [Fact] + public void ConstructorSnapshotsTheRetailHeightTable() + { + var dat = CreateDat(out _); + var heights = Enumerable.Range(0, 256).Select(value => (float)value).ToArray(); + var factory = new LandblockBuildFactory( + dat, + new object(), + heights, + new PhysicsDataCache()); + heights[42] = -1f; + + var snapshot = Assert.IsType(typeof(LandblockBuildFactory) + .GetField("_heightTable", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(factory)); + Assert.NotSame(heights, snapshot); + Assert.Equal(42f, snapshot[42]); + } + + [Fact] + public void ConstructorRejectsIncompleteRetailHeightTable() + { + var dat = CreateDat(out _); + + Assert.Throws(() => new LandblockBuildFactory( + dat, + new object(), + new float[255], + new PhysicsDataCache())); + } + + [Fact] + public void Build_ExceptionReleasesSharedGateForTheNextTransaction() + { + var dat = CreateDat(out RecordingDatProxy proxy); + proxy.Add(LandblockId, new LandBlock { Id = LandblockId }); + proxy.ExceptionToThrow = new InvalidDataException("fixture read failure"); + var factory = Factory(dat, new object()); + + Assert.Throws(() => + factory.Build(Request(LandblockStreamJobKind.LoadFar))); + proxy.ExceptionToThrow = null; + + Assert.NotNull(factory.Build(Request(LandblockStreamJobKind.LoadFar))); + } + + [Fact] + public void FactorySurface_HasNoRendererWorldStateOrLiveOriginDependency() + { + Type type = typeof(LandblockBuildFactory); + Type[] dependencyTypes = type + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .Select(field => field.FieldType) + .Concat(type.GetConstructors().SelectMany(ctor => + ctor.GetParameters().Select(parameter => parameter.ParameterType))) + .ToArray(); + + Assert.DoesNotContain(dependencyTypes, dependency => + dependency.FullName?.Contains("GameWindow", StringComparison.Ordinal) == true); + Assert.DoesNotContain(dependencyTypes, dependency => + dependency.FullName?.Contains("GpuWorldState", StringComparison.Ordinal) == true); + Assert.DoesNotContain(dependencyTypes, dependency => + dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true); + Assert.DoesNotContain(dependencyTypes, dependency => + dependency.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true); + } + + [Fact] + public void InstalledDatNearBuild_IsDeterministicAndClosesEntityPhysicsPayload() + { + string? datDirectory = ResolveDatDirectory(); + if (datDirectory is null) + throw SkipException.ForSkip("Installed retail DATs are required."); + + using var dat = new BoundedTestDatCollection(datDirectory); + var bounded = (IDatReaderWriter)dat; + Region? region = bounded.Get(0x13000000u); + float[]? heights = region?.LandDefs.LandHeightTable; + Assert.NotNull(heights); + Assert.True(heights.Length >= 256); + var factory = new LandblockBuildFactory( + bounded, + new object(), + heights, + new PhysicsDataCache()); + LandblockBuildRequest request = Request(LandblockStreamJobKind.LoadNear, generation: 4); + + LandblockBuild? first = factory.Build(request); + LandblockBuild? second = factory.Build(request); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.Equal(HoltburgOrigin, first.Origin); + Assert.NotNull(first.EnvCells); + Assert.NotEmpty(first.Landblock.Entities); + AssertEntityBuildsEqual(first.Landblock.Entities, second.Landblock.Entities); + Assert.Equal( + first.EnvCells.VisibilityCells.Select(cell => cell.CellId), + second.EnvCells!.VisibilityCells.Select(cell => cell.CellId)); + Assert.Equal( + first.EnvCells.Shells.Select(shell => shell.GeometryId), + second.EnvCells.Shells.Select(shell => shell.GeometryId)); + + LandBlockInfo? info = bounded.Get( + (LandblockId & 0xFFFF0000u) | 0xFFFEu); + Assert.NotNull(info); + var expectedVisibilityCells = new List(); + uint firstCellId = (LandblockId & 0xFFFF0000u) | 0x0100u; + for (uint offset = 0; offset < info.NumCells; offset++) + { + uint cellId = firstCellId + offset; + EnvCell? cell = bounded.Get(cellId); + if (cell is null || cell.EnvironmentId == 0) + continue; + DatReaderWriter.DBObjs.Environment? environment = + bounded.Get( + 0x0D000000u | cell.EnvironmentId); + if (environment?.Cells.ContainsKey(cell.CellStructure) == true) + expectedVisibilityCells.Add(cellId); + } + Assert.Equal( + expectedVisibilityCells, + first.EnvCells.VisibilityCells.Select(cell => cell.CellId)); + + PhysicsDatBundle physics = Assert.IsType( + first.Landblock.PhysicsDats); + Assert.NotNull(physics.Info); + foreach (WorldEntity entity in first.Landblock.Entities) + { + if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) + Assert.Contains(entity.SourceGfxObjOrSetupId, physics.Setups.Keys); + foreach (MeshRef mesh in entity.MeshRefs) + Assert.Contains(mesh.GfxObjId, physics.GfxObjs.Keys); + } + } + + private static LandblockBuildFactory Factory(IDatReaderWriter dat, object gate) => + new(dat, gate, new float[256], new PhysicsDataCache()); + + private static LandblockBuildRequest Request( + LandblockStreamJobKind kind, + ulong generation = 1) => + new(LandblockId, kind, generation, HoltburgOrigin); + + private static LandblockBuildRequest Request( + uint landblockId, + LandblockStreamJobKind kind, + ulong generation, + LandblockBuildOrigin origin) => + new(landblockId, kind, generation, origin); + + private static void AddNearFixture( + RecordingDatProxy proxy, + uint landblockId, + ushort environmentId, + uint cellCount = 1) + { + proxy.Add(landblockId, new LandBlock { Id = landblockId }); + proxy.Add( + (landblockId & 0xFFFF0000u) | 0xFFFEu, + new LandBlockInfo { NumCells = cellCount }); + + var environment = new DatReaderWriter.DBObjs.Environment + { + Id = 0x0D000000u | environmentId, + }; + for (uint offset = 0; offset < cellCount; offset++) + { + ushort structure = checked((ushort)(offset + 1)); + uint cellId = (landblockId & 0xFFFF0000u) | (0x0100u + offset); + proxy.Add(cellId, new EnvCell + { + Id = cellId, + EnvironmentId = environmentId, + CellStructure = structure, + Position = new Frame + { + Orientation = Quaternion.Identity, + }, + }); + environment.Cells[structure] = EmptyCellStruct(); + } + proxy.Add(environment.Id, environment); + } + + private static CellStruct EmptyCellStruct() => new() + { + VertexArray = new VertexArray + { + Vertices = new Dictionary(), + }, + }; + + private static IDatReaderWriter CreateDat(out RecordingDatProxy proxy) + { + IDatReaderWriter dat = DispatchProxy.Create(); + proxy = (RecordingDatProxy)(object)dat; + return dat; + } + + private static void AssertEntityBuildsEqual( + IReadOnlyList expected, + IReadOnlyList actual) + { + Assert.Equal(expected.Count, actual.Count); + for (int i = 0; i < expected.Count; i++) + { + WorldEntity left = expected[i]; + WorldEntity right = actual[i]; + Assert.Equal(left.Id, right.Id); + Assert.Equal(left.SourceGfxObjOrSetupId, right.SourceGfxObjOrSetupId); + Assert.Equal(left.Position, right.Position); + Assert.Equal(left.Rotation, right.Rotation); + Assert.Equal(left.ParentCellId, right.ParentCellId); + Assert.Equal(left.EffectCellId, right.EffectCellId); + Assert.Equal(left.MeshRefs.Count, right.MeshRefs.Count); + for (int part = 0; part < left.MeshRefs.Count; part++) + Assert.Equal(left.MeshRefs[part], right.MeshRefs[part]); + } + } + + private static string? ResolveDatDirectory() + { + string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured)) + return configured; + const string installed = @"C:\Turbine\Asheron's Call"; + if (Directory.Exists(installed)) + return installed; + string documents = Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + return Directory.Exists(documents) ? documents : null; + } + + private class RecordingDatProxy : DispatchProxy + { + private readonly Dictionary<(Type Type, uint Id), object> _objects = new(); + private readonly object _gate = new(); + private int _activeReads; + + internal List<(Type Type, uint Id)> Reads { get; } = new(); + internal int MaxConcurrentReads { get; private set; } + internal TimeSpan ReadDelay { get; set; } + internal Exception? ExceptionToThrow { get; set; } + internal ManualResetEventSlim ReadObserved { get; } = new(); + + internal void Add(uint id, T value) where T : class => + _objects[(typeof(T), id)] = value; + + internal void Remove(uint id) where T : class => + _objects.Remove((typeof(T), id)); + + internal void ClearReads() + { + lock (_gate) + { + Reads.Clear(); + MaxConcurrentReads = 0; + } + } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod?.Name == "Get" && args is [uint id]) + { + Type type = targetMethod.ReturnType; + int active = Interlocked.Increment(ref _activeReads); + ReadObserved.Set(); + lock (_gate) + { + Reads.Add((type, id)); + MaxConcurrentReads = Math.Max(MaxConcurrentReads, active); + } + try + { + if (ReadDelay > TimeSpan.Zero) + Thread.Sleep(ReadDelay); + if (ExceptionToThrow is { } error) + throw error; + return _objects.TryGetValue((type, id), out object? value) + ? value + : null; + } + finally + { + Interlocked.Decrement(ref _activeReads); + } + } + + if (targetMethod?.ReturnType == typeof(void)) + return null; + if (targetMethod?.ReturnType.IsValueType == true) + return Activator.CreateInstance(targetMethod.ReturnType); + return null; + } + } +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs index 63cc5a67..27518587 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs @@ -232,27 +232,45 @@ public sealed class LandblockBuildOriginTests } [Fact] - public void GameWindowBuildAndPublication_UseCapturedOriginInsteadOfLiveCenter() + public void BuildFactoryAndGameWindowPublication_UseCapturedOriginInsteadOfLiveCenter() { string root = FindRepoRoot(); - string source = File.ReadAllText(Path.Combine( + string gameWindowSource = File.ReadAllText(Path.Combine( root, "src", "AcDream.App", "Rendering", "GameWindow.cs")); - string buildSection = Slice( - source, - "private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(", - "private void AdvanceLandblockPresentationRetirement("); + string buildSource = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Streaming", + "LandblockBuildFactory.cs")); string publicationSection = Slice( - source, + gameWindowSource, "private void ApplyLoadedTerrainLocked(", "private void OnUpdate(double dt)"); - Assert.Contains("request.Origin", buildSection, StringComparison.Ordinal); - Assert.DoesNotContain("_liveCenterX", buildSection, StringComparison.Ordinal); - Assert.DoesNotContain("_liveCenterY", buildSection, StringComparison.Ordinal); + Assert.Contains("request.Origin", buildSource, StringComparison.Ordinal); + Assert.DoesNotContain("_liveCenterX", buildSource, StringComparison.Ordinal); + Assert.DoesNotContain("_liveCenterY", buildSource, StringComparison.Ordinal); + Assert.DoesNotContain( + "BuildLandblockForStreaming", + gameWindowSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "BuildSceneryEntitiesForStreaming", + gameWindowSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "BuildInteriorEntitiesForStreaming", + gameWindowSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "BuildPhysicsDatBundle", + gameWindowSource, + StringComparison.Ordinal); Assert.Contains("build.Origin.CenterX", publicationSection, StringComparison.Ordinal); Assert.Contains("build.Origin.CenterY", publicationSection, StringComparison.Ordinal); Assert.DoesNotContain("_liveCenterX", publicationSection, StringComparison.Ordinal); From 0f9c03f4225b41e99a9047006dfc1a9f2d49e981 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 20:30:54 +0200 Subject: [PATCH 59/98] docs(plan): complete landblock build extraction --- ...1-gamewindow-slice-5-landblock-presentation.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md index 5a55579c..e8f60d14 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md +++ b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md @@ -28,10 +28,19 @@ test, and bisectable commit rather than being hidden in a mechanical move. and publication. Origin-less or mismatched request-aware work fails before it can publish. Three corrected-diff reviews are clean. Release gate: 6,965 passed / 5 skipped. -- [ ] B — capture one immutable world-origin snapshot per load and extract the +- [x] B — capture one immutable world-origin snapshot per load and extract the serialized DAT-backed `LandblockBuildFactory`. - - Origin capture is complete in A2; B now contains only the DAT-backed - factory extraction and its fixture/serialization gates. + - Landed in `5af101b2`: `LandblockBuildFactory` now owns the complete + serialized worker-side DAT transaction, immutable height-table snapshot, + stab/scenery/interior hydration, complete EnvCell build, and physics-DAT + bundle. `GameWindow` only constructs the owner and passes its `Build` + operation to the streamer; it no longer contains any build helper body. + Hermetic tests pin Far/Near/Promote shape, zero-submesh visibility cells, + missing-record behavior, the exact supplied DAT gate, non-interleaved full + Near transactions, exception recovery, and immutable inputs. The installed + Holtburg gate independently derives visibility completeness from + `LandBlockInfo.NumCells`. Three corrected-diff reviews are clean. Release + gate: 6,979 passed / 5 skipped; `GameWindow.cs` is 9,573 lines. - [ ] C — extract terrain, visibility, EnvCell, building, and render-pinning publication. - [ ] D — extract physics/cache/collision publication and cross-landblock From 1029f8372e6fbb6c2e9571400d29ca6602103740 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 20:47:01 +0200 Subject: [PATCH 60/98] refactor(streaming): extract landblock render publisher --- src/AcDream.App/Rendering/GameWindow.cs | 169 +++----- .../Streaming/LandblockRenderPublisher.cs | 290 ++++++++++++++ .../Streaming/LandblockBuildOriginTests.cs | 12 +- .../LandblockRenderPublisherTests.cs | 365 ++++++++++++++++++ 4 files changed, 708 insertions(+), 128 deletions(-) create mode 100644 src/AcDream.App/Streaming/LandblockRenderPublisher.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LandblockRenderPublisherTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 21b41a7a..69d4cf12 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -151,6 +151,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.GpuWorldState _worldState = new(); private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements; + private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher; private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Streaming.StreamingController? _streamingController; private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; @@ -228,14 +229,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock. private System.Collections.Concurrent.ConcurrentDictionary? _surfaceCache; - // Phase A8 (2026-05-26): per-landblock BuildingRegistry keyed by full landblock - // id (e.g. 0xA9B40000). Built from LandBlockInfo.Buildings at ApplyLoadedTerrain - // time; each entry's BuildingRegistry.GetBuildingsContainingCell drives render-frame - // indoor-cell scoping. Entries are removed in the removeTerrain callbacks. - // Only touched on the render thread — no lock required. - private readonly System.Collections.Generic.Dictionary - _buildingRegistries = new(); - // Phase A8 (2026-05-28): WB EnvCellRenderManager port. Cells render // through this dedicated pipeline now, NOT through WbDrawDispatcher. // The dispatcher's IndoorPass still walks cell-static stabs (WorldEntity @@ -2419,6 +2412,18 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer( _gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum); _envCellRenderer.Initialize(_meshShader!); + _landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher( + publishTerrain: (landblockId, meshData, origin) => + _terrain!.AddLandblockWithMesh(landblockId, meshData, origin), + removeTerrain: landblockId => _terrain!.RemoveLandblock(landblockId), + cellVisibility: _cellVisibility, + worldState: _worldState, + commitEnvCells: _envCellRenderer.CommitLandblock, + prepareEnvCells: build => + AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule( + build, + _wbMeshAdapter.MeshManager!), + removeEnvCells: _envCellRenderer.RemoveLandblock); _clipFrame ??= ClipFrame.NoClip(); _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( @@ -2531,7 +2536,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // objects it thinks we still know, so we re-project them ourselves. onLandblockLoaded: loadedLandblockId => _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), - ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin, + ensureEnvCellMeshes: _landblockRenderPublisher!.PrepareAfterRenderPins, retirementCoordinator: _landblockRetirements); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; @@ -3774,12 +3779,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext $"live: AdminEnvirons sound cue = {name} " + $"(0x{environChangeType:X2}) — audio binding pending"); } - private void EnsureEnvCellMeshesAfterPin( - AcDream.App.Rendering.Wb.EnvCellLandblockBuild build) - { - if (_wbMeshAdapter?.MeshManager is { } meshManager) - AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(build, meshManager); - } private void AdvanceLandblockPresentationRetirement( AcDream.App.Streaming.LandblockRetirementTicket ticket) { @@ -3807,7 +3806,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { ticket.RunOnce( AcDream.App.Streaming.LandblockRetirementStage.Terrain, - () => _terrain?.RemoveLandblock(ticket.LandblockId)); + () => _landblockRenderPublisher?.RemoveTerrain(ticket.LandblockId)); } ticket.RunOnce( @@ -3821,15 +3820,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext }); ticket.RunOnce( AcDream.App.Streaming.LandblockRetirementStage.CellVisibility, - () => _cellVisibility.RemoveLandblock( - (ticket.LandblockId >> 16) & 0xFFFFu)); + () => _landblockRenderPublisher?.RemoveCellVisibility(ticket.LandblockId)); ticket.RunOnce( AcDream.App.Streaming.LandblockRetirementStage.BuildingRegistry, - () => _buildingRegistries.Remove( - ticket.LandblockId & 0xFFFF0000u)); + () => _landblockRenderPublisher?.RemoveBuildingRegistry(ticket.LandblockId)); ticket.RunOnce( AcDream.App.Streaming.LandblockRetirementStage.EnvironmentCells, - () => _envCellRenderer?.RemoveLandblock(ticket.LandblockId)); + () => _landblockRenderPublisher?.RemoveEnvironmentCells(ticket.LandblockId)); } /// @@ -3893,76 +3890,35 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext AcDream.Core.Terrain.LandblockMeshData meshData) { var lb = build.Landblock; - // _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker). - // _heightTable still needed for physics TerrainSurface below. - if (_terrain is null || _dats is null || _heightTable is null) return; + if (_landblockRenderPublisher is null || _dats is null || _heightTable is null) + return; - // datLock fix: every dat the apply needs was pre-read by the worker into - // lb.PhysicsDats. Read from the bundle — NO _dats.Get here, so this method - // (and its caller) need no _datLock. + // Every DAT object publication consumes was captured by the worker. + // The update-thread publishers consume only this immutable bundle. var datBundle = lb.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty; - uint lbXu = (lb.LandblockId >> 24) & 0xFFu; - uint lbYu = (lb.LandblockId >> 16) & 0xFFu; - int lbX = (int)lbXu; - int lbY = (int)lbYu; - var origin = new System.Numerics.Vector3( - (lbX - build.Origin.CenterX) * 192f, - (lbY - build.Origin.CenterY) * 192f, - 0f); - - // Phase A.5 T15/T16: route through AddLandblockWithMesh — the named - // two-tier entry point. Delegates to AddLandblock internally; both - // paths share one GPU upload path. - // [FRAME-DIAG]: bracket the terrain glBufferSubData sub-span so we can tell - // the (tiny ~17KB) GPU upload apart from the dat-read/registration tail. - long fdU0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; - _terrain.AddLandblockWithMesh(lb.LandblockId, meshData, origin); + AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderDiagBefore = + _landblockRenderPublisher.Diagnostics; + AcDream.App.Streaming.LandblockRenderPublication renderPublication = + _landblockRenderPublisher.BeginPublication(build, meshData); + var origin = renderPublication.Origin; if (_frameDiag) - _applyUploadAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdU0; + { + AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderDiagAfter = + _landblockRenderPublisher.Diagnostics; + long terrainTicks = + renderDiagAfter.TerrainPublishTicks - renderDiagBefore.TerrainPublishTicks; + _applyUploadAccumTicks += terrainTicks; + _applyCellAccumTicks += + renderDiagAfter.BeginPublishTicks + - renderDiagBefore.BeginPublishTicks + - terrainTicks; + } - // [FRAME-DIAG]: split the post-upload apply CPU into three sub-spans via - // method-scope checkpoints — cell-build → gfxobj-BSP → ShadowObjects+lights. + // [FRAME-DIAG]: the publisher reports terrain and render-prefix time; + // this checkpoint begins the physics/cache portion of the cell span. long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; - // Commit the exact visibility-cell snapshot produced by THIS streaming - // completion. The former global ConcurrentBag let one landblock drain - // another landblock's cells and exposed partial builds to the flood. - var committedCells = new System.Collections.Generic.Dictionary(); - if (build.EnvCells is { } envCellBuild) - { - if (envCellBuild.LandblockId != lb.LandblockId) - throw new InvalidOperationException( - $"EnvCell transaction 0x{envCellBuild.LandblockId:X8} was attached to " + - $"landblock 0x{lb.LandblockId:X8}."); - - _cellVisibility.CommitLandblock(lb.LandblockId, envCellBuild.VisibilityCells); - foreach (var cell in envCellBuild.VisibilityCells) - committedCells[cell.CellId] = cell; - } - - // Compute the per-landblock AABB for frustum culling. XY from the - // landblock's world origin + 192 footprint. Z from the terrain vertex - // range padded +50 above (for trees/buildings) and -10 below (for - // basements). TerrainRenderer already scans vertices internally; we - // replicate here so GpuWorldState has the same bounds for the static - // mesh renderer's culling pass. - { - float zMin = float.MaxValue, zMax = float.MinValue; - foreach (var v in meshData.Vertices) - { - float z = v.Position.Z; - if (z < zMin) zMin = z; - if (z > zMax) zMax = z; - } - if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; } - zMax += 50f; // generous pad for trees and buildings - zMin -= 10f; // below-ground buffer for basements/cellars - var aabbMin = new System.Numerics.Vector3(origin.X, origin.Y, zMin); - var aabbMax = new System.Numerics.Vector3(origin.X + 192f, origin.Y + 192f, zMax); - _worldState.SetLandblockAabb(lb.LandblockId, aabbMin, aabbMax); - } - // Phase B.3: populate the physics engine with terrain + indoor cell // surfaces for this landblock. Runs under _datLock (same lock as the // rest of ApplyLoadedTerrainLocked) so dat reads are safe. @@ -4155,47 +4111,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces, portalPlanes, origin.X, origin.Y); - - // Phase A8 (2026-05-26 / fix 2026-05-28): build per-landblock - // BuildingRegistry from LandBlockInfo.Buildings, stamping - // LoadedCell.BuildingId for each cell in a building's cell set. - // - // FIX 2026-05-28: previously passed `drainedCells` only — that's the - // dict of cells drained THIS frame. Cells loaded on prior frames - // were missed, leaving their BuildingId null and the - // `cameraInsideBuilding` gate FALSE even when the player was inside - // a tagged cottage. (First visual-gate launch showed 8737 [vis] - // lines with inside=True really=True but ZERO [buildings] probe - // emissions — same root cause as the RR7.1 / RR7.2 saga.) The fix: - // merge `drainedCells` with every cell currently registered with - // _cellVisibility for this landblock prefix. BuildingLoader's BFS - // + stamping pass then sees the complete cell set. - // - // Cells without a building stay at BuildingId == null (outdoor - // surface cells; dungeon cells not enumerated in LandBlockInfo.Buildings). - if (lbInfo is not null) - { - // The streaming result now carries the complete landblock cell - // set; no cross-frame merge or global-bag recovery is required. - var lbStampCells = committedCells; - // FIX 2026-05-28: normalize storage key to the cell-prefix - // convention (`landblockId & 0xFFFF0000u`). The lb.LandblockId - // field encodes 0xXXYY_FFFF (low 16 bits = 0xFFFF for the - // landblock's own LandBlockInfo dat id), but the runtime - // lookup at line ~7110 derives the key from a cell id via - // `cellId & 0xFFFF0000u` which yields 0xXXYY_0000. Storage - // and lookup must agree. Mask both sides to the upper-16 - // form so the registry resolves correctly. This is the same - // bug the RR7.2 commit (`efe3520`, reverted by `9aaae02`) - // tried to fix; landing it here in the data-flow layer. - uint regKey = lb.LandblockId & 0xFFFF0000u; - _buildingRegistries[regKey] = - AcDream.App.Rendering.Wb.BuildingLoader.Build( - lbInfo, lb.LandblockId, lbStampCells); - } - - if (build.EnvCells is { } renderBuild) - _envCellRenderer?.CommitLandblock(renderBuild); + _landblockRenderPublisher.CompletePublication(renderPublication); } // N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via @@ -5623,7 +5539,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // routes interior-root look-ins to its landscape-stage sub-pass // (DrawBuildingLookIns); the root's own building self-excludes // via the seed eye-side test. - foreach (var registry in _buildingRegistries.Values) + foreach (var registry in _landblockRenderPublisher?.BuildingRegistries + ?? Array.Empty()) { foreach (var b in registry.All()) { diff --git a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs new file mode 100644 index 00000000..eb41ae7d --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs @@ -0,0 +1,290 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Terrain; + +namespace AcDream.App.Streaming; + +/// +/// Immutable receipt for the render-owned prefix of one accepted landblock +/// publication. Physics and static presentation consume the same captured +/// origin between and +/// . +/// +public sealed class LandblockRenderPublication +{ + private readonly IReadOnlyDictionary _visibilityCells; + + internal LandblockRenderPublication( + object owner, + LandblockBuild build, + Vector3 origin, + Dictionary visibilityCells) + { + Owner = owner; + Build = build; + Origin = origin; + _visibilityCells = new ReadOnlyDictionary(visibilityCells); + } + + internal object Owner { get; } + internal LandblockBuild Build { get; } + internal bool CompletionCommitted { get; set; } + + public uint LandblockId => Build.Landblock.LandblockId; + public Vector3 Origin { get; } + public IReadOnlyDictionary VisibilityCells => _visibilityCells; +} + +/// +/// Typed cumulative diagnostics owned by the render publication boundary. +/// Times use ticks so callers can aggregate without +/// losing sub-millisecond precision. +/// +public readonly record struct LandblockRenderPublisherDiagnostics( + long BeginCount, + long CompleteCount, + long TerrainPublishTicks, + long BeginPublishTicks, + long CompletePublishTicks, + long EnvCellPreparationCount, + long TerrainRemovalCount, + long CellVisibilityRemovalCount, + long BuildingRegistryRemovalCount, + long EnvCellRemovalCount); + +/// +/// Update-thread owner of landblock render publication. It publishes terrain, +/// the complete visibility-cell transaction, frustum bounds, building +/// registries, and EnvCell renderer state without reading DATs or deciding +/// residency. Render-ID acquisition remains in ; +/// schema-aware EnvCell preparation is replayed only after that acquisition. +/// +/// +/// The ordering preserves retail LScape::grab_visible_cells +/// (0x00504EC0), CLandBlock::init_buildings (0x0052FD80), +/// CLandBlock::grab_visible_cells (0x0052F460), and the extracted +/// WorldBuilder one-job/one-EnvCell-transaction seam documented in +/// docs/architecture/worldbuilder-inventory.md. +/// +public sealed class LandblockRenderPublisher +{ + private readonly object _receiptOwner = new(); + private readonly Action _publishTerrain; + private readonly Action _removeTerrain; + private readonly CellVisibility _cellVisibility; + private readonly GpuWorldState _worldState; + private readonly Action? _commitEnvCells; + private readonly Action? _prepareEnvCells; + private readonly Action? _removeEnvCells; + private readonly Dictionary _buildingRegistries = new(); + + private long _beginCount; + private long _completeCount; + private long _terrainPublishTicks; + private long _beginPublishTicks; + private long _completePublishTicks; + private long _envCellPreparationCount; + private long _terrainRemovalCount; + private long _cellVisibilityRemovalCount; + private long _buildingRegistryRemovalCount; + private long _envCellRemovalCount; + + public LandblockRenderPublisher( + Action publishTerrain, + Action removeTerrain, + CellVisibility cellVisibility, + GpuWorldState worldState, + Action? commitEnvCells = null, + Action? prepareEnvCells = null, + Action? removeEnvCells = null) + { + ArgumentNullException.ThrowIfNull(publishTerrain); + ArgumentNullException.ThrowIfNull(removeTerrain); + ArgumentNullException.ThrowIfNull(cellVisibility); + ArgumentNullException.ThrowIfNull(worldState); + + _publishTerrain = publishTerrain; + _removeTerrain = removeTerrain; + _cellVisibility = cellVisibility; + _worldState = worldState; + _commitEnvCells = commitEnvCells; + _prepareEnvCells = prepareEnvCells; + _removeEnvCells = removeEnvCells; + } + + public IReadOnlyCollection BuildingRegistries => + _buildingRegistries.Values; + + public LandblockRenderPublisherDiagnostics Diagnostics => new( + _beginCount, + _completeCount, + _terrainPublishTicks, + _beginPublishTicks, + _completePublishTicks, + _envCellPreparationCount, + _terrainRemovalCount, + _cellVisibilityRemovalCount, + _buildingRegistryRemovalCount, + _envCellRemovalCount); + + /// + /// Publishes the prefix that retail establishes before physics/static + /// activation: terrain, complete visibility cells, and spatial bounds. + /// + public LandblockRenderPublication BeginPublication( + LandblockBuild build, + LandblockMeshData meshData) + { + ArgumentNullException.ThrowIfNull(build); + ArgumentNullException.ThrowIfNull(meshData); + if (!build.Origin.IsSpecified) + throw new ArgumentException( + "Render publication requires the build's captured origin.", + nameof(build)); + + long beginStarted = Stopwatch.GetTimestamp(); + uint landblockId = build.Landblock.LandblockId; + if (build.EnvCells is { } candidateEnvCells && + candidateEnvCells.LandblockId != landblockId) + { + throw new InvalidOperationException( + $"EnvCell transaction 0x{candidateEnvCells.LandblockId:X8} was attached to " + + $"landblock 0x{landblockId:X8}."); + } + + Vector3 origin = ComputeOrigin(landblockId, build.Origin); + + long terrainStarted = Stopwatch.GetTimestamp(); + _publishTerrain(landblockId, meshData, origin); + _terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted; + + var committedCells = new Dictionary(); + if (build.EnvCells is { } envCells) + { + _cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells); + foreach (LoadedCell cell in envCells.VisibilityCells) + committedCells[cell.CellId] = cell; + } + + (Vector3 aabbMin, Vector3 aabbMax) = ComputeAabb(meshData, origin); + _worldState.SetLandblockAabb(landblockId, aabbMin, aabbMax); + + _beginCount++; + _beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted; + return new LandblockRenderPublication( + _receiptOwner, + build, + origin, + committedCells); + } + + /// + /// Completes the render-owned suffix after physics has consumed the same + /// receipt: building membership is stamped before the EnvCell renderer + /// receives the complete immutable transaction. + /// + public void CompletePublication(LandblockRenderPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (!ReferenceEquals(publication.Owner, _receiptOwner)) + throw new ArgumentException( + "The render publication receipt belongs to another publisher.", + nameof(publication)); + if (publication.CompletionCommitted) + return; + + long started = Stopwatch.GetTimestamp(); + LandblockBuild build = publication.Build; + uint landblockId = publication.LandblockId; + if (build.Landblock.PhysicsDats?.Info is { } info) + { + uint registryKey = landblockId & 0xFFFF0000u; + _buildingRegistries[registryKey] = BuildingLoader.Build( + info, + landblockId, + publication.VisibilityCells); + } + + if (build.EnvCells is { } envCells) + _commitEnvCells?.Invoke(envCells); + + publication.CompletionCommitted = true; + _completeCount++; + _completePublishTicks += Stopwatch.GetTimestamp() - started; + } + + /// + /// Replays EnvCell schema preparation after + /// has acquired every synthetic render identifier. + /// + public void PrepareAfterRenderPins(EnvCellLandblockBuild build) + { + ArgumentNullException.ThrowIfNull(build); + _prepareEnvCells?.Invoke(build); + _envCellPreparationCount++; + } + + public void RemoveTerrain(uint landblockId) + { + _removeTerrain(landblockId); + _terrainRemovalCount++; + } + + public void RemoveCellVisibility(uint landblockId) + { + _cellVisibility.RemoveLandblock((landblockId >> 16) & 0xFFFFu); + _cellVisibilityRemovalCount++; + } + + public void RemoveBuildingRegistry(uint landblockId) + { + _buildingRegistries.Remove(landblockId & 0xFFFF0000u); + _buildingRegistryRemovalCount++; + } + + public void RemoveEnvironmentCells(uint landblockId) + { + _removeEnvCells?.Invoke(landblockId); + _envCellRemovalCount++; + } + + private static Vector3 ComputeOrigin( + uint landblockId, + LandblockBuildOrigin capturedOrigin) + { + int landblockX = (int)((landblockId >> 24) & 0xFFu); + int landblockY = (int)((landblockId >> 16) & 0xFFu); + return new Vector3( + (landblockX - capturedOrigin.CenterX) * 192f, + (landblockY - capturedOrigin.CenterY) * 192f, + 0f); + } + + private static (Vector3 Min, Vector3 Max) ComputeAabb( + LandblockMeshData meshData, + Vector3 origin) + { + float zMin = float.MaxValue; + float zMax = float.MinValue; + foreach (TerrainVertex vertex in meshData.Vertices) + { + float z = vertex.Position.Z; + if (z < zMin) zMin = z; + if (z > zMax) zMax = z; + } + if (zMin == float.MaxValue) + { + zMin = 0f; + zMax = 0f; + } + + zMax += 50f; + zMin -= 10f; + return ( + new Vector3(origin.X, origin.Y, zMin), + new Vector3(origin.X + 192f, origin.Y + 192f, zMax)); + } +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs index 27518587..9ec20493 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs @@ -247,6 +247,12 @@ public sealed class LandblockBuildOriginTests "AcDream.App", "Streaming", "LandblockBuildFactory.cs")); + string renderPublisherSource = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Streaming", + "LandblockRenderPublisher.cs")); string publicationSection = Slice( gameWindowSource, "private void ApplyLoadedTerrainLocked(", @@ -271,8 +277,10 @@ public sealed class LandblockBuildOriginTests "BuildPhysicsDatBundle", gameWindowSource, StringComparison.Ordinal); - Assert.Contains("build.Origin.CenterX", publicationSection, StringComparison.Ordinal); - Assert.Contains("build.Origin.CenterY", publicationSection, StringComparison.Ordinal); + Assert.Contains("ComputeOrigin(landblockId, build.Origin)", renderPublisherSource, StringComparison.Ordinal); + Assert.DoesNotContain("_liveCenterX", renderPublisherSource, StringComparison.Ordinal); + Assert.DoesNotContain("_liveCenterY", renderPublisherSource, StringComparison.Ordinal); + Assert.Contains("renderPublication.Origin", publicationSection, StringComparison.Ordinal); Assert.DoesNotContain("_liveCenterX", publicationSection, StringComparison.Ordinal); Assert.DoesNotContain("_liveCenterY", publicationSection, StringComparison.Ordinal); } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockRenderPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockRenderPublisherTests.cs new file mode 100644 index 00000000..5bf84ba2 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockRenderPublisherTests.cs @@ -0,0 +1,365 @@ +using System.Numerics; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockRenderPublisherTests +{ + private const uint LandblockId = 0xA9B4FFFFu; + private static readonly LandblockBuildOrigin CapturedOrigin = new(0xA8, 0xB3); + + [Fact] + public void BeginPublication_PublishesTerrainVisibilityAndAabbFromCapturedOrigin() + { + var calls = new List(); + var visibility = new CellVisibility(); + var state = new GpuWorldState(); + LoadedCell cell = VisibilityCell(0xA9B40100u); + LandblockBuild build = Build( + new EnvCellLandblockBuild( + LandblockId, + [cell], + Array.Empty())); + LandblockMeshData mesh = MeshAtHeights(7f, 19f); + var publisher = Publisher( + calls, + visibility, + state, + out _, + out _, + out _); + + LandblockRenderPublication receipt = publisher.BeginPublication(build, mesh); + + Assert.Equal(new Vector3(192f, 192f, 0f), receipt.Origin); + Assert.Equal(["terrain:A9B4FFFF:192:192"], calls); + Assert.True(visibility.TryGetCell(cell.CellId, out LoadedCell? committed)); + Assert.Same(cell, committed); + Assert.Single(receipt.VisibilityCells); + + state.AddLandblock(build.Landblock); + var bounds = Assert.Single(state.LandblockBounds); + Assert.Equal(new Vector3(192f, 192f, -3f), bounds.AabbMin); + Assert.Equal(new Vector3(384f, 384f, 69f), bounds.AabbMax); + } + + [Fact] + public void BeginPublication_VisibilityOnlyCellDoesNotRequireDrawableShell() + { + var visibility = new CellVisibility(); + LoadedCell cell = VisibilityCell(0xA9B40100u); + LandblockBuild build = Build(new EnvCellLandblockBuild( + LandblockId, + [cell], + Array.Empty())); + var publisher = Publisher( + new List(), + visibility, + new GpuWorldState(), + out _, + out _, + out _); + + LandblockRenderPublication receipt = publisher.BeginPublication( + build, + EmptyMesh()); + publisher.CompletePublication(receipt); + + Assert.True(visibility.TryGetCell(cell.CellId, out _)); + Assert.Single(publisher.BuildingRegistries); + Assert.Empty(Assert.Single(publisher.BuildingRegistries).All()); + } + + [Fact] + public void BeginPublication_MismatchedEnvCellTransactionHasNoSideEffects() + { + var calls = new List(); + var visibility = new CellVisibility(); + var state = new GpuWorldState(); + LoadedCell cell = VisibilityCell(0xAAB40100u); + LandblockBuild build = Build(new EnvCellLandblockBuild( + 0xAAB4FFFFu, + [cell], + Array.Empty())); + var publisher = Publisher( + calls, + visibility, + state, + out _, + out _, + out _); + + Assert.Throws(() => + publisher.BeginPublication(build, EmptyMesh())); + + Assert.Empty(calls); + Assert.False(visibility.TryGetCell(cell.CellId, out _)); + Assert.Null(state.DetachLandblock(LandblockId)); + Assert.Equal(0, publisher.Diagnostics.BeginCount); + } + + [Fact] + public void CompletePublication_CommitsBuildingThenEnvCellsAndIsIdempotent() + { + var calls = new List(); + LandblockBuild build = Build(EmptyEnvCells()); + int envCommits = 0; + LandblockRenderPublisher? publisher = null; + publisher = new LandblockRenderPublisher( + publishTerrain: (_, _, _) => calls.Add("terrain"), + removeTerrain: _ => { }, + cellVisibility: new CellVisibility(), + worldState: new GpuWorldState(), + commitEnvCells: _ => + { + Assert.Single(publisher!.BuildingRegistries); + calls.Add("env-cells"); + envCommits++; + }); + LandblockRenderPublication receipt = publisher.BeginPublication(build, EmptyMesh()); + + publisher.CompletePublication(receipt); + publisher.CompletePublication(receipt); + + Assert.Equal(["terrain", "env-cells"], calls); + Assert.Equal(1, envCommits); + Assert.Single(publisher.BuildingRegistries); + Assert.Equal(1, publisher.Diagnostics.CompleteCount); + } + + [Fact] + public void CompletePublication_FailedEnvCommitRetriesWithoutDuplicatingRegistry() + { + int attempts = 0; + LandblockBuild build = Build(EmptyEnvCells()); + var publisher = new LandblockRenderPublisher( + publishTerrain: (_, _, _) => { }, + removeTerrain: _ => { }, + cellVisibility: new CellVisibility(), + worldState: new GpuWorldState(), + commitEnvCells: _ => + { + attempts++; + if (attempts == 1) + throw new InvalidOperationException("injected EnvCell failure"); + }); + LandblockRenderPublication receipt = publisher.BeginPublication(build, EmptyMesh()); + + Assert.Throws(() => + publisher.CompletePublication(receipt)); + publisher.CompletePublication(receipt); + + Assert.Equal(2, attempts); + Assert.Single(publisher.BuildingRegistries); + Assert.Equal(1, publisher.Diagnostics.CompleteCount); + } + + [Fact] + public void CompletePublication_ForeignReceiptIsRejectedWithoutConsumingIt() + { + var first = Publisher( + new List(), + new CellVisibility(), + new GpuWorldState(), + out int[] firstCommits, + out _, + out _); + var second = Publisher( + new List(), + new CellVisibility(), + new GpuWorldState(), + out int[] secondCommits, + out _, + out _); + LandblockRenderPublication receipt = first.BeginPublication( + Build(EmptyEnvCells()), + EmptyMesh()); + + Assert.Throws(() => + second.CompletePublication(receipt)); + + Assert.Empty(second.BuildingRegistries); + Assert.Equal(0, secondCommits[0]); + Assert.Equal(0, second.Diagnostics.CompleteCount); + + first.CompletePublication(receipt); + Assert.Single(first.BuildingRegistries); + Assert.Equal(1, firstCommits[0]); + Assert.Equal(1, first.Diagnostics.CompleteCount); + } + + [Fact] + public void PrepareAndRetirementOperationsStayBalancedAtTheirOwnerBoundary() + { + var calls = new List(); + var visibility = new CellVisibility(); + var publisher = Publisher( + calls, + visibility, + new GpuWorldState(), + out _, + out int[] preparations, + out int[] envRemovals); + LoadedCell cell = VisibilityCell(0xA9B40100u); + EnvCellLandblockBuild envCells = new( + LandblockId, + [cell], + Array.Empty()); + LandblockRenderPublication receipt = publisher.BeginPublication( + Build(envCells), + EmptyMesh()); + publisher.CompletePublication(receipt); + + publisher.PrepareAfterRenderPins(envCells); + publisher.RemoveTerrain(LandblockId); + publisher.RemoveCellVisibility(LandblockId); + publisher.RemoveBuildingRegistry(LandblockId); + publisher.RemoveEnvironmentCells(LandblockId); + + Assert.Equal(1, preparations[0]); + Assert.Equal(1, envRemovals[0]); + Assert.Equal( + [ + "terrain:A9B4FFFF:192:192", + "prepare", + "remove-terrain:A9B4FFFF", + "remove-env:A9B4FFFF", + ], + calls); + Assert.False(visibility.TryGetCell(cell.CellId, out _)); + Assert.Empty(publisher.BuildingRegistries); + LandblockRenderPublisherDiagnostics diagnostics = publisher.Diagnostics; + Assert.Equal(1, diagnostics.EnvCellPreparationCount); + Assert.Equal(1, diagnostics.TerrainRemovalCount); + Assert.Equal(1, diagnostics.CellVisibilityRemovalCount); + Assert.Equal(1, diagnostics.BuildingRegistryRemovalCount); + Assert.Equal(1, diagnostics.EnvCellRemovalCount); + } + + [Fact] + public void PublisherSurface_HasNoDatResidencyPolicyOrLiveOriginDependency() + { + Type type = typeof(LandblockRenderPublisher); + Type[] dependencies = type + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .Select(field => field.FieldType) + .Concat(type.GetConstructors().SelectMany(ctor => + ctor.GetParameters().Select(parameter => parameter.ParameterType))) + .ToArray(); + + Assert.DoesNotContain(dependencies, dependency => + dependency.FullName?.Contains("DatReader", StringComparison.Ordinal) == true); + Assert.DoesNotContain(dependencies, dependency => + dependency.FullName?.Contains("StreamingRegion", StringComparison.Ordinal) == true); + Assert.DoesNotContain(dependencies, dependency => + dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true); + } + + [Fact] + public void GameWindow_HasNoDirectRenderPublicationBodies() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.DoesNotContain("_terrain.AddLandblockWithMesh", source, StringComparison.Ordinal); + Assert.DoesNotContain("_cellVisibility.CommitLandblock", source, StringComparison.Ordinal); + Assert.DoesNotContain("_buildingRegistries", source, StringComparison.Ordinal); + Assert.DoesNotContain("_envCellRenderer?.CommitLandblock", source, StringComparison.Ordinal); + Assert.DoesNotContain("EnsureEnvCellMeshesAfterPin", source, StringComparison.Ordinal); + } + + private static LandblockRenderPublisher Publisher( + List calls, + CellVisibility visibility, + GpuWorldState state, + out int[] envCommits, + out int[] preparations, + out int[] envRemovals) + { + envCommits = [0]; + preparations = [0]; + envRemovals = [0]; + int[] commitCounter = envCommits; + int[] preparationCounter = preparations; + int[] removalCounter = envRemovals; + return new LandblockRenderPublisher( + publishTerrain: (id, _, origin) => calls.Add( + $"terrain:{id:X8}:{origin.X:0}:{origin.Y:0}"), + removeTerrain: id => calls.Add($"remove-terrain:{id:X8}"), + cellVisibility: visibility, + worldState: state, + commitEnvCells: _ => commitCounter[0]++, + prepareEnvCells: _ => + { + preparationCounter[0]++; + calls.Add("prepare"); + }, + removeEnvCells: id => + { + removalCounter[0]++; + calls.Add($"remove-env:{id:X8}"); + }); + } + + private static LandblockBuild Build(EnvCellLandblockBuild envCells) + { + var physics = new PhysicsDatBundle( + new LandBlockInfo(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary()); + return new LandblockBuild( + new LoadedLandblock( + LandblockId, + new LandBlock(), + Array.Empty(), + physics), + envCells, + CapturedOrigin); + } + + private static EnvCellLandblockBuild EmptyEnvCells() => new( + LandblockId, + Array.Empty(), + Array.Empty()); + + private static LoadedCell VisibilityCell(uint cellId) => new() + { + CellId = cellId, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + + private static LandblockMeshData MeshAtHeights(float first, float second) => new( + [ + new TerrainVertex(new Vector3(0f, 0f, first), Vector3.UnitZ, 0, 0, 0, 0), + new TerrainVertex(new Vector3(1f, 1f, second), Vector3.UnitZ, 0, 0, 0, 0), + ], + Array.Empty()); + + private static LandblockMeshData EmptyMesh() => + new(Array.Empty(), Array.Empty()); + + private static string FindRepoRoot() + { + string? directory = AppContext.BaseDirectory; + while (directory is not null) + { + if (File.Exists(Path.Combine(directory, "AcDream.slnx"))) + return directory; + directory = Directory.GetParent(directory)?.FullName; + } + throw new DirectoryNotFoundException("Could not locate repository root."); + } +} From acb6b34d0199555a1a7f060a29c0480c71b3ac19 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 20:48:26 +0200 Subject: [PATCH 61/98] docs(plan): complete landblock render publication --- ...7-21-gamewindow-slice-5-landblock-presentation.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md index e8f60d14..f434abb6 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md +++ b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md @@ -41,8 +41,18 @@ test, and bisectable commit rather than being hidden in a mechanical move. Holtburg gate independently derives visibility completeness from `LandBlockInfo.NumCells`. Three corrected-diff reviews are clean. Release gate: 6,979 passed / 5 skipped; `GameWindow.cs` is 9,573 lines. -- [ ] C — extract terrain, visibility, EnvCell, building, and render-pinning +- [x] C — extract terrain, visibility, EnvCell, building, and render-pinning publication. + - Landed in `1029f837`: `LandblockRenderPublisher` owns captured-origin + terrain publication, complete visibility-cell transactions, AABBs, + building registries, EnvCell commit/preparation, and matching retirement + operations. Invalid EnvCell identities fail before any presentation + mutation; visibility-only cells and zero-submesh transactions remain + first-class. Tests pin building-before-EnvCell order, duplicate and failed + completion behavior, foreign receipts, post-pin preparation, real + publish-to-retire balance, and hidden AABB ownership. Three corrected-diff + reviews are clean. Release gate: 6,988 passed / 5 skipped; + `GameWindow.cs` is 9,490 lines. - [ ] D — extract physics/cache/collision publication and cross-landblock reflood ownership. - [ ] E — extract static lighting, translucency, default-script, and plugin From 3613d393e65542e6460643ae4a9953ed3dda8d93 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 21:13:22 +0200 Subject: [PATCH 62/98] refactor(streaming): extract landblock physics publisher Move streamed terrain/cell/building and static collision publication behind a focused update-thread owner. Preserve retail publication order while making replacement and retirement exact by logical landblock ownership, including current-cell rebasing and adjacent-seam isolation. Co-authored-by: Codex --- src/AcDream.App/Rendering/GameWindow.cs | 645 +++------------- .../Streaming/LandblockPhysicsPublisher.cs | 654 ++++++++++++++++ src/AcDream.Core/Physics/PhysicsEngine.cs | 1 + src/AcDream.Core/World/Cells/CellGraph.cs | 11 + .../LandblockPhysicsPublisherTests.cs | 711 ++++++++++++++++++ .../Physics/PhysicsEngineResidencyTests.cs | 32 + .../World/Cells/CellGraphTests.cs | 30 + 7 files changed, 1538 insertions(+), 546 deletions(-) create mode 100644 src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 69d4cf12..66e9e207 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -152,6 +152,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.Streaming.GpuWorldState _worldState = new(); private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements; private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher; + private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher; private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Streaming.StreamingController? _streamingController; private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; @@ -2424,6 +2425,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext build, _wbMeshAdapter.MeshManager!), removeEnvCells: _envCellRenderer.RemoveLandblock); + _landblockPhysicsPublisher = + new AcDream.App.Streaming.LandblockPhysicsPublisher( + _physicsEngine, + _heightTable!); _clipFrame ??= ClipFrame.NoClip(); _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( @@ -3349,7 +3354,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // offset is never re-based — so the two overlap, and the Z-agnostic outdoor // cell-snap resolves the player into the stale center. On a replacement this // can be the abandoned first destination, not the unplaced player's source. - _physicsEngine.RemoveLandblock(streamingOriginLandblockId); + _landblockPhysicsPublisher!.RemoveLandblock(streamingOriginLandblockId); _liveWorldOrigin.Recenter(lbX, lbY); newWorldPos = new System.Numerics.Vector3( @@ -3814,9 +3819,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext () => { if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full) - _physicsEngine.RemoveLandblock(ticket.LandblockId); + _landblockPhysicsPublisher!.RemoveLandblock(ticket.LandblockId); else - _physicsEngine.DemoteLandblockToTerrain(ticket.LandblockId); + _landblockPhysicsPublisher!.DemoteToTerrain(ticket.LandblockId); }); ticket.RunOnce( AcDream.App.Streaming.LandblockRetirementStage.CellVisibility, @@ -3885,577 +3890,125 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext } } + private void PublishLandblockStaticLightingBeforeCollision( + AcDream.Core.World.WorldEntity entity, + AcDream.Core.World.PhysicsDatBundle datBundle) + { + if (_lightingSink is null || _dats is null) + return; + + uint sourceId = entity.SourceGfxObjOrSetupId; + if ((sourceId & 0xFF000000u) != 0x02000000u) + return; + if (!datBundle.Setups.TryGetValue(sourceId, out var setup) + || setup.Lights.Count == 0) + { + return; + } + + // A landblock can reapply without an unload (#168 ForceReloadWindow + // and Far-to-Near promotion). Static IDs are deterministic, so replace + // the prior owner state before registering the Setup lights. + uint effectOwnerId = entity.Id; + _lightingSink.UnregisterOwner(effectOwnerId); + _translucencyFades.ClearEntity(effectOwnerId); + var lights = AcDream.Core.Lighting.LightInfoLoader.Load( + setup, + ownerId: effectOwnerId, + entityPosition: entity.Position, + entityRotation: entity.Rotation, + cellId: entity.ParentCellId ?? 0u); + foreach (var light in lights) + _lightingSink.RegisterOwnedLight(light); + } + private void ApplyLoadedTerrainLocked( AcDream.App.Streaming.LandblockBuild build, AcDream.Core.Terrain.LandblockMeshData meshData) { - var lb = build.Landblock; - if (_landblockRenderPublisher is null || _dats is null || _heightTable is null) + AcDream.Core.World.LoadedLandblock landblock = build.Landblock; + if (_landblockRenderPublisher is null || _landblockPhysicsPublisher is null) return; - // Every DAT object publication consumes was captured by the worker. - // The update-thread publishers consume only this immutable bundle. - var datBundle = lb.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty; - - AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderDiagBefore = + AcDream.Core.World.PhysicsDatBundle datBundle = + landblock.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty; + AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderBefore = _landblockRenderPublisher.Diagnostics; AcDream.App.Streaming.LandblockRenderPublication renderPublication = _landblockRenderPublisher.BeginPublication(build, meshData); - var origin = renderPublication.Origin; + System.Numerics.Vector3 origin = renderPublication.Origin; + + AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderPrefix = + _landblockRenderPublisher.Diagnostics; if (_frameDiag) { - AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderDiagAfter = - _landblockRenderPublisher.Diagnostics; long terrainTicks = - renderDiagAfter.TerrainPublishTicks - renderDiagBefore.TerrainPublishTicks; + renderPrefix.TerrainPublishTicks - renderBefore.TerrainPublishTicks; _applyUploadAccumTicks += terrainTicks; _applyCellAccumTicks += - renderDiagAfter.BeginPublishTicks - - renderDiagBefore.BeginPublishTicks + renderPrefix.BeginPublishTicks + - renderBefore.BeginPublishTicks - terrainTicks; } - // [FRAME-DIAG]: the publisher reports terrain and render-prefix time; - // this checkpoint begins the physics/cache portion of the cell span. - long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; + AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsBefore = + _landblockPhysicsPublisher.Diagnostics; + AcDream.App.Streaming.LandblockPhysicsPublication physicsPublication = + _landblockPhysicsPublisher.BeginPublication(renderPublication); - // Phase B.3: populate the physics engine with terrain + indoor cell - // surfaces for this landblock. Runs under _datLock (same lock as the - // rest of ApplyLoadedTerrainLocked) so dat reads are safe. + // Retail initializes buildings/visible cells before ordinary static + // collision and scripts. The two receipts retain one captured origin + // while each focused owner commits its exact stage. + _landblockRenderPublisher.CompletePublication(renderPublication); + _landblockPhysicsPublisher.CompletePublication( + physicsPublication, + entity => PublishLandblockStaticLightingBeforeCollision( + entity, + datBundle)); + + if (_frameDiag) { - uint lbPhysX = (lb.LandblockId >> 24) & 0xFFu; - uint lbPhysY = (lb.LandblockId >> 16) & 0xFFu; - // Extract per-vertex terrain-type bytes so TerrainSurface can - // classify water cells for ValidateWalkable's water-depth - // adjustment. Each TerrainInfo is a ushort with Type in bits - // 2-6; taking the low byte preserves those bits (+ Road in 0-1, - // which the classifier masks off). - var terrainBytes = new byte[81]; - for (int i = 0; i < 81; i++) - terrainBytes[i] = (byte)(ushort)lb.Heightmap.Terrain[i]; - - var terrainSurface = new AcDream.Core.Physics.TerrainSurface( - lb.Heightmap.Height, _heightTable, lbPhysX, lbPhysY, terrainBytes); - - var cellSurfaces = new List(); - var portalPlanes = new List(); - var lbInfo = datBundle.Info; - if (lbInfo is not null && lbInfo.NumCells > 0) - { - uint firstCellId = (lb.LandblockId & 0xFFFF0000u) | 0x0100u; - for (uint offset = 0; offset < lbInfo.NumCells; offset++) - { - uint envCellId = firstCellId + offset; - if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell)) continue; - if (envCell.EnvironmentId == 0) continue; - - if (!datBundle.Environments.TryGetValue( - 0x0D000000u | envCell.EnvironmentId, out var environment)) continue; - if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) continue; - - // Transform CellStruct vertices from cell-local to world space. - var rot = envCell.Position.Orientation; - var cellOriginWorld = envCell.Position.Origin + origin; - var physicsCellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(rot) * - System.Numerics.Matrix4x4.CreateTranslation(cellOriginWorld); - - // Commit live physics-cache state on the render thread with - // the same completed landblock transaction. The worker only - // builds private output; it no longer mutates cell registries. - _physicsDataCache.CacheCellStruct( - envCellId, - envCell, - cellStruct, - physicsCellTransform); - - var worldVerts = new Dictionary( - cellStruct.VertexArray.Vertices.Count); - foreach (var (vid, vtx) in cellStruct.VertexArray.Vertices) - { - var localPos = vtx.Origin; - var worldPos = System.Numerics.Vector3.Transform(localPos, rot) + cellOriginWorld; - worldVerts[(ushort)vid] = worldPos; - } - - // Extract polygon vertex-id lists from PhysicsPolygons. - // PhysicsPolygons is Dictionary; iterate Values. - var polyVids = new List>(cellStruct.PhysicsPolygons.Count); - foreach (var poly in cellStruct.PhysicsPolygons.Values) - { - var vids = new List(poly.VertexIds.Count); - foreach (var vid in poly.VertexIds) - vids.Add(vid); - polyVids.Add(vids); - } - - cellSurfaces.Add(new AcDream.Core.Physics.CellSurface(envCellId, worldVerts, polyVids)); - - // Extract portal planes from this EnvCell's CellPortals. - // CellPortal.PolygonId indexes cellStruct.Polygons (rendering polygons), - // NOT PhysicsPolygons — confirmed by ACViewer EnvCell.find_transit_cells. - foreach (var portal in envCell.CellPortals) - { - if (!cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)) - continue; - if (poly.VertexIds.Count < 3) - continue; - - // Collect ALL polygon vertices for accurate centroid + radius. - var portalVerts = new System.Numerics.Vector3[poly.VertexIds.Count]; - bool allFound = true; - for (int pv = 0; pv < poly.VertexIds.Count; pv++) - { - if (!worldVerts.TryGetValue((ushort)poly.VertexIds[pv], out portalVerts[pv])) - { allFound = false; break; } - } - if (!allFound) continue; - - portalPlanes.Add(AcDream.Core.Physics.PortalPlane.FromVertices( - portalVerts.AsSpan(), - portal.OtherCellId, // target cell (0xFFFF = outdoor) - envCellId & 0xFFFFu, // owner cell (low 16 bits) - (ushort)portal.Flags)); - } - } - } - - // Phase 2: cache building portal lists for CellTransit.CheckBuildingTransit. - // Iterates LandBlockInfo.Buildings — each BuildingInfo has a Frame (world- - // relative origin + orientation) and a Portals list. The landcell id is - // derived from the building's frame origin using retail's row-major grid - // formula (gridX * 8 + gridY + 1) within the 192m × 192m landblock. - if (lbInfo is not null && lbInfo.Buildings.Count > 0) - { - // #146 (2026-06-24): clear this landblock's prior building cache so - // the loop re-bases each building's STREAMING-RELATIVE WorldTransform - // with this completion's captured origin (recomputed above per apply). - // CacheBuilding's per-cell first-wins then applies fresh within this - // pass — mirrors terrain's per-apply WorldOffset re-base (AddLandblock). - // Without it, CacheBuilding's idempotent guard locks the transform at - // the frame it was first cached with, so a teleport recenter strands - // the shell BSP at a stale world offset and house walls stop colliding - // (login at Arwic → portal to Holtburg → walls clip; bldOrigin ~5.5km off). - _physicsDataCache.RemoveBuildingsForLandblock(lb.LandblockId); - - uint lbPrefix = lb.LandblockId & 0xFFFF0000u; - foreach (var building in lbInfo.Buildings) - { - // #147 (2026-06-24): do NOT skip portal-less buildings. A town's - // PERIMETER WALL is a building with NO portals (you don't enter a - // wall segment through a door) — but it still needs its shell BSP - // registered for COLLISION. The original skip existed for the - // transit/entry feature (CellTransit.CheckBuildingTransit); dropping - // the whole building also dropped its collision shell, so a far - // town's city walls had ZERO collision and the player walked through - // them (Arwic: 16 of 30 buildings are portal-less wall segments - // ringing the town at 24 m intervals). Retail collides with a - // building's shell regardless of portals — find_building_collisions - // (0x006b5300) tests parts[0], independent of the portal list. So a - // portal-less building is now cached with an EMPTY portal list (no - // transit) but its collision shell (ModelId) intact. - - var bldPortals = new System.Collections.Generic.List( - building.Portals.Count); - foreach (var bp in building.Portals) - { - bldPortals.Add(new AcDream.Core.Physics.BldPortalInfo( - otherCellId: lbPrefix | (uint)bp.OtherCellId, - // DatReaderWriter parses the dat's 16-bit field as - // ushort; retail sign-extends it to a SIGNED int - // (CBldPortal.other_portal_id, acclient.h:32098) and - // check_building_transit gates on `>= 0` - // (Ghidra 0x0052c5dc). 0xFFFF → -1 = no reciprocal. - otherPortalId: unchecked((short)bp.OtherPortalId), - flags: (ushort)bp.Flags)); - } - - // Build a world transform for the building. Frame.Origin is - // landblock-relative; add the landblock world origin to get - // world space. - var bldOriginWorld = building.Frame.Origin + origin; - var buildingTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(building.Frame.Orientation) - * System.Numerics.Matrix4x4.CreateTranslation(bldOriginWorld); - - // Derive the outdoor landcell id containing this building. - // Reuse TerrainSurface.ComputeOutdoorCellId rather than - // re-deriving the row-major (gridX * 8 + gridY + 1) formula here. - // Frame.Origin is landblock-relative, same coordinate space as - // ComputeOutdoorCellId expects (local X/Y within the 192m block). - uint landcellLow = terrainSurface.ComputeOutdoorCellId( - building.Frame.Origin.X, building.Frame.Origin.Y); - uint landcellId = lbPrefix | landcellLow; - - // BR-7 / A6.P4 (2026-06-11): the shell part-0 GfxObj id - // arms the retail building collision channel - // (CBuildingObj::find_building_collisions, Ghidra - // 0x006b5300 — one BSP test on part_array->parts[0]). - // 0x01 model ids are the GfxObj; 0x02 Setups resolve to - // their first part here, where the dat is at hand. - uint shellPart0 = building.ModelId; - if ((shellPart0 & 0xFF000000u) == 0x02000000u) - { - // _dats is non-null on every streaming-drain path - // (the GfxObj physics cache loop below dereferences - // it unconditionally). - datBundle.Setups.TryGetValue(building.ModelId, out var bldSetup); - shellPart0 = bldSetup is not null && bldSetup.Parts.Count > 0 - ? bldSetup.Parts[0] - : 0u; - } - _physicsDataCache.CacheBuilding(landcellId, bldPortals, buildingTransform, - modelId: shellPart0); - } - } - - _physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces, - portalPlanes, origin.X, origin.Y); - _landblockRenderPublisher.CompletePublication(renderPublication); + AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsAfter = + _landblockPhysicsPublisher.Diagnostics; + AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderAfter = + _landblockRenderPublisher.Diagnostics; + long gfxCacheTicks = + physicsAfter.GfxCacheTicks - physicsBefore.GfxCacheTicks; + _applyCellAccumTicks += + physicsAfter.BasePublishTicks - physicsBefore.BasePublishTicks; + _applyCellAccumTicks += + renderAfter.CompletePublishTicks + - renderPrefix.CompletePublishTicks; + _applyBspAccumTicks += gfxCacheTicks; + _applyShadowAccumTicks += + physicsAfter.CompletePublishTicks + - physicsBefore.CompletePublishTicks + - gfxCacheTicks; } - // N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via - // ObjectMeshManager.PrepareMeshDataAsync. The legacy EnsureUploaded loop - // and the legacy cell-mesh upload path are retired with InstancedMeshRenderer. - // Cache GfxObj physics data (BSP trees) for the physics engine — this - // loop is physics-only, not renderer-side. - // [FRAME-DIAG] checkpoint: end cell-build, begin gfxobj-BSP. - if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyCellAccumTicks += t - fdCheck; fdCheck = t; } - foreach (var entity in lb.Entities) - { - foreach (var meshRef in entity.MeshRefs) - { - if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue; - if (!datBundle.GfxObjs.TryGetValue(meshRef.GfxObjId, out var gfx)) continue; - _physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx); - } - } - // [FRAME-DIAG] checkpoint: end gfxobj-BSP, begin ShadowObjects+lights. - if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyBspAccumTicks += t - fdCheck; fdCheck = t; } - - // Task 7: register static entities into the ShadowObjectRegistry so the - // Transition system can find and collide against them during movement. - // Only entities backed by a GfxObj with a physics BSP are registered — - // entities with no BSP (pure visual, no physics) are skipped. - // - // Radius source priority: - // 1. GfxObj: use the BSP root bounding sphere radius if available. - // 2. Setup: use Setup.Radius (the capsule radius) if available. - // 3. Fallback: 1.0m (conservative default for trees / small objects). - int lbBspCount = 0, lbCylCount = 0, lbNoneCount = 0; - int scTried = 0; - foreach (var entity in lb.Entities) - { - // Phase G.2: if the entity's Setup has baked-in LightInfos, - // register them with the LightManager so torches, braziers, - // and lifestones cast real light on nearby geometry. Hooked - // via the LightingHookSink so per-entity owner tracking + - // SetLightHook IsLit toggles all go through one codepath. - // Only applies to Setup-sourced entities (0x02xxxxxx) — raw - // GfxObjs don't carry Lights dictionaries. - if (_lightingSink is not null && _dats is not null) - { - uint src = entity.SourceGfxObjOrSetupId; - if ((src & 0xFF000000u) == 0x02000000u) - { - datBundle.Setups.TryGetValue(src, out var datSetup); - if (datSetup is not null && datSetup.Lights.Count > 0) - { - // #176 site-A light-stacking leak: a landblock can RE-apply - // without an unload in between (#168 ForceReloadWindow, Far→Near - // promotion), and static entity ids are deterministic — so each - // re-apply appended another copy of the same lights under the - // same owner (observed ×2→×4 per session; the wash intensifies - // over time, and the stacked ties destabilize the 128-cap pool - // sort). Clear the owner's previous registration first: the - // re-apply becomes idempotent, and a first apply is a no-op. - uint effectOwnerId = entity.Id; - _lightingSink.UnregisterOwner(effectOwnerId); - _translucencyFades.ClearEntity(effectOwnerId); // #188 - var loaded = AcDream.Core.Lighting.LightInfoLoader.Load( - datSetup, - ownerId: effectOwnerId, - entityPosition: entity.Position, - entityRotation: entity.Rotation, - cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility - foreach (var ls in loaded) - _lightingSink.RegisterOwnedLight(ls); - } - } - } - - int entityBsp = 0, entityCyl = 0; - // Treat both procedural scenery (0x80000000+) AND LandBlockInfo - // stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities - // that should use visual-mesh-AABB collision. Stabs include landscape - // trees placed by Turbine (not procedural scenery) that otherwise - // have no collision shape registered. - uint _srcPrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u; - bool _isOutdoorMesh = ((entity.Id & 0x80000000u) != 0) // scenery - || ((entity.Id < 0x40000000u) // stab - && (_srcPrefix == 0x01000000u || _srcPrefix == 0x02000000u)); - bool _isScenery = _isOutdoorMesh; - // ISSUES #83 / Phase A1 (2026-05-21): landblock stabs - // (LandBlockInfo.Objects + Buildings) use entity.Id with the - // 0xCXXYYIII layout per LandblockStaticEntityIdAllocator. Their BSP - // collision covers the whole structure; the mesh-AABB-fallback - // path below is for canopy-only-BSP procedural scenery - // (0x8XXYYIII) and produces a redundant 1.5m-clamped - // invisible disc at the stab's mesh origin — the user-reported - // "thin air" collision inside cottages. Gate the fallback to - // exclude stabs. Spec: - // docs/superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md. - bool _isLandblockStab = - AcDream.Core.World.LandblockStaticEntityIdAllocator.IsInNamespace(entity.Id); - if (_isScenery) scTried++; - // #185 FIX (2026-07-08): register the entity's BSP parts as ONE - // multi-part shadow object keyed on the entity's UNIQUE 32-bit id, - // via RegisterMultiPart (retail CPhysicsObj::add_shadows_to_cells - // 0x00514ae0 → CPartArray::AddPartsShadow — one object, a part array). - // - // The former per-part Register(entity.Id * 256u + partIndex) OVERFLOWED - // uint32 for class-prefixed landblock ids (0x40/0x80/0xC0…): the << 8 - // dropped the prefix byte, so different-class entities sharing the low - // 24 bits collided on ONE shadow part-id and Register's - // deregister-then-insert silently overwrote one entity's collision - // geometry — the #185 "invisible wall half-way up the stairs" (rendered - // steps with no collision; 23 such part-id collisions in landblock - // 0xF682 alone: 0xF6822100 ← {0x40F68221, 0xC0F68221}, …). entity.Id is - // unique, so RegisterMultiPart has no synthetic id and no collision. - // Building shells stay excluded (they collide via the per-LandCell - // building channel, CSortCell::find_collisions). The Setup CylSphere/ - // Sphere path below is UNCHANGED and only runs when entityBsp == 0 - // (retail binary dispatch: BSP xor cyl). Builder: - // ShadowShapeBuilder.FromLandblockBspParts. - var bspShapes = AcDream.Core.Physics.ShadowShapeBuilder.FromLandblockBspParts( - entity.MeshRefs, entity.IsBuildingShell, - id => _physicsDataCache.GetGfxObj(id)); - entityBsp = bspShapes.Count; - if (entityBsp > 0) - { - _physicsEngine.ShadowObjects.RegisterMultiPart( - entity.Id, entity.Position, entity.Rotation, bspShapes, - 0u, AcDream.Core.Physics.EntityCollisionFlags.None, - origin.X, origin.Y, lb.LandblockId, - seedCellId: entity.ParentCellId ?? 0u, isStatic: true); - // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. - // One line per BSP part, all under the entity's single id (no synthetic partId). - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) - for (int _pi = 0; _pi < bspShapes.Count; _pi++) - Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{bspShapes[_pi].GfxObjId:X8} lb=0x{lb.LandblockId:X8} type=BSP note=multipart-part{_pi} hasPhys=true state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}")); - } - - // Register collision shapes from the Setup (if this entity has one). - // Retail uses CylSpheres for trunks/pillars, Spheres for blob-shaped - // collision volumes. We register both as "cylinder" shadow entries - // because our collision system only has BSP and Cylinder types; a - // Sphere is handled as a short cylinder. - // - // SCALE + ROTATION handling: - // - Radius, Height, and the local Origin offset are ALL scaled by - // entity.Scale so they match the visually-scaled mesh. - // - The Origin offset is ROTATED by entity.Rotation so rotated - // scenery has its collision cylinder in the correct world spot. - // - // Keying: - // entity.Id → the primary CylSphere (if any) - // entity.Id + K*0x10000000u → additional CylSpheres/Spheres - // This ensures uniqueness per shape so ShadowObjectRegistry doesn't - // clobber entries via Deregister. - { - var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); - // Retail binary collision dispatch (CPhysicsObj::FindObjCollisions - // @0x0050f050, see feedback_retail_binary_dispatch): an object uses - // its physics BSP if it HAS one (HAS_PHYSICS_BSP_PS 0x10000), ELSE - // its CSetup CylSpheres/Spheres — never both. The per-part BSP loop - // above already registered every BSP-bearing part (entityBsp counts - // them). So register the Setup's CylSpheres/Spheres ONLY when no BSP - // claimed this object (entityBsp == 0). - // - // This supersedes the ISSUES #83 / A1.6 (2026-05-21) gate that - // skipped Setup cyl/sphere for ALL landblock stabs to stop a - // BSP+cylinder doubled-collision. That gate over-broadened: it also - // killed collision for BSP-LESS stabs whose ONLY shape is a Setup - // CylSphere — e.g. the Holtburg town torch (stab Setup 0x020005D8, - // part 0x01001774 HasPhysics=False/BSP=null, cylSphere r=0.2 h=2.2). - // Retail collides with it via that cylsphere (live cdb 2026-06-25: - // FindObjCollisions target ncyl=1, cyl h=2.2 at world (105.99,17.17)); - // acdream walked straight through. Gating on entityBsp==0 keeps #83's - // anti-doubling (stab WITH a BSP → BSP-only) while restoring cyl - // collision for BSP-less stabs. Other landblock entities on this path - // (scenery — tree-trunk cylspheres etc.) are unaffected: with no BSP - // they still register cyl/sphere exactly as before. - if (setup is not null && entityBsp == 0) - { - float entScale = entity.Scale > 0f ? entity.Scale : 1f; - uint shapeIndex = 0; - - // Register every CylSphere the Setup defines. - for (int ci = 0; ci < setup.CylSpheres.Count; ci++) - { - var cyl = setup.CylSpheres[ci]; - float cylRadius = cyl.Radius * entScale; - float baseHeight = cyl.Height > 0 ? cyl.Height : cyl.Radius * 4f; - float cylHeight = baseHeight * entScale; - if (cylRadius <= 0f) continue; - - // Rotate the local origin offset by entity rotation, - // then scale it before adding to entity.Position. - var localOffset = new System.Numerics.Vector3( - cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale; - var worldOffset = System.Numerics.Vector3.Transform(localOffset, entity.Rotation); - - uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u; - _physicsEngine.ShadowObjects.Register( - shapeId, entity.SourceGfxObjOrSetupId, - entity.Position + worldOffset, - entity.Rotation, cylRadius, - origin.X, origin.Y, lb.LandblockId, - AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight, - seedCellId: entity.ParentCellId ?? 0u); - // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. - // state/flags literals: landblock-baked scenery; no server PhysicsState. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) - Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-cylsphere#{ci} state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}")); - entityCyl++; - } - - // Register every Sphere as a short cylinder when no - // CylSphere claimed the object. - if (setup.CylSpheres.Count == 0) - { - for (int si = 0; si < setup.Spheres.Count; si++) - { - var sph = setup.Spheres[si]; - if (sph.Radius <= 0f) continue; - - float sphRadius = sph.Radius * entScale; - float sphHeight = sphRadius * 2f; - - // Rotate + scale the local origin, then offset the - // cylinder base down by the scaled radius so the - // short cylinder is centered on the sphere. - var localOffset = new System.Numerics.Vector3( - sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale; - var worldOffset = System.Numerics.Vector3.Transform(localOffset, entity.Rotation); - worldOffset.Z -= sphRadius; - - uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u; - _physicsEngine.ShadowObjects.Register( - shapeId, entity.SourceGfxObjOrSetupId, - entity.Position + worldOffset, - entity.Rotation, sphRadius, - origin.X, origin.Y, lb.LandblockId, - AcDream.Core.Physics.ShadowCollisionType.Cylinder, sphHeight, - seedCellId: entity.ParentCellId ?? 0u); - // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. - // state/flags literals: landblock-baked scenery; no server PhysicsState. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) - Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-sphere#{si} state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}")); - entityCyl++; - } - } - - // Setup.Radius fallback: the Setup has NO CylSpheres and NO - // Spheres but has a positive Radius/Height. Use the overall - // bounding cylinder scaled by entity.Scale. - if (setup.CylSpheres.Count == 0 && setup.Spheres.Count == 0 - && setup.Radius > 0f && entityBsp == 0) - { - float fr = setup.Radius * entScale; - float fh = (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale; - uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u; - _physicsEngine.ShadowObjects.Register( - shapeId, entity.SourceGfxObjOrSetupId, - entity.Position, entity.Rotation, fr, - origin.X, origin.Y, lb.LandblockId, - AcDream.Core.Physics.ShadowCollisionType.Cylinder, fh, - seedCellId: entity.ParentCellId ?? 0u); - // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. - // state/flags literals: landblock-baked scenery; no server PhysicsState. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) - Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-radius-fallback state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}")); - entityCyl++; - } - } - } - - // Tally per-entity collision presence (debug counter — optional). - if (entityBsp > 0) lbBspCount++; - if (entityCyl > 0) lbCylCount++; - if (entityBsp == 0 && entityCyl == 0) - { - // Only count as "none" if it's an OUTDOOR entity (0x01/0x02 source). - // EnvCell entities (src = cell ID like 0xAABBxxxx) use BSP collision - // via CellPhysics and don't need cylinder registration. - uint srcPrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u; - if (srcPrefix == 0x01000000u || srcPrefix == 0x02000000u) - lbNoneCount++; - } - } - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && scTried > 0) - Console.WriteLine( - $"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} (outdoorNone={lbNoneCount})"); - - // Find scenery WITHOUT any cached visual bounds at all - int sceneryNoCache = 0; - var sampleMissing = new List(); - foreach (var entity in lb.Entities) - { - if ((entity.Id & 0x80000000u) == 0) continue; // not scenery - bool anyHaveBounds = false; - foreach (var mr in entity.MeshRefs) - { - var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId); - if (vb is not null && vb.Radius > 0f) { anyHaveBounds = true; break; } - } - if (!anyHaveBounds) - { - sceneryNoCache++; - if (sampleMissing.Count < 3) - sampleMissing.Add(entity.SourceGfxObjOrSetupId); - } - } - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && sceneryNoCache > 0) - { - string samples = string.Join(",", sampleMissing.Select(s => $"0x{s:X8}")); - Console.WriteLine($" → {sceneryNoCache} scenery entities had no visual bounds cached. Samples: {samples}"); - } - - - // BR-7 / A6.P4 (2026-06-11): this landblock's cells + buildings are - // now hydrated — re-run the registration flood for entities whose - // shadow cell set touches it. Covers the streaming races in both - // directions (server spawn before the landblock hydrated → flood - // couldn't traverse; cells hydrated after the spawn). Retail - // equivalent: CObjCell::init_objects → recalc_cross_cells on cell - // load (Ghidra 0x0052b420 / 0x00515a30). - _physicsEngine.ShadowObjects.RefloodLandblock(lb.LandblockId); - - // Register each stab as a plugin snapshot so the plugin host has - // visibility into the streaming world state. - foreach (var entity in lb.Entities) + long pluginStarted = + _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; + foreach (AcDream.Core.World.WorldEntity entity in landblock.Entities) { // Live objects publish through LiveEntityRuntime exactly once per - // accepted incarnation. This landblock callback owns only DAT - // statics; a retained live projection must not emit a second spawn - // when its spatial bucket reloads. + // accepted incarnation. This callback owns DAT statics only. if (entity.ServerGuid != 0) continue; - var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( - Id: entity.Id, - SourceId: entity.SourceGfxObjOrSetupId, - Position: entity.Position, - Rotation: entity.Rotation); + + var snapshot = + new AcDream.Plugin.Abstractions.WorldEntitySnapshot( + Id: entity.Id, + SourceId: entity.SourceGfxObjOrSetupId, + Position: entity.Position, + Rotation: entity.Rotation); _worldGameState.Add(snapshot); _worldEvents.FireEntitySpawned(snapshot); } - // [FRAME-DIAG] checkpoint: end ShadowObjects+lights registration (apply tail). - if (_frameDiag) _applyShadowAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdCheck; + if (_frameDiag) + { + _applyShadowAccumTicks += + System.Diagnostics.Stopwatch.GetTimestamp() - pluginStarted; + } } private void OnUpdate(double dt) diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs new file mode 100644 index 00000000..02908273 --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -0,0 +1,654 @@ +using System.Diagnostics; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +/// +/// Exact receipt for the two physics-owned stages of one accepted landblock +/// publication. The render publisher commits buildings and EnvCells between +/// these stages without recomputing the captured origin. +/// +public sealed class LandblockPhysicsPublication +{ + internal LandblockPhysicsPublication( + object owner, + LandblockBuild build, + Vector3 origin) + { + Owner = owner; + Build = build; + Origin = origin; + } + + internal object Owner { get; } + internal LandblockBuild Build { get; } + internal bool CompletionCommitted { get; set; } + + public uint LandblockId => Build.Landblock.LandblockId; + public Vector3 Origin { get; } +} + +/// +/// Cumulative update-thread diagnostics for physics publication and removal. +/// Durations are ticks. +/// +public readonly record struct LandblockPhysicsPublisherDiagnostics( + long BeginCount, + long CompleteCount, + long BasePublishTicks, + long GfxCacheTicks, + long CompletePublishTicks, + long CellSurfaceCount, + long PortalPlaneCount, + long BuildingCount, + long StaticBspOwnerCount, + long StaticCylinderOwnerCount, + long RefloodCount, + long DemotionCount, + long FullRemovalCount); + +/// +/// Update-thread owner of streamed landblock physics publication. It consumes +/// only the immutable build payload: terrain, cells, portal planes, building +/// collision, cached GfxObj shapes, static BSP/cylinder registration, and the +/// final cross-cell reflood. It has no DAT reader or residency policy. +/// +/// +/// The order preserves retail CObjCell::init_objects (0x0052B420), +/// CPhysicsObj::recalc_cross_cells (0x00515A30), +/// CBuildingObj::find_building_collisions (0x006B5300), and +/// CPhysicsObj::add_shadows_to_cells (0x00514AE0). The BSP-or-Setup +/// collision gate follows CPhysicsObj::FindObjCollisions (0x0050F050). +/// Building shells use the per-landcell building channel; ordinary statics +/// use one multipart shadow owner or the mutually exclusive Setup +/// cylinder/sphere fallback. +/// +public sealed class LandblockPhysicsPublisher +{ + private readonly object _receiptOwner = new(); + private readonly PhysicsEngine _physicsEngine; + private readonly PhysicsDataCache _physicsDataCache; + private readonly float[] _heightTable; + + private long _beginCount; + private long _completeCount; + private long _basePublishTicks; + private long _gfxCacheTicks; + private long _completePublishTicks; + private long _cellSurfaceCount; + private long _portalPlaneCount; + private long _buildingCount; + private long _staticBspOwnerCount; + private long _staticCylinderOwnerCount; + private long _refloodCount; + private long _demotionCount; + private long _fullRemovalCount; + + public LandblockPhysicsPublisher( + PhysicsEngine physicsEngine, + float[] heightTable) + { + ArgumentNullException.ThrowIfNull(physicsEngine); + ArgumentNullException.ThrowIfNull(heightTable); + if (heightTable.Length < 256) + throw new ArgumentException( + "The retail terrain height table must contain at least 256 entries.", + nameof(heightTable)); + + _physicsEngine = physicsEngine; + _physicsDataCache = physicsEngine.DataCache + ?? throw new ArgumentException( + "The physics engine must own its canonical data cache before publication wiring.", + nameof(physicsEngine)); + _heightTable = (float[])heightTable.Clone(); + } + + public LandblockPhysicsPublisherDiagnostics Diagnostics => new( + _beginCount, + _completeCount, + _basePublishTicks, + _gfxCacheTicks, + _completePublishTicks, + _cellSurfaceCount, + _portalPlaneCount, + _buildingCount, + _staticBspOwnerCount, + _staticCylinderOwnerCount, + _refloodCount, + _demotionCount, + _fullRemovalCount); + + /// + /// Publishes terrain, EnvCell physics, portal planes, and building shells. + /// The render publisher completes its building/EnvCell transaction before + /// publishes ordinary static collision. + /// + public LandblockPhysicsPublication BeginPublication( + LandblockRenderPublication renderPublication) + { + ArgumentNullException.ThrowIfNull(renderPublication); + LandblockBuild build = renderPublication.Build; + Vector3 origin = renderPublication.Origin; + if (!IsFinite(origin)) + throw new ArgumentOutOfRangeException(nameof(renderPublication)); + + long started = Stopwatch.GetTimestamp(); + LoadedLandblock landblock = build.Landblock; + PhysicsDatBundle datBundle = + landblock.PhysicsDats ?? PhysicsDatBundle.Empty; + uint currentCellId = _physicsDataCache.CellGraph.CurrCell?.Id ?? 0u; + uint landblockX = (landblock.LandblockId >> 24) & 0xFFu; + uint landblockY = (landblock.LandblockId >> 16) & 0xFFu; + + // A publication is an exact replacement for this landblock prefix. + // CacheCellStruct/CacheBuilding intentionally use retail first-wins + // semantics within one pass, so retire the prior pass before adding + // the replacement. Adjacent prefixes remain untouched. + _physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId); + _physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId); + _physicsDataCache.CellGraph.RemoveEnvCellsForLandblock( + landblock.LandblockId); + + // TerrainInfo.Type occupies bits 2-6. The low byte retains those bits + // plus Road (bits 0-1), which TerrainSurface masks independently. + var terrainBytes = new byte[81]; + for (int i = 0; i < terrainBytes.Length; i++) + terrainBytes[i] = (byte)(ushort)landblock.Heightmap.Terrain[i]; + + var terrainSurface = new TerrainSurface( + landblock.Heightmap.Height, + _heightTable, + landblockX, + landblockY, + terrainBytes); + var cellSurfaces = new List(); + var portalPlanes = new List(); + + DatReaderWriter.DBObjs.LandBlockInfo? landblockInfo = datBundle.Info; + if (landblockInfo is not null && landblockInfo.NumCells > 0) + { + uint firstCellId = + (landblock.LandblockId & 0xFFFF0000u) | 0x0100u; + for (uint offset = 0; offset < landblockInfo.NumCells; offset++) + { + uint envCellId = firstCellId + offset; + if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell)) + continue; + if (envCell.EnvironmentId == 0) + continue; + if (!datBundle.Environments.TryGetValue( + 0x0D000000u | envCell.EnvironmentId, + out var environment)) + { + continue; + } + if (!environment.Cells.TryGetValue( + envCell.CellStructure, + out var cellStruct)) + { + continue; + } + + Quaternion rotation = envCell.Position.Orientation; + Vector3 cellOriginWorld = envCell.Position.Origin + origin; + Matrix4x4 physicsCellTransform = + Matrix4x4.CreateFromQuaternion(rotation) + * Matrix4x4.CreateTranslation(cellOriginWorld); + + _physicsDataCache.CacheCellStruct( + envCellId, + envCell, + cellStruct, + physicsCellTransform); + + var worldVertices = new Dictionary( + cellStruct.VertexArray.Vertices.Count); + foreach ((ushort vertexId, var vertex) in + cellStruct.VertexArray.Vertices) + { + Vector3 worldPosition = + Vector3.Transform(vertex.Origin, rotation) + + cellOriginWorld; + worldVertices[(ushort)vertexId] = worldPosition; + } + + var polygonVertexIds = new List>( + cellStruct.PhysicsPolygons.Count); + foreach (var polygon in cellStruct.PhysicsPolygons.Values) + { + var vertexIds = new List(polygon.VertexIds.Count); + foreach (short vertexId in polygon.VertexIds) + vertexIds.Add(vertexId); + polygonVertexIds.Add(vertexIds); + } + cellSurfaces.Add(new CellSurface( + envCellId, + worldVertices, + polygonVertexIds)); + + // CellPortal.PolygonId indexes rendering Polygons, not + // PhysicsPolygons (ACViewer EnvCell.find_transit_cells). + foreach (var portal in envCell.CellPortals) + { + if (!cellStruct.Polygons.TryGetValue( + portal.PolygonId, + out var polygon) + || polygon.VertexIds.Count < 3) + { + continue; + } + + var portalVertices = new Vector3[polygon.VertexIds.Count]; + bool allFound = true; + for (int index = 0; index < polygon.VertexIds.Count; index++) + { + if (!worldVertices.TryGetValue( + (ushort)polygon.VertexIds[index], + out portalVertices[index])) + { + allFound = false; + break; + } + } + if (!allFound) + continue; + + portalPlanes.Add(PortalPlane.FromVertices( + portalVertices.AsSpan(), + portal.OtherCellId, + envCellId & 0xFFFFu, + (ushort)portal.Flags)); + } + } + } + + int buildingCount = PublishBuildings( + landblock, + datBundle, + landblockInfo, + terrainSurface, + origin); + _physicsEngine.AddLandblock( + landblock.LandblockId, + terrainSurface, + cellSurfaces, + portalPlanes, + origin.X, + origin.Y); + if ((currentCellId & 0xFFFF0000u) + == (landblock.LandblockId & 0xFFFF0000u)) + { + _physicsEngine.UpdatePlayerCurrCell(currentCellId); + } + + _cellSurfaceCount += cellSurfaces.Count; + _portalPlaneCount += portalPlanes.Count; + _buildingCount += buildingCount; + _beginCount++; + _basePublishTicks += Stopwatch.GetTimestamp() - started; + return new LandblockPhysicsPublication( + _receiptOwner, + build, + origin); + } + + /// + /// Publishes ordinary static collision and refloods after the caller has + /// completed the render-owned building/EnvCell suffix. The callback keeps + /// the shipped per-entity light-before-collision order until checkpoint E + /// moves static presentation into the transaction coordinator. + /// + public void CompletePublication( + LandblockPhysicsPublication publication, + Action? beforeStaticCollision = null) + { + ArgumentNullException.ThrowIfNull(publication); + if (!ReferenceEquals(publication.Owner, _receiptOwner)) + throw new ArgumentException( + "The physics publication receipt belongs to another publisher.", + nameof(publication)); + if (publication.CompletionCommitted) + return; + + long completedStarted = Stopwatch.GetTimestamp(); + LoadedLandblock landblock = publication.Build.Landblock; + PhysicsDatBundle datBundle = + landblock.PhysicsDats ?? PhysicsDatBundle.Empty; + + long cacheStarted = Stopwatch.GetTimestamp(); + foreach (WorldEntity entity in landblock.Entities) + { + foreach (MeshRef meshRef in entity.MeshRefs) + { + if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) + continue; + if (datBundle.GfxObjs.TryGetValue(meshRef.GfxObjId, out var gfx)) + _physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx); + } + } + _gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted; + + // The immutable entity list is an exact Near snapshot. Retire every + // static owner created by the prior snapshot before registering this + // one; RegisterMultiPart can replace matching IDs but cannot discover + // owners omitted by a ForceReload/reapply. Logical-owner removal also + // clears footprints flooded across an adjacent landblock seam. + _physicsEngine.ShadowObjects.DeregisterStaticOwnersForLandblock( + landblock.LandblockId); + + int landblockBspCount = 0; + int landblockCylinderCount = 0; + int landblockNoCollisionCount = 0; + int sceneryTried = 0; + foreach (WorldEntity entity in landblock.Entities) + { + beforeStaticCollision?.Invoke(entity); + + // Retail building shells collide exclusively through the + // per-landcell building channel. They never become ordinary + // shadow objects, even when their source Setup has cylinders. + if (entity.IsBuildingShell) + continue; + + int entityBspCount = 0; + int entityCylinderCount = 0; + uint sourcePrefix = + entity.SourceGfxObjOrSetupId & 0xFF000000u; + bool isOutdoorMesh = + (entity.Id & 0x80000000u) != 0 + || (entity.Id < 0x40000000u + && (sourcePrefix == 0x01000000u + || sourcePrefix == 0x02000000u)); + if (isOutdoorMesh) + sceneryTried++; + + IReadOnlyList bspShapes = + ShadowShapeBuilder.FromLandblockBspParts( + entity.MeshRefs, + entity.IsBuildingShell, + _physicsDataCache.GetGfxObj); + entityBspCount = bspShapes.Count; + if (entityBspCount > 0) + { + _physicsEngine.ShadowObjects.RegisterMultiPart( + entity.Id, + entity.Position, + entity.Rotation, + bspShapes, + 0u, + EntityCollisionFlags.None, + publication.Origin.X, + publication.Origin.Y, + landblock.LandblockId, + seedCellId: entity.ParentCellId ?? 0u, + isStatic: true); + LogMultipartRegistration(landblock, entity, bspShapes); + } + + SetupPhysics? setup = + _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); + if (setup is not null && entityBspCount == 0) + { + float scale = entity.Scale > 0f ? entity.Scale : 1f; + var setupShapes = new List(); + for (int cylinderIndex = 0; + cylinderIndex < setup.CylSpheres.Count; + cylinderIndex++) + { + DatReaderWriter.Types.CylSphere cylinder = + setup.CylSpheres[cylinderIndex]; + float radius = cylinder.Radius * scale; + float baseHeight = cylinder.Height > 0f + ? cylinder.Height + : cylinder.Radius * 4f; + float height = baseHeight * scale; + if (radius <= 0f) + continue; + + Vector3 localOffset = cylinder.Origin * scale; + setupShapes.Add(new ShadowShape( + GfxObjId: entity.SourceGfxObjOrSetupId, + LocalPosition: localOffset, + LocalRotation: Quaternion.Identity, + Scale: scale, + CollisionType: ShadowCollisionType.Cylinder, + Radius: radius, + CylHeight: height)); + } + + if (setup.CylSpheres.Count == 0) + { + for (int sphereIndex = 0; + sphereIndex < setup.Spheres.Count; + sphereIndex++) + { + DatReaderWriter.Types.Sphere sphere = + setup.Spheres[sphereIndex]; + if (sphere.Radius <= 0f) + continue; + + float radius = sphere.Radius * scale; + Vector3 localOffset = sphere.Origin * scale; + Vector3 localBaseOffset = localOffset + + Vector3.Transform( + -Vector3.UnitZ * radius, + Quaternion.Inverse(entity.Rotation)); + setupShapes.Add(new ShadowShape( + GfxObjId: entity.SourceGfxObjOrSetupId, + LocalPosition: localBaseOffset, + LocalRotation: Quaternion.Identity, + Scale: scale, + CollisionType: ShadowCollisionType.Cylinder, + Radius: radius, + CylHeight: radius * 2f)); + } + } + + if (setup.CylSpheres.Count == 0 + && setup.Spheres.Count == 0 + && setup.Radius > 0f) + { + float radius = setup.Radius * scale; + float height = (setup.Height > 0f + ? setup.Height + : setup.Radius * 2f) * scale; + setupShapes.Add(new ShadowShape( + GfxObjId: entity.SourceGfxObjOrSetupId, + LocalPosition: Vector3.Zero, + LocalRotation: Quaternion.Identity, + Scale: scale, + CollisionType: ShadowCollisionType.Cylinder, + Radius: radius, + CylHeight: height)); + } + + if (setupShapes.Count > 0) + { + _physicsEngine.ShadowObjects.RegisterMultiPart( + entity.Id, + entity.Position, + entity.Rotation, + setupShapes, + 0u, + EntityCollisionFlags.None, + publication.Origin.X, + publication.Origin.Y, + landblock.LandblockId, + seedCellId: entity.ParentCellId ?? 0u, + isStatic: true); + LogSetupRegistration(landblock, entity, setupShapes); + entityCylinderCount = setupShapes.Count; + } + } + + if (entityBspCount > 0) + landblockBspCount++; + if (entityCylinderCount > 0) + landblockCylinderCount++; + if (entityBspCount == 0 && entityCylinderCount == 0 + && (sourcePrefix == 0x01000000u + || sourcePrefix == 0x02000000u)) + { + landblockNoCollisionCount++; + } + } + + if (PhysicsDiagnostics.ProbeBuildingEnabled && sceneryTried > 0) + { + Console.WriteLine( + $"lb 0x{landblock.LandblockId:X8}: scenery tried={sceneryTried} " + + $"(outdoorNone={landblockNoCollisionCount})"); + } + LogMissingSceneryBounds(landblock); + + _physicsEngine.ShadowObjects.RefloodLandblock(landblock.LandblockId); + _refloodCount++; + + _staticBspOwnerCount += landblockBspCount; + _staticCylinderOwnerCount += landblockCylinderCount; + publication.CompletionCommitted = true; + _completeCount++; + _completePublishTicks += + Stopwatch.GetTimestamp() - completedStarted; + } + + public void DemoteToTerrain(uint landblockId) + { + _physicsEngine.DemoteLandblockToTerrain(landblockId); + _demotionCount++; + } + + public void RemoveLandblock(uint landblockId) + { + _physicsEngine.RemoveLandblock(landblockId); + _fullRemovalCount++; + } + + private int PublishBuildings( + LoadedLandblock landblock, + PhysicsDatBundle datBundle, + DatReaderWriter.DBObjs.LandBlockInfo? landblockInfo, + TerrainSurface terrainSurface, + Vector3 origin) + { + if (landblockInfo is null || landblockInfo.Buildings.Count == 0) + return 0; + + uint landblockPrefix = landblock.LandblockId & 0xFFFF0000u; + foreach (var building in landblockInfo.Buildings) + { + var portals = new List(building.Portals.Count); + foreach (var portal in building.Portals) + { + portals.Add(new BldPortalInfo( + otherCellId: landblockPrefix | (uint)portal.OtherCellId, + otherPortalId: unchecked((short)portal.OtherPortalId), + flags: (ushort)portal.Flags)); + } + + Vector3 buildingOrigin = building.Frame.Origin + origin; + Matrix4x4 buildingTransform = + Matrix4x4.CreateFromQuaternion(building.Frame.Orientation) + * Matrix4x4.CreateTranslation(buildingOrigin); + uint landcellLow = terrainSurface.ComputeOutdoorCellId( + building.Frame.Origin.X, + building.Frame.Origin.Y); + uint landcellId = landblockPrefix | landcellLow; + + uint shellPartZero = building.ModelId; + if ((shellPartZero & 0xFF000000u) == 0x02000000u) + { + datBundle.Setups.TryGetValue( + building.ModelId, + out var setup); + shellPartZero = setup is not null && setup.Parts.Count > 0 + ? setup.Parts[0] + : 0u; + } + _physicsDataCache.CacheBuilding( + landcellId, + portals, + buildingTransform, + modelId: shellPartZero); + } + return landblockInfo.Buildings.Count; + } + + private static void LogSetupRegistration( + LoadedLandblock landblock, + WorldEntity entity, + IReadOnlyList shapes) + { + if (!PhysicsDiagnostics.ProbeBuildingEnabled) + return; + + for (int index = 0; index < shapes.Count; index++) + { + Console.WriteLine(FormattableString.Invariant( + $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{shapes[index].GfxObjId:X8} lb=0x{landblock.LandblockId:X8} type=Cylinder note=setup-part{index} state=0x{0u:X8} flags={EntityCollisionFlags.None}")); + } + } + + private static void LogMultipartRegistration( + LoadedLandblock landblock, + WorldEntity entity, + IReadOnlyList shapes) + { + if (!PhysicsDiagnostics.ProbeBuildingEnabled) + return; + + for (int index = 0; index < shapes.Count; index++) + { + Console.WriteLine(FormattableString.Invariant( + $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{shapes[index].GfxObjId:X8} lb=0x{landblock.LandblockId:X8} type=BSP note=multipart-part{index} hasPhys=true state=0x{0u:X8} flags={EntityCollisionFlags.None}")); + } + } + + private void LogMissingSceneryBounds(LoadedLandblock landblock) + { + if (!PhysicsDiagnostics.ProbeBuildingEnabled) + return; + + int missingCount = 0; + var samples = new List(); + foreach (WorldEntity entity in landblock.Entities) + { + if ((entity.Id & 0x80000000u) == 0) + continue; + + bool hasBounds = false; + foreach (MeshRef meshRef in entity.MeshRefs) + { + GfxObjVisualBounds? bounds = + _physicsDataCache.GetVisualBounds(meshRef.GfxObjId); + if (bounds is not null && bounds.Radius > 0f) + { + hasBounds = true; + break; + } + } + if (hasBounds) + continue; + + missingCount++; + if (samples.Count < 3) + samples.Add(entity.SourceGfxObjOrSetupId); + } + + if (missingCount > 0) + { + string sampleText = string.Join(",", samples.Select( + value => $"0x{value:X8}")); + Console.WriteLine( + $" → {missingCount} scenery entities had no visual bounds cached. " + + $"Samples: {sampleText}"); + } + } + + private static bool IsFinite(Vector3 value) => + float.IsFinite(value.X) + && float.IsFinite(value.Y) + && float.IsFinite(value.Z); +} diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index f7a94e61..a2c24799 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -124,6 +124,7 @@ public sealed class PhysicsEngine ShadowObjects.DeregisterStaticOwnersForLandblock(landblockId); ShadowObjects.RemoveLandblock(landblockId); DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply + DataCache?.RemoveBuildingsForLandblock(landblockId); // #145: if the player's current cell belonged to the landblock being removed (a teleport // drops the stale source center via OnLivePositionUpdated), clear it. Otherwise CurrCell diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index c4ca420c..873b00b1 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -58,6 +58,11 @@ public sealed class CellGraph public void RemoveLandblock(uint landblockPrefix) { uint lb = landblockPrefix & 0xFFFF0000u; + if (CurrCell is { } current + && (current.Id & 0xFFFF0000u) == lb) + { + CurrCell = null; + } _terrain.TryRemove(lb, out _); foreach (var id in new List(_envCells.Keys)) if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _); @@ -70,6 +75,12 @@ public sealed class CellGraph public void RemoveEnvCellsForLandblock(uint landblockPrefix) { uint lb = landblockPrefix & 0xFFFF0000u; + if (CurrCell is { } current + && (current.Id & 0xFFFF0000u) == lb + && (current.Id & 0xFFFFu) >= 0x0100u) + { + CurrCell = null; + } foreach (var id in new List(_envCells.Keys)) if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _); } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs new file mode 100644 index 00000000..b82db487 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -0,0 +1,711 @@ +using System.Numerics; +using System.Reflection; +using AcDream.App.Streaming; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockPhysicsPublisherTests +{ + private const uint FirstLandblock = 0xA9B4FFFFu; + private const uint AdjacentLandblock = 0xAAB4FFFFu; + private const uint SetupId = 0x02000042u; + private const uint SecondSetupId = 0x02000043u; + private static readonly float[] HeightTable = + Enumerable.Range(0, 256).Select(index => (float)index).ToArray(); + + [Fact] + public void Constructor_ClonesHeightTableAndRejectsIncompleteInput() + { + Assert.Throws(() => new LandblockPhysicsPublisher( + new PhysicsEngine(), + new float[255])); + Assert.Throws(() => new LandblockPhysicsPublisher( + new PhysicsEngine(), + HeightTable)); + + float[] mutable = HeightTable.ToArray(); + var engine = new PhysicsEngine(); + var cache = new PhysicsDataCache(); + engine.DataCache = cache; + var publisher = new LandblockPhysicsPublisher(engine, mutable); + mutable[0] = 999f; + + LandblockPhysicsPublication receipt = Begin( + publisher, + Build(FirstLandblock)); + publisher.CompletePublication(receipt); + + Assert.Equal(1, engine.LandblockCount); + } + + [Fact] + public void FarPublication_IsTerrainOnlyAndDemotionPreservesIt() + { + var fixture = Fixture(); + LandblockPhysicsPublication receipt = Begin( + fixture.Publisher, + Build(FirstLandblock, bundle: PhysicsDatBundle.Empty)); + fixture.Publisher.CompletePublication(receipt); + + Assert.Equal(1, fixture.Engine.LandblockCount); + Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock)); + Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + + fixture.Publisher.DemoteToTerrain(FirstLandblock); + + Assert.Equal(1, fixture.Engine.LandblockCount); + Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock)); + Assert.Equal(1, fixture.Publisher.Diagnostics.DemotionCount); + + fixture.Publisher.RemoveLandblock(FirstLandblock); + Assert.Equal(0, fixture.Engine.LandblockCount); + Assert.False(fixture.Engine.IsLandblockTerrainResident(FirstLandblock)); + Assert.Equal(1, fixture.Publisher.Diagnostics.FullRemovalCount); + } + + [Fact] + public void BeginPublication_CommitsCellPortalAndBuildingPhysicsFromOneBundle() + { + var fixture = Fixture(); + const uint envCellId = 0xA9B40100u; + PhysicsDatBundle bundle = CellPortalAndBuildingBundle(envCellId); + + LandblockPhysicsPublication receipt = Begin( + fixture.Publisher, + Build( + FirstLandblock, + bundle: bundle, + origin: new LandblockBuildOrigin(0xA8, 0xB4))); + + Assert.Equal(new Vector3(192f, 0f, 0f), receipt.Origin); + LandblockPhysicsPublisherDiagnostics diagnostics = + fixture.Publisher.Diagnostics; + Assert.Equal(1, diagnostics.CellSurfaceCount); + Assert.Equal(1, diagnostics.PortalPlaneCount); + Assert.Equal(1, diagnostics.BuildingCount); + Assert.NotNull(fixture.Cache.CellGraph.GetVisible(envCellId)); + BuildingPhysics building = Assert.Single( + fixture.Cache.BuildingIds.Select(id => fixture.Cache.GetBuilding(id)!)); + Assert.Equal(new Vector3(204f, 12f, 0f), building.WorldTransform.Translation); + Assert.Equal(0x01000077u, building.ModelId); + } + + [Fact] + public void CompletePublication_PhysicsBspSuppressesSetupCylinderFallback() + { + const uint gfxObjId = 0x01000077u; + var fixture = Fixture(); + CacheCylinderSetup(fixture.Cache); + fixture.Cache.RegisterGfxObjForTest(gfxObjId, BspGfx(radius: 1.25f)); + WorldEntity entity = new() + { + Id = 0x80A9B401u, + SourceGfxObjOrSetupId = SetupId, + Position = new Vector3(12f, 12f, 0f), + Rotation = Quaternion.Identity, + MeshRefs = [new MeshRef(gfxObjId, Matrix4x4.Identity)], + }; + + Publish(fixture.Publisher, Build(FirstLandblock, [entity])); + + ShadowEntry entry = Assert.Single( + fixture.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(ShadowCollisionType.BSP, entry.CollisionType); + Assert.Equal(1, fixture.Publisher.Diagnostics.StaticBspOwnerCount); + Assert.Equal(0, fixture.Publisher.Diagnostics.StaticCylinderOwnerCount); + } + + [Fact] + public void CompletePublication_BuildingShellNeverRegistersSetupCollision() + { + var fixture = Fixture(); + CacheCylinderSetup(fixture.Cache); + WorldEntity shell = new() + { + Id = 0xC0A9B401u, + SourceGfxObjOrSetupId = SetupId, + Position = new Vector3(12f, 12f, 0f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + IsBuildingShell = true, + }; + + Publish( + fixture.Publisher, + Build( + FirstLandblock, + [shell], + CellPortalAndBuildingBundle(0xA9B40100u))); + + Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + Assert.Empty(fixture.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Single(fixture.Cache.BuildingIds); + } + + [Fact] + public void CompletePublication_MultipleSetupShapesShareOneLogicalOwner() + { + var fixture = Fixture(); + CacheCylinderSetup(fixture.Cache, SetupId, shapeCount: 5); + CacheCylinderSetup(fixture.Cache, SecondSetupId); + const uint firstId = 0x80A9B401u; + const uint collidingLegacyId = 0xC0A9B401u; + WorldEntity first = CylinderEntity( + firstId, + new Vector3(12f, 12f, 0f)); + WorldEntity second = CylinderEntity( + collidingLegacyId, + new Vector3(36f, 12f, 0f), + SecondSetupId); + + Publish( + fixture.Publisher, + Build(FirstLandblock, [first, second])); + + Assert.Equal(2, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + ShadowEntry[] entries = fixture.Engine.ShadowObjects + .AllEntriesForDebug() + .ToArray(); + Assert.Equal(6, entries.Length); + Assert.Equal( + [firstId, collidingLegacyId], + entries.Select(entry => entry.EntityId).Distinct().Order().ToArray()); + } + + [Fact] + public void CompletePublication_RunsPerEntityCallbackBeforeCollisionAndIsIdempotent() + { + var fixture = Fixture(); + WorldEntity entity = CylinderEntity( + 0x80A9B401u, + new Vector3(12f, 12f, 0f)); + CacheCylinderSetup(fixture.Cache); + LandblockPhysicsPublication receipt = Begin( + fixture.Publisher, + Build(FirstLandblock, [entity])); + int callbackCount = 0; + + fixture.Publisher.CompletePublication(receipt, observed => + { + Assert.Same(entity, observed); + Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + callbackCount++; + }); + fixture.Publisher.CompletePublication(receipt, _ => callbackCount++); + + Assert.Equal(1, callbackCount); + Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + ShadowEntry entry = Assert.Single( + fixture.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(entity.Id, entry.EntityId); + Assert.Equal(ShadowCollisionType.Cylinder, entry.CollisionType); + Assert.Equal(1, fixture.Publisher.Diagnostics.CompleteCount); + Assert.Equal(1, fixture.Publisher.Diagnostics.RefloodCount); + } + + [Fact] + public void CompletePublication_CallbackFailureCanRetryWithoutStackingCollision() + { + var fixture = Fixture(); + WorldEntity entity = CylinderEntity( + 0x80A9B401u, + new Vector3(12f, 12f, 0f)); + CacheCylinderSetup(fixture.Cache); + LandblockPhysicsPublication receipt = Begin( + fixture.Publisher, + Build(FirstLandblock, [entity])); + int attempts = 0; + + Assert.Throws(() => + fixture.Publisher.CompletePublication(receipt, _ => + { + attempts++; + throw new InvalidOperationException("injected static presentation failure"); + })); + fixture.Publisher.CompletePublication(receipt, _ => attempts++); + + Assert.Equal(2, attempts); + Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + Assert.Single(fixture.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(1, fixture.Publisher.Diagnostics.CompleteCount); + } + + [Fact] + public void NearReapply_ReplacesSameStaticOwnerWithoutDuplication() + { + var fixture = Fixture(); + WorldEntity first = CylinderEntity( + 0x80A9B401u, + new Vector3(12f, 12f, 0f)); + WorldEntity moved = CylinderEntity( + first.Id, + new Vector3(36f, 12f, 0f)); + CacheCylinderSetup(fixture.Cache); + + LandblockPhysicsPublication firstReceipt = Begin( + fixture.Publisher, + Build(FirstLandblock, [first])); + fixture.Publisher.CompletePublication(firstReceipt); + LandblockPhysicsPublication secondReceipt = Begin( + fixture.Publisher, + Build(FirstLandblock, [moved])); + fixture.Publisher.CompletePublication(secondReceipt); + + Assert.Equal(1, fixture.Engine.LandblockCount); + Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + ShadowEntry entry = Assert.Single( + fixture.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(moved.Position + new Vector3(0f, 0f, 0.6f), entry.Position); + Assert.Equal(2, fixture.Publisher.Diagnostics.BeginCount); + Assert.Equal(2, fixture.Publisher.Diagnostics.CompleteCount); + } + + [Fact] + public void NearReapply_RemovesOmittedStaticAcrossSeamAndPreservesNeighborOwner() + { + var fixture = Fixture(); + CacheCylinderSetup(fixture.Cache); + WorldEntity omitted = CylinderEntity( + 0x80A9B401u, + new Vector3(191.5f, 12f, 0f)); + WorldEntity neighbor = CylinderEntity( + 0x80AAB401u, + new Vector3(192.5f, 12f, 0f)); + Publish( + fixture.Publisher, + Build(FirstLandblock, [omitted])); + Publish( + fixture.Publisher, + Build(AdjacentLandblock, [neighbor])); + + Publish( + fixture.Publisher, + Build(FirstLandblock, Array.Empty())); + + Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + ShadowEntry survivor = Assert.Single( + fixture.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(neighbor.Id, survivor.EntityId); + Assert.Contains( + fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u), + entry => entry.EntityId == neighbor.Id); + Assert.DoesNotContain( + fixture.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == omitted.Id); + } + + [Fact] + public void NearReapply_RebasesCellAndBuildingAndRemovesMissingSnapshotData() + { + const uint envCellId = 0xA9B40100u; + var fixture = Fixture(); + PhysicsDatBundle populated = CellPortalAndBuildingBundle(envCellId); + + Publish( + fixture.Publisher, + Build(FirstLandblock, bundle: populated)); + fixture.Engine.UpdatePlayerCurrCell(envCellId); + AcDream.Core.World.Cells.ObjCell originalCurrent = + fixture.Cache.CellGraph.CurrCell!; + Assert.Equal( + Vector3.Zero, + fixture.Cache.CellGraph.GetVisible(envCellId)!.WorldTransform.Translation); + Assert.Equal( + new Vector3(12f, 12f, 0f), + Assert.Single(fixture.Cache.BuildingIds + .Select(id => fixture.Cache.GetBuilding(id)!)) + .WorldTransform.Translation); + + Publish( + fixture.Publisher, + Build( + FirstLandblock, + bundle: populated, + origin: new LandblockBuildOrigin(0xA8, 0xB4))); + Assert.Equal( + new Vector3(192f, 0f, 0f), + fixture.Cache.CellGraph.GetVisible(envCellId)!.WorldTransform.Translation); + Assert.NotSame(originalCurrent, fixture.Cache.CellGraph.CurrCell); + Assert.Same( + fixture.Cache.CellGraph.GetVisible(envCellId), + fixture.Cache.CellGraph.CurrCell); + Assert.Equal( + new Vector3(204f, 12f, 0f), + Assert.Single(fixture.Cache.BuildingIds + .Select(id => fixture.Cache.GetBuilding(id)!)) + .WorldTransform.Translation); + + Publish( + fixture.Publisher, + Build(FirstLandblock, bundle: PhysicsDatBundle.Empty)); + + Assert.Null(fixture.Cache.CellGraph.GetVisible(envCellId)); + Assert.Null(fixture.Cache.CellGraph.CurrCell); + Assert.Empty(fixture.Cache.BuildingIds); + } + + [Fact] + public void NearReapply_DoesNotRetireAdjacentCellOrBuildingCache() + { + const uint firstCellId = 0xA9B40100u; + const uint adjacentCellId = 0xAAB40100u; + var fixture = Fixture(); + Publish( + fixture.Publisher, + Build( + FirstLandblock, + bundle: CellPortalAndBuildingBundle(firstCellId))); + Publish( + fixture.Publisher, + Build( + AdjacentLandblock, + bundle: CellPortalAndBuildingBundle(adjacentCellId))); + + Publish( + fixture.Publisher, + Build(FirstLandblock, bundle: PhysicsDatBundle.Empty)); + + Assert.Null(fixture.Cache.CellGraph.GetVisible(firstCellId)); + Assert.NotNull(fixture.Cache.CellGraph.GetVisible(adjacentCellId)); + Assert.DoesNotContain( + fixture.Cache.BuildingIds, + id => (id & 0xFFFF0000u) == 0xA9B40000u); + Assert.Contains( + fixture.Cache.BuildingIds, + id => (id & 0xFFFF0000u) == 0xAAB40000u); + } + + [Fact] + public void AdjacentLandblockDemotion_DoesNotEraseNeighborStaticOwner() + { + var fixture = Fixture(); + CacheCylinderSetup(fixture.Cache); + WorldEntity first = CylinderEntity( + 0x80A9B401u, + new Vector3(191.5f, 12f, 0f)); + WorldEntity neighbor = CylinderEntity( + 0x80AAB401u, + new Vector3(192.5f, 12f, 0f)); + + Publish( + fixture.Publisher, + Build(FirstLandblock, [first])); + Publish( + fixture.Publisher, + Build(AdjacentLandblock, [neighbor])); + Assert.Equal(2, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + Assert.Contains( + fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u), + entry => entry.EntityId == neighbor.Id); + + fixture.Publisher.DemoteToTerrain(FirstLandblock); + + Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + ShadowEntry surviving = Assert.Single( + fixture.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(neighbor.Id, surviving.EntityId); + Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock)); + Assert.True(fixture.Engine.IsLandblockTerrainResident(AdjacentLandblock)); + } + + [Fact] + public void FullRemoval_DoesNotEraseAdjacentLandblockCollision() + { + var fixture = Fixture(); + CacheCylinderSetup(fixture.Cache); + WorldEntity first = CylinderEntity( + 0x80A9B401u, + new Vector3(191.5f, 12f, 0f)); + WorldEntity neighbor = CylinderEntity( + 0x80AAB401u, + new Vector3(192.5f, 12f, 0f)); + Publish( + fixture.Publisher, + Build( + FirstLandblock, + [first], + CellPortalAndBuildingBundle(0xA9B40100u))); + Publish( + fixture.Publisher, + Build( + AdjacentLandblock, + [neighbor], + CellPortalAndBuildingBundle(0xAAB40100u))); + + fixture.Publisher.RemoveLandblock(FirstLandblock); + + Assert.Equal(1, fixture.Engine.LandblockCount); + Assert.False(fixture.Engine.IsLandblockTerrainResident(FirstLandblock)); + Assert.True(fixture.Engine.IsLandblockTerrainResident(AdjacentLandblock)); + Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + Assert.Equal( + neighbor.Id, + Assert.Single(fixture.Engine.ShadowObjects.AllEntriesForDebug()).EntityId); + uint survivingBuilding = Assert.Single(fixture.Cache.BuildingIds); + Assert.Equal(0xAAB40000u, survivingBuilding & 0xFFFF0000u); + } + + [Fact] + public void CompletePublication_ForeignReceiptIsRejectedWithoutConsumingIt() + { + var first = Fixture(); + var second = Fixture(); + LandblockPhysicsPublication receipt = Begin( + first.Publisher, + Build(FirstLandblock)); + + Assert.Throws(() => + second.Publisher.CompletePublication(receipt)); + Assert.Equal(0, second.Publisher.Diagnostics.CompleteCount); + + first.Publisher.CompletePublication(receipt); + Assert.Equal(1, first.Publisher.Diagnostics.CompleteCount); + } + + [Fact] + public void PublisherSurface_HasNoDatReaderResidencyOrLiveOriginDependency() + { + Type type = typeof(LandblockPhysicsPublisher); + Type[] dependencies = type + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .Select(field => field.FieldType) + .Concat(type.GetConstructors().SelectMany(constructor => + constructor.GetParameters().Select(parameter => parameter.ParameterType))) + .ToArray(); + + Assert.DoesNotContain(dependencies, dependency => + dependency.FullName?.Contains("DatReader", StringComparison.Ordinal) == true); + Assert.DoesNotContain(dependencies, dependency => + dependency.FullName?.Contains("StreamingRegion", StringComparison.Ordinal) == true); + Assert.DoesNotContain(dependencies, dependency => + dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true); + + MethodInfo begin = Assert.Single( + type.GetMethods(), + method => method.Name == nameof(LandblockPhysicsPublisher.BeginPublication)); + ParameterInfo parameter = Assert.Single(begin.GetParameters()); + Assert.Equal(typeof(LandblockRenderPublication), parameter.ParameterType); + Assert.DoesNotContain(type.GetConstructors().SelectMany(constructor => + constructor.GetParameters()), parameterInfo => + parameterInfo.ParameterType == typeof(PhysicsDataCache)); + } + + [Fact] + public void GameWindow_HasNoLandblockPhysicsPublicationBodies() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.DoesNotContain("_physicsDataCache.CacheCellStruct", source, StringComparison.Ordinal); + Assert.DoesNotContain("_physicsDataCache.CacheBuilding", source, StringComparison.Ordinal); + Assert.DoesNotContain("ShadowShapeBuilder.FromLandblockBspParts", source, StringComparison.Ordinal); + Assert.DoesNotContain("ShadowObjects.RefloodLandblock", source, StringComparison.Ordinal); + Assert.DoesNotContain("_physicsEngine.DemoteLandblockToTerrain", source, StringComparison.Ordinal); + Assert.DoesNotContain("_physicsEngine.RemoveLandblock", source, StringComparison.Ordinal); + } + + private static void Publish( + LandblockPhysicsPublisher publisher, + LandblockBuild build) + { + LandblockPhysicsPublication receipt = Begin(publisher, build); + publisher.CompletePublication(receipt); + } + + private static LandblockPhysicsPublication Begin( + LandblockPhysicsPublisher publisher, + LandblockBuild build) => + publisher.BeginPublication(RenderReceipt(build)); + + private static LandblockRenderPublication RenderReceipt(LandblockBuild build) + { + var publisher = new LandblockRenderPublisher( + publishTerrain: (_, _, _) => { }, + removeTerrain: _ => { }, + cellVisibility: new AcDream.App.Rendering.CellVisibility(), + worldState: new GpuWorldState()); + return publisher.BeginPublication( + build, + new AcDream.Core.Terrain.LandblockMeshData( + Array.Empty(), + Array.Empty())); + } + + private static ( + LandblockPhysicsPublisher Publisher, + PhysicsEngine Engine, + PhysicsDataCache Cache) Fixture() + { + var engine = new PhysicsEngine(); + var cache = new PhysicsDataCache(); + engine.DataCache = cache; + return ( + new LandblockPhysicsPublisher(engine, HeightTable), + engine, + cache); + } + + private static void CacheCylinderSetup( + PhysicsDataCache cache, + uint setupId = SetupId, + int shapeCount = 1) + { + var setup = new Setup(); + for (int index = 0; index < shapeCount; index++) + { + setup.CylSpheres.Add( + new CylSphere + { + Radius = 0.4f + index * 0.01f, + Height = 1.2f, + Origin = new Vector3(index * 0.1f, 0f, 0.6f), + }); + } + cache.CacheSetup(setupId, setup); + } + + private static GfxObjPhysics BspGfx(float radius) => new() + { + BSP = new PhysicsBSPTree + { + Root = new PhysicsBSPNode + { + Type = DatReaderWriter.Enums.BSPNodeType.Leaf, + }, + }, + BoundingSphere = new Sphere + { + Origin = Vector3.Zero, + Radius = radius, + }, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = new Dictionary(), + }; + + private static PhysicsDatBundle CellPortalAndBuildingBundle(uint envCellId) + { + var cellStruct = new CellStruct + { + VertexArray = new VertexArray + { + Vertices = new Dictionary + { + [0] = new SWVertex { Origin = Vector3.Zero }, + [1] = new SWVertex { Origin = Vector3.UnitY }, + [2] = new SWVertex { Origin = Vector3.UnitZ }, + }, + }, + PhysicsPolygons = new Dictionary(), + Polygons = new Dictionary + { + [0] = new Polygon { VertexIds = [0, 1, 2] }, + }, + }; + var environment = new DatReaderWriter.DBObjs.Environment + { + Id = 0x0D000001u, + Cells = { [1] = cellStruct }, + }; + var envCell = new EnvCell + { + Id = envCellId, + EnvironmentId = 1, + CellStructure = 1, + Position = new Frame { Orientation = Quaternion.Identity }, + CellPortals = + { + new CellPortal + { + PolygonId = 0, + OtherCellId = 0xFFFF, + }, + }, + }; + var info = new LandBlockInfo + { + NumCells = 1, + Buildings = + { + new BuildingInfo + { + ModelId = 0x01000077u, + Frame = new Frame + { + Origin = new Vector3(12f, 12f, 0f), + Orientation = Quaternion.Identity, + }, + }, + }, + }; + return new PhysicsDatBundle( + info, + new Dictionary { [envCellId] = envCell }, + new Dictionary + { + [environment.Id] = environment, + }, + new Dictionary(), + new Dictionary()); + } + + private static WorldEntity CylinderEntity( + uint id, + Vector3 position, + uint setupId = SetupId) => new() + { + Id = id, + SourceGfxObjOrSetupId = setupId, + Position = position, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static LandblockBuild Build( + uint landblockId, + IReadOnlyList? entities = null, + PhysicsDatBundle? bundle = null, + LandblockBuildOrigin? origin = null) + { + var landblockInfo = new LandBlockInfo(); + PhysicsDatBundle physics = bundle ?? new PhysicsDatBundle( + landblockInfo, + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary()); + return new LandblockBuild( + new LoadedLandblock( + landblockId, + FlatHeightmap(), + entities ?? Array.Empty(), + physics), + Origin: origin ?? new LandblockBuildOrigin(0xA9, 0xB4)); + } + + private static LandBlock FlatHeightmap() => new() + { + Terrain = new TerrainInfo[81], + Height = new byte[81], + }; + + private static string FindRepoRoot() + { + string? directory = AppContext.BaseDirectory; + while (directory is not null) + { + if (File.Exists(Path.Combine(directory, "AcDream.slnx"))) + return directory; + directory = Directory.GetParent(directory)?.FullName; + } + throw new DirectoryNotFoundException("Could not locate repository root."); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs index 6032062f..6cd199c8 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs @@ -151,4 +151,36 @@ public class PhysicsEngineResidencyTests entry => entry.EntityId == dynamicId); Assert.Equal(1, engine.ShadowObjects.RetainedRegistrationCount); } + + [Fact] + public void RemoveLandblock_RemovesOnlyItsCachedBuildings() + { + const uint landblockId = 0xA9B4FFFFu; + const uint retiredBuildingCell = 0xA9B40001u; + const uint adjacentBuildingCell = 0xAAB40001u; + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + cache.RegisterBuildingForTest( + retiredBuildingCell, + Building(Matrix4x4.Identity)); + cache.RegisterBuildingForTest( + adjacentBuildingCell, + Building(Matrix4x4.CreateTranslation(192f, 0f, 0f))); + + engine.RemoveLandblock(landblockId); + + Assert.Null(cache.GetBuilding(retiredBuildingCell)); + Assert.NotNull(cache.GetBuilding(adjacentBuildingCell)); + } + + private static BuildingPhysics Building(Matrix4x4 transform) + { + Matrix4x4.Invert(transform, out Matrix4x4 inverse); + return new BuildingPhysics + { + WorldTransform = transform, + InverseWorldTransform = inverse, + Portals = Array.Empty(), + }; + } } diff --git a/tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs b/tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs index e7944c8f..459cb537 100644 --- a/tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs +++ b/tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs @@ -56,6 +56,21 @@ public class CellGraphTests Assert.Null(g.GetVisible(0xA9B40014u)); } + [Fact] + public void RemoveLandblock_ClearsCurrentCellForRetiredPrefixOnly() + { + var g = new CellGraph(); + var current = Env(0xA9B40174u); + g.Add(current); + g.CurrCell = current; + + g.RemoveLandblock(0xAAB40000u); + Assert.Same(current, g.CurrCell); + + g.RemoveLandblock(0xA9B40000u); + Assert.Null(g.CurrCell); + } + [Fact] public void RemoveEnvCellsForLandblock_PreservesTerrain() { @@ -69,6 +84,21 @@ public class CellGraphTests Assert.IsType(g.GetVisible(0xA9B40014u)); } + [Fact] + public void RemoveEnvCellsForLandblock_ClearsOnlyMatchingIndoorCurrentCell() + { + var g = new CellGraph(); + var current = Env(0xA9B40174u); + g.Add(current); + g.CurrCell = current; + + g.RemoveEnvCellsForLandblock(0xAAB4FFFFu); + Assert.Same(current, g.CurrCell); + + g.RemoveEnvCellsForLandblock(0xA9B4FFFFu); + Assert.Null(g.CurrCell); + } + [Fact] public void Neighbor_ResolvesPortalOtherCellId() { From e1110f7e54440e81fea4ba9aac9e809974dca4e8 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 21:14:24 +0200 Subject: [PATCH 63/98] docs(plan): complete landblock physics publication Record the exact Slice 5D owner boundary, corrected review results, Release gate, and GameWindow reduction before starting static presentation coordination. Co-authored-by: Codex --- ...-21-gamewindow-slice-5-landblock-presentation.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md index f434abb6..ed20ff07 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md +++ b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md @@ -53,8 +53,19 @@ test, and bisectable commit rather than being hidden in a mechanical move. publish-to-retire balance, and hidden AABB ownership. Three corrected-diff reviews are clean. Release gate: 6,988 passed / 5 skipped; `GameWindow.cs` is 9,490 lines. -- [ ] D — extract physics/cache/collision publication and cross-landblock +- [x] D — extract physics/cache/collision publication and cross-landblock reflood ownership. + - Landed in `3613d393`: `LandblockPhysicsPublisher` now owns captured-origin + terrain/cell/portal/building physics, cached GfxObj shapes, mutually + exclusive multipart BSP or Setup fallback collision, exact static-owner + replacement, final reflood, demotion, and full removal. Every Setup shape + shares the retail logical entity ID; building shells remain exclusive to + the per-landcell building channel. Reapply retires stale cell/building/UCG + state and omitted static owners by landblock prefix, rebinds `CurrCell` to + the replacement object, and preserves adjacent owners even when collision + footprints cross the seam. Three corrected-diff reviews are clean. Release + gate: 7,008 passed / 5 skipped; zero-warning build; `GameWindow.cs` is + 9,043 lines. - [ ] E — extract static lighting, translucency, default-script, and plugin presentation; compose the small `LandblockPresentationPipeline`. - [ ] F — move retryable demotion/full-retirement presentation under the From ea0da8c8ae3ed8f749cd32aecfb04693f3ca9d09 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 21:45:16 +0200 Subject: [PATCH 64/98] refactor(streaming): compose landblock presentation transaction Own render, physics, DAT-static presentation, and retained static-resource rebinds behind retryable receipts so a failed publication resumes its exact unfinished suffix without replaying retail create-time defaults. Co-authored-by: Codex --- src/AcDream.App/Rendering/GameWindow.cs | 48 ++- .../RetailStaticAnimatingObjectScheduler.cs | 65 ++- .../Rendering/Vfx/EntityScriptActivator.cs | 128 +++++- src/AcDream.App/Streaming/GpuWorldState.cs | 47 ++- .../Streaming/LandblockPhysicsPublisher.cs | 58 ++- .../LandblockPresentationPipeline.cs | 104 ++++- .../Streaming/LandblockRenderPublisher.cs | 140 +++++-- .../LandblockStaticPresentationPublisher.cs | 346 ++++++++++++++++ .../Streaming/StreamingController.cs | 20 +- ...tailStaticAnimatingObjectSchedulerTests.cs | 36 ++ .../Vfx/EntityScriptActivatorTests.cs | 107 ++++- .../Streaming/GpuWorldStateActivatorTests.cs | 42 +- ...dblockConcretePresentationPipelineTests.cs | 380 ++++++++++++++++++ .../LandblockPhysicsPublisherTests.cs | 3 +- ...ndblockStaticPresentationPublisherTests.cs | 309 ++++++++++++++ 15 files changed, 1744 insertions(+), 89 deletions(-) create mode 100644 src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 66e9e207..c4f3c771 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -153,6 +153,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements; private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher; private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher; + private AcDream.App.Streaming.LandblockStaticPresentationPublisher? + _landblockStaticPresentationPublisher; private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Streaming.StreamingController? _streamingController; private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; @@ -2194,7 +2196,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _translucencyFades.ClearEntity(ownerId); }, (entity, info) => _staticAnimationScheduler.Register(entity, info), - ownerId => _staticAnimationScheduler.Unregister(ownerId)); + ownerId => _staticAnimationScheduler.Unregister(ownerId), + (entity, info) => _staticAnimationScheduler.Rebind(entity, info)); _entityScriptActivator = entityScriptActivator; // Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock @@ -2429,6 +2432,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext new AcDream.App.Streaming.LandblockPhysicsPublisher( _physicsEngine, _heightTable!); + _landblockStaticPresentationPublisher = + new AcDream.App.Streaming.LandblockStaticPresentationPublisher( + _lightingSink!, + _translucencyFades, + _worldGameState, + _worldEvents); _clipFrame ??= ClipFrame.NoClip(); _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( @@ -2520,6 +2529,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext }); _streamer.Start(); + var landblockPresentationPipeline = + new AcDream.App.Streaming.LandblockPresentationPipeline( + _landblockRenderPublisher!, + _landblockPhysicsPublisher!, + _landblockStaticPresentationPublisher!, + _worldState, + onLandblockLoaded: loadedLandblockId => + _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), + ensureEnvCellMeshes: + _landblockRenderPublisher!.PrepareAfterRenderPins, + retirementCoordinator: _landblockRetirements); _streamingController = new AcDream.App.Streaming.StreamingController( enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad( new AcDream.App.Streaming.LandblockBuildRequest( @@ -2542,7 +2562,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext onLandblockLoaded: loadedLandblockId => _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), ensureEnvCellMeshes: _landblockRenderPublisher!.PrepareAfterRenderPins, - retirementCoordinator: _landblockRetirements); + retirementCoordinator: _landblockRetirements, + presentationPipeline: landblockPresentationPipeline); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; _worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator( @@ -3793,19 +3814,28 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext ticket.RunForEachEntity( AcDream.App.Streaming.LandblockRetirementStage.EntityLighting, entity => !staticOnly || entity.ServerGuid == 0, - entity => _lightingSink?.UnregisterOwner(entity.Id)); + entity => + { + if (entity.ServerGuid == 0) + _landblockStaticPresentationPublisher!.RemoveLighting(entity); + else + _lightingSink?.UnregisterOwner(entity.Id); + }); ticket.RunForEachEntity( AcDream.App.Streaming.LandblockRetirementStage.EntityTranslucency, entity => !staticOnly || entity.ServerGuid == 0, - entity => _translucencyFades.ClearEntity(entity.Id)); + entity => + { + if (entity.ServerGuid == 0) + _landblockStaticPresentationPublisher!.RemoveTranslucency(entity); + else + _translucencyFades.ClearEntity(entity.Id); + }); ticket.RunForEachEntity( AcDream.App.Streaming.LandblockRetirementStage.PluginProjection, static entity => entity.ServerGuid == 0, - entity => - { - _worldGameState.RemoveById(entity.Id); - _worldEvents.ForgetEntity(entity.Id); - }); + entity => _landblockStaticPresentationPublisher! + .RemovePluginProjection(entity)); if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full) { diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index 3c4ca4ea..ab813a51 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -78,7 +78,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram internal int Count => _owners.Count; - public void Register(WorldEntity entity, ScriptActivationInfo info) + public bool Register(WorldEntity entity, ScriptActivationInfo info) { ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(info); @@ -86,13 +86,13 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram || info.Setup is not { } setup || info.DefaultAnimationId == 0) { - return; + return false; } if (_owners.TryGetValue(entity.Id, out Owner? retained)) { if (ReferenceEquals(retained.Entity, entity)) - return; + return true; throw new InvalidOperationException( $"DAT-static animation owner 0x{entity.Id:X8} is already registered."); } @@ -110,7 +110,7 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram new MotionTable(), _animationLoader); if (!sequencer.HasCurrentNode) - return; + return false; } int partCount = setup.Parts.Count; @@ -131,6 +131,63 @@ internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFram SurfaceOverrides = surfaces, PartAvailable = available, }); + return true; + } + + /// + /// Rebinds the retained retail static-animation workset member to a fresh + /// landblock snapshot without reconstructing its PartArray sequencer or + /// restarting Setup.DefaultAnimation. + /// + public void Rebind(WorldEntity entity, ScriptActivationInfo info) + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(info); + if (!_owners.TryGetValue(entity.Id, out Owner? owner)) + { + throw new InvalidOperationException( + $"DAT-static animation owner 0x{entity.Id:X8} is not registered."); + } + if (ReferenceEquals(owner.Entity, entity)) + return; + if (entity.ServerGuid != 0 + || !info.UsesStaticAnimationWorkset + || info.Setup is null + || info.DefaultAnimationId == 0) + { + throw new InvalidOperationException( + $"DAT-static animation owner 0x{entity.Id:X8} cannot be rebound to incompatible Setup data."); + } + + int partCount = owner.Setup.Parts.Count; + uint[] gfxIds = BuildPartGfxIds(owner.Setup, entity); + bool[] available = new bool[partCount]; + if (info.PartAvailability is { } supplied) + { + for (int i = 0; i < partCount && i < supplied.Count; i++) + available[i] = supplied[i]; + } + + // Compose publishes the owner's retained lists directly. Detach the + // displaced immutable snapshot before those lists continue advancing + // under the replacement entity. + WorldEntity displaced = owner.Entity; + if (ReferenceEquals(displaced.MeshRefs, owner.MeshRefs)) + displaced.MeshRefs = owner.MeshRefs.ToArray(); + if (ReferenceEquals(displaced.IndexedPartTransforms, owner.PartPoses) + || ReferenceEquals(displaced.IndexedPartAvailable, owner.PartAvailable)) + { + displaced.SetIndexedPartPoses( + owner.PartPoses.ToArray(), + owner.PartAvailable.ToArray()); + } + + owner.Entity = entity; + owner.PartGfxIds = gfxIds; + owner.SurfaceOverrides = MatchSurfaceOverrides(entity, gfxIds, available); + owner.PartAvailable = available; + owner.HasPreparedLivePartFrames = false; + owner.PreparedLivePartFrames.Clear(); } /// diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs index f314c9fd..d998be06 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs @@ -40,6 +40,12 @@ public sealed class EntityScriptActivator public bool ScriptsStopped; public bool EmittersStopped; public bool PoseRemoved; + public WorldEntity? PendingReplacement; + public ScriptActivationInfo? PendingReplacementInfo; + public bool ReplacementPosePublished; + public bool ReplacementEffectOwnerUpdated; + public bool ReplacementAnimationOwnerUpdated; + public bool ReplacementAnchorUpdated; } private readonly PhysicsScriptRunner _scriptRunner; @@ -48,8 +54,9 @@ public sealed class EntityScriptActivator private readonly Func _resolver; private readonly Action? _registerDatStaticEffectOwner; private readonly Action? _unregisterDatStaticEffectOwner; - private readonly Action? _registerDatStaticAnimationOwner; + private readonly Func? _registerDatStaticAnimationOwner; private readonly Action? _unregisterDatStaticAnimationOwner; + private readonly Action? _rebindDatStaticAnimationOwner; private readonly Dictionary _staticOwners = new(); public EntityScriptActivator( @@ -59,8 +66,9 @@ public sealed class EntityScriptActivator Func resolver, Action? registerDatStaticEffectOwner = null, Action? unregisterDatStaticEffectOwner = null, - Action? registerDatStaticAnimationOwner = null, - Action? unregisterDatStaticAnimationOwner = null) + Func? registerDatStaticAnimationOwner = null, + Action? unregisterDatStaticAnimationOwner = null, + Action? rebindDatStaticAnimationOwner = null) { _scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner)); _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink)); @@ -70,6 +78,116 @@ public sealed class EntityScriptActivator _unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner; _registerDatStaticAnimationOwner = registerDatStaticAnimationOwner; _unregisterDatStaticAnimationOwner = unregisterDatStaticAnimationOwner; + _rebindDatStaticAnimationOwner = rebindDatStaticAnimationOwner; + } + + /// + /// Rebinds a retained DAT-static logical owner to the fresh immutable + /// snapshot produced by a same-landblock + /// rehydrate. This is not a retail create edge: default Setup scripts and + /// existing emitters keep their logical lifetime and are never replayed. + /// + public void OnRebindStatic(WorldEntity previous, WorldEntity replacement) + { + ArgumentNullException.ThrowIfNull(previous); + ArgumentNullException.ThrowIfNull(replacement); + if (previous.ServerGuid != 0 + || replacement.ServerGuid != 0 + || previous.Id == 0 + || previous.Id != replacement.Id) + { + throw new ArgumentException( + "DAT-static rebind requires the same nonzero local ID.", + nameof(replacement)); + } + + uint key = previous.Id; + if (!_staticOwners.TryGetValue(key, out StaticOwnerState? state)) + return; + if (ReferenceEquals(state.Entity, replacement)) + return; + if (!ReferenceEquals(state.Entity, previous)) + { + throw new InvalidOperationException( + $"DAT-static owner 0x{key:X8} does not match the retained incarnation."); + } + if (state.ReleaseStarted) + { + throw new InvalidOperationException( + $"DAT-static owner 0x{key:X8} cannot rebind while teardown is pending."); + } + + if (state.PendingReplacement is null) + { + ScriptActivationInfo replacementInfo = _resolver(replacement) + ?? throw new InvalidOperationException( + $"DAT-static owner 0x{key:X8} lost its Setup activation data during rebind."); + ValidateLogicalRebind(state, replacement, replacementInfo); + state.PendingReplacement = replacement; + state.PendingReplacementInfo = replacementInfo; + } + else if (!ReferenceEquals(state.PendingReplacement, replacement)) + { + throw new InvalidOperationException( + $"DAT-static owner 0x{key:X8} already has another rebind pending."); + } + + ScriptActivationInfo info = state.PendingReplacementInfo!; + if (state.PosePublished && !state.ReplacementPosePublished) + { + _poses.Publish(replacement, info.PartTransforms, info.PartAvailability); + state.ReplacementPosePublished = true; + } + if (state.EffectOwnerRegistered && !state.ReplacementEffectOwnerUpdated) + { + _registerDatStaticEffectOwner!(key, replacement, info.EffectProfile!); + state.ReplacementEffectOwnerUpdated = true; + } + if (state.AnimationOwnerRegistered && !state.ReplacementAnimationOwnerUpdated) + { + (_rebindDatStaticAnimationOwner + ?? throw new InvalidOperationException( + "DAT-static animation ownership has no retained-owner rebind callback."))( + replacement, + info); + state.ReplacementAnimationOwnerUpdated = true; + } + if (!state.ReplacementAnchorUpdated) + { + _scriptRunner.SetOwnerAnchor(key, replacement.Position); + state.ReplacementAnchorUpdated = true; + } + + state.Entity = replacement; + state.Info = info; + state.PendingReplacement = null; + state.PendingReplacementInfo = null; + state.ReplacementPosePublished = false; + state.ReplacementEffectOwnerUpdated = false; + state.ReplacementAnimationOwnerUpdated = false; + state.ReplacementAnchorUpdated = false; + } + + private static void ValidateLogicalRebind( + StaticOwnerState state, + WorldEntity replacement, + ScriptActivationInfo replacementInfo) + { + ScriptActivationInfo retained = state.Info; + bool retainedAnimation = retained.UsesStaticAnimationWorkset + && retained.DefaultAnimationId != 0; + bool replacementAnimation = replacementInfo.UsesStaticAnimationWorkset + && replacementInfo.DefaultAnimationId != 0; + if (state.Entity.SourceGfxObjOrSetupId != replacement.SourceGfxObjOrSetupId + || retained.ScriptId != replacementInfo.ScriptId + || retainedAnimation != replacementAnimation + || (retainedAnimation + && retained.DefaultAnimationId != replacementInfo.DefaultAnimationId) + || (retained.EffectProfile is null) != (replacementInfo.EffectProfile is null)) + { + throw new InvalidOperationException( + $"DAT-static owner 0x{state.Entity.Id:X8} changed Setup lifetime during a retained rebind."); + } } public void OnCreate(WorldEntity entity) @@ -156,8 +274,8 @@ public sealed class EntityScriptActivator && info.DefaultAnimationId != 0 && _registerDatStaticAnimationOwner is not null) { - _registerDatStaticAnimationOwner(entity, info); - state.AnimationOwnerRegistered = true; + state.AnimationOwnerRegistered = + _registerDatStaticAnimationOwner(entity, info); } if (!state.ScriptStarted && info.ScriptId != 0) diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 354e8392..3a96f5d5 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -670,6 +670,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery RequiresActivation: false); } + // Reconcile the only fallible logical-owner edge before consuming any + // pending live/render/tier maps. A retry then sees the exact pending + // suffix even when a resolver or owner callback fails partway through. + landblock = NormalizeLoadedLandblock(landblock); + if (_loaded.TryGetValue(landblock.LandblockId, out LoadedLandblock? displaced)) + ReconcileRetainedStaticEntityScripts(displaced, landblock); + // If pending live entities have been waiting for this landblock, // merge them into the render-thread-owned mutable entity bucket before // storing. Subsequent live spawns append to that bucket in O(1). @@ -681,11 +688,6 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery landblock = NormalizeLoadedLandblock(landblock, merged); _pendingByLandblock.Remove(landblock.LandblockId); } - else - { - landblock = NormalizeLoadedLandblock(landblock); - } - HashSet? mergedRenderIds = additionalRenderIds is null ? null : new HashSet(additionalRenderIds); @@ -703,7 +705,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery tier = LandblockStreamTier.Near; } - if (_loaded.Remove(landblock.LandblockId, out LoadedLandblock? displaced)) + if (_loaded.Remove(landblock.LandblockId, out displaced)) { RemoveLoadedLandblockFromFlatView(displaced); _animatedIndexByLandblock.Remove(landblock.LandblockId); @@ -717,6 +719,39 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery (IEnumerable?)mergedRenderIds ?? Array.Empty()); } + /// + /// A same-landblock rehydrate is an acdream streaming replacement, not a + /// retail logical create/delete edge. Retained static IDs rebind their + /// Setup-owned resources without replaying defaults; omitted IDs perform + /// the same exact teardown used by ordinary retirement. + /// + private void ReconcileRetainedStaticEntityScripts( + LoadedLandblock displaced, + LoadedLandblock replacement) + { + if (_entityScriptActivator is null) + return; + + var replacementsById = new Dictionary(); + for (int i = 0; i < replacement.Entities.Count; i++) + { + WorldEntity entity = replacement.Entities[i]; + if (entity.ServerGuid == 0) + replacementsById.Add(entity.Id, entity); + } + + for (int i = 0; i < displaced.Entities.Count; i++) + { + WorldEntity prior = displaced.Entities[i]; + if (prior.ServerGuid != 0) + continue; + if (replacementsById.TryGetValue(prior.Id, out WorldEntity? retained)) + _entityScriptActivator.OnRebindStatic(prior, retained); + else + _entityScriptActivator.OnRemove(prior); + } + } + internal void ActivateLandblockPresentation( GpuLandblockSpatialPublication publication) { diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index 02908273..c13e21ea 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -24,6 +24,7 @@ public sealed class LandblockPhysicsPublication internal object Owner { get; } internal LandblockBuild Build { get; } + internal bool BeginCommitted { get; set; } internal bool CompletionCommitted { get; set; } public uint LandblockId => Build.Landblock.LandblockId; @@ -127,6 +128,20 @@ public sealed class LandblockPhysicsPublisher /// public LandblockPhysicsPublication BeginPublication( LandblockRenderPublication renderPublication) + { + LandblockPhysicsPublication publication = PreparePublication( + renderPublication); + BeginPublication(publication); + return publication; + } + + /// + /// Captures the exact render receipt before physics mutation begins. The + /// coordinator retains this receipt and can retry the idempotent exact + /// replacement when a cache/backend operation fails. + /// + public LandblockPhysicsPublication PreparePublication( + LandblockRenderPublication renderPublication) { ArgumentNullException.ThrowIfNull(renderPublication); LandblockBuild build = renderPublication.Build; @@ -134,7 +149,25 @@ public sealed class LandblockPhysicsPublisher if (!IsFinite(origin)) throw new ArgumentOutOfRangeException(nameof(renderPublication)); + return new LandblockPhysicsPublication( + _receiptOwner, + build, + origin); + } + + /// + /// Advances the exact terrain/cell/building physics replacement from a + /// retained receipt. + /// + public void BeginPublication(LandblockPhysicsPublication publication) + { + ValidateReceipt(publication); + if (publication.BeginCommitted) + return; + long started = Stopwatch.GetTimestamp(); + LandblockBuild build = publication.Build; + Vector3 origin = publication.Origin; LoadedLandblock landblock = build.Landblock; PhysicsDatBundle datBundle = landblock.PhysicsDats ?? PhysicsDatBundle.Empty; @@ -286,12 +319,9 @@ public sealed class LandblockPhysicsPublisher _cellSurfaceCount += cellSurfaces.Count; _portalPlaneCount += portalPlanes.Count; _buildingCount += buildingCount; + publication.BeginCommitted = true; _beginCount++; _basePublishTicks += Stopwatch.GetTimestamp() - started; - return new LandblockPhysicsPublication( - _receiptOwner, - build, - origin); } /// @@ -304,11 +334,10 @@ public sealed class LandblockPhysicsPublisher LandblockPhysicsPublication publication, Action? beforeStaticCollision = null) { - ArgumentNullException.ThrowIfNull(publication); - if (!ReferenceEquals(publication.Owner, _receiptOwner)) - throw new ArgumentException( - "The physics publication receipt belongs to another publisher.", - nameof(publication)); + ValidateReceipt(publication); + if (!publication.BeginCommitted) + throw new InvalidOperationException( + "Physics publication cannot complete before its prefix commits."); if (publication.CompletionCommitted) return; @@ -651,4 +680,15 @@ public sealed class LandblockPhysicsPublisher float.IsFinite(value.X) && float.IsFinite(value.Y) && float.IsFinite(value.Z); + + private void ValidateReceipt(LandblockPhysicsPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (!ReferenceEquals(publication.Owner, _receiptOwner)) + { + throw new ArgumentException( + "The physics publication receipt belongs to another publisher.", + nameof(publication)); + } + } } diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 5f815cb2..bc7585e4 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -41,6 +41,9 @@ public sealed class LandblockPresentationPipeline public required uint LandblockId; public LandblockStreamTier Tier; public bool PresentationCommitted; + public LandblockRenderPublication? RenderPublication; + public LandblockPhysicsPublication? PhysicsPublication; + public LandblockStaticPresentationPublication? StaticPublication; public bool SpatialCommitted; public GpuLandblockSpatialPublication? SpatialPublication; public bool SpatialPresentationCommitted; @@ -48,7 +51,11 @@ public sealed class LandblockPresentationPipeline public bool LiveRecoveryCommitted; } - private readonly Action _publishBeforeSpatialCommit; + private readonly Action? + _publishBeforeSpatialCommit; + private readonly LandblockRenderPublisher? _renderPublisher; + private readonly LandblockPhysicsPublisher? _physicsPublisher; + private readonly LandblockStaticPresentationPublisher? _staticPublisher; private readonly GpuWorldState _state; private readonly Action? _onLandblockLoaded; private readonly Action? _ensureEnvCellMeshes; @@ -79,6 +86,37 @@ public sealed class LandblockPresentationPipeline demoteNearLayer); } + /// + /// Production constructor. The pipeline retains each concrete owner + /// receipt before mutation and resumes only the unfinished exact stage + /// after a failure. + /// + public LandblockPresentationPipeline( + LandblockRenderPublisher renderPublisher, + LandblockPhysicsPublisher physicsPublisher, + LandblockStaticPresentationPublisher staticPublisher, + GpuWorldState state, + Action? onLandblockLoaded = null, + Action? ensureEnvCellMeshes = null, + LandblockRetirementCoordinator? retirementCoordinator = null) + { + _renderPublisher = renderPublisher + ?? throw new ArgumentNullException(nameof(renderPublisher)); + _physicsPublisher = physicsPublisher + ?? throw new ArgumentNullException(nameof(physicsPublisher)); + _staticPublisher = staticPublisher + ?? throw new ArgumentNullException(nameof(staticPublisher)); + _state = state ?? throw new ArgumentNullException(nameof(state)); + _onLandblockLoaded = onLandblockLoaded; + _ensureEnvCellMeshes = ensureEnvCellMeshes; + // Until Slice 5F internalizes concrete retirement ownership, a + // concrete publication pipeline must share the production coordinator. + // A legacy no-op retirement suffix would detach spatial state while + // leaking the concrete render/physics/static owners. + _retirements = retirementCoordinator + ?? throw new ArgumentNullException(nameof(retirementCoordinator)); + } + public int PendingRetirementCount => _retirements.PendingCount; public int PendingPublicationCount => _publications.Count; @@ -219,22 +257,60 @@ public sealed class LandblockPresentationPipeline { if (!transaction.PresentationCommitted) { - try + if (_renderPublisher is not null + && _physicsPublisher is not null + && _staticPublisher is not null) { - _publishBeforeSpatialCommit(transaction.Build, transaction.MeshData); + transaction.RenderPublication ??= + _renderPublisher.PreparePublication( + transaction.Build, + transaction.MeshData); + transaction.PhysicsPublication ??= + _physicsPublisher.PreparePublication( + transaction.RenderPublication); + transaction.StaticPublication ??= + _staticPublisher.PreparePublication( + transaction.PhysicsPublication); + + // Prepare every immutable receipt before the first external + // mutation. Cross-owner identity/data errors therefore block + // the publication without leaving a committed prefix. + _renderPublisher.BeginPublication(transaction.RenderPublication); + _physicsPublisher.BeginPublication(transaction.PhysicsPublication); + + // Retail establishes the building/visible-cell suffix before + // each static object publishes lights, collision, and scripts. + _renderPublisher.CompletePublication( + transaction.RenderPublication); + + _staticPublisher.BeginPublication(transaction.StaticPublication); + _physicsPublisher.CompletePublication( + transaction.PhysicsPublication, + entity => _staticPublisher.PrepareEntityBeforeCollision( + transaction.StaticPublication, + entity)); + _staticPublisher.CompletePublication( + transaction.StaticPublication); } - catch (Exception error) + else { - // The legacy GameWindow callback still spans several concrete - // owners and cannot report a committed prefix. Until Slice-5 - // checkpoints C-E replace it with individually ledgered - // publishers, its production wrapper conservatively reports a - // committed mutation. Test/compatibility callbacks retain the - // established strong-exception contract unless they explicitly - // report otherwise. - if (error is StreamingMutationException { MutationCommitted: true }) - _publications.Remove(result); - throw; + try + { + (_publishBeforeSpatialCommit + ?? throw new InvalidOperationException( + "The presentation pipeline has no publication owners."))( + transaction.Build, + transaction.MeshData); + } + catch (Exception error) + { + // Compatibility callbacks cannot report an exact retained + // receipt. Preserve the established fail-fast rule only + // for callbacks that explicitly report committed mutation. + if (error is StreamingMutationException { MutationCommitted: true }) + _publications.Remove(result); + throw; + } } transaction.PresentationCommitted = true; } diff --git a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs index eb41ae7d..2e240ba3 100644 --- a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs @@ -20,17 +20,32 @@ public sealed class LandblockRenderPublication internal LandblockRenderPublication( object owner, LandblockBuild build, + LandblockMeshData meshData, Vector3 origin, - Dictionary visibilityCells) + Dictionary visibilityCells, + Vector3 aabbMin, + Vector3 aabbMax) { Owner = owner; Build = build; + MeshData = meshData; Origin = origin; _visibilityCells = new ReadOnlyDictionary(visibilityCells); + AabbMin = aabbMin; + AabbMax = aabbMax; } internal object Owner { get; } internal LandblockBuild Build { get; } + internal LandblockMeshData MeshData { get; } + internal Vector3 AabbMin { get; } + internal Vector3 AabbMax { get; } + internal bool TerrainCommitted { get; set; } + internal bool VisibilityCommitted { get; set; } + internal bool AabbCommitted { get; set; } + internal bool BeginCommitted { get; set; } + internal bool BuildingRegistryCommitted { get; set; } + internal bool EnvCellsCommitted { get; set; } internal bool CompletionCommitted { get; set; } public uint LandblockId => Build.Landblock.LandblockId; @@ -137,6 +152,22 @@ public sealed class LandblockRenderPublisher public LandblockRenderPublication BeginPublication( LandblockBuild build, LandblockMeshData meshData) + { + LandblockRenderPublication publication = PreparePublication( + build, + meshData); + BeginPublication(publication); + return publication; + } + + /// + /// Validates and captures the immutable render transaction before its + /// first external mutation. A coordinator retains this receipt when an + /// exact-replacement callback fails, then resumes the same publication. + /// + public LandblockRenderPublication PreparePublication( + LandblockBuild build, + LandblockMeshData meshData) { ArgumentNullException.ThrowIfNull(build); ArgumentNullException.ThrowIfNull(meshData); @@ -145,7 +176,6 @@ public sealed class LandblockRenderPublisher "Render publication requires the build's captured origin.", nameof(build)); - long beginStarted = Stopwatch.GetTimestamp(); uint landblockId = build.Landblock.LandblockId; if (build.EnvCells is { } candidateEnvCells && candidateEnvCells.LandblockId != landblockId) @@ -157,28 +187,64 @@ public sealed class LandblockRenderPublisher Vector3 origin = ComputeOrigin(landblockId, build.Origin); - long terrainStarted = Stopwatch.GetTimestamp(); - _publishTerrain(landblockId, meshData, origin); - _terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted; - - var committedCells = new Dictionary(); + var visibilityCells = new Dictionary(); if (build.EnvCells is { } envCells) { - _cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells); foreach (LoadedCell cell in envCells.VisibilityCells) - committedCells[cell.CellId] = cell; + visibilityCells[cell.CellId] = cell; } - (Vector3 aabbMin, Vector3 aabbMax) = ComputeAabb(meshData, origin); - _worldState.SetLandblockAabb(landblockId, aabbMin, aabbMax); - - _beginCount++; - _beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted; return new LandblockRenderPublication( _receiptOwner, build, + meshData, origin, - committedCells); + visibilityCells, + aabbMin, + aabbMax); + } + + /// + /// Advances the render prefix from a retained receipt. Every operation is + /// exact replacement, so a callback failure can safely retry this stage. + /// + public void BeginPublication(LandblockRenderPublication publication) + { + ValidateReceipt(publication); + if (publication.BeginCommitted) + return; + + long beginStarted = Stopwatch.GetTimestamp(); + LandblockBuild build = publication.Build; + uint landblockId = publication.LandblockId; + + if (!publication.TerrainCommitted) + { + long terrainStarted = Stopwatch.GetTimestamp(); + _publishTerrain(landblockId, publication.MeshData, publication.Origin); + _terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted; + publication.TerrainCommitted = true; + } + + if (!publication.VisibilityCommitted) + { + if (build.EnvCells is { } envCells) + _cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells); + publication.VisibilityCommitted = true; + } + + if (!publication.AabbCommitted) + { + _worldState.SetLandblockAabb( + landblockId, + publication.AabbMin, + publication.AabbMax); + publication.AabbCommitted = true; + } + + publication.BeginCommitted = true; + _beginCount++; + _beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted; } /// @@ -188,28 +254,35 @@ public sealed class LandblockRenderPublisher /// public void CompletePublication(LandblockRenderPublication publication) { - ArgumentNullException.ThrowIfNull(publication); - if (!ReferenceEquals(publication.Owner, _receiptOwner)) - throw new ArgumentException( - "The render publication receipt belongs to another publisher.", - nameof(publication)); + ValidateReceipt(publication); + if (!publication.BeginCommitted) + throw new InvalidOperationException( + "Render publication cannot complete before its prefix commits."); if (publication.CompletionCommitted) return; long started = Stopwatch.GetTimestamp(); LandblockBuild build = publication.Build; uint landblockId = publication.LandblockId; - if (build.Landblock.PhysicsDats?.Info is { } info) + if (!publication.BuildingRegistryCommitted) { - uint registryKey = landblockId & 0xFFFF0000u; - _buildingRegistries[registryKey] = BuildingLoader.Build( - info, - landblockId, - publication.VisibilityCells); + if (build.Landblock.PhysicsDats?.Info is { } info) + { + uint registryKey = landblockId & 0xFFFF0000u; + _buildingRegistries[registryKey] = BuildingLoader.Build( + info, + landblockId, + publication.VisibilityCells); + } + publication.BuildingRegistryCommitted = true; } - if (build.EnvCells is { } envCells) - _commitEnvCells?.Invoke(envCells); + if (!publication.EnvCellsCommitted) + { + if (build.EnvCells is { } envCells) + _commitEnvCells?.Invoke(envCells); + publication.EnvCellsCommitted = true; + } publication.CompletionCommitted = true; _completeCount++; @@ -287,4 +360,15 @@ public sealed class LandblockRenderPublisher new Vector3(origin.X, origin.Y, zMin), new Vector3(origin.X + 192f, origin.Y + 192f, zMax)); } + + private void ValidateReceipt(LandblockRenderPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (!ReferenceEquals(publication.Owner, _receiptOwner)) + { + throw new ArgumentException( + "The render publication receipt belongs to another publisher.", + nameof(publication)); + } + } } diff --git a/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs b/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs new file mode 100644 index 00000000..345369ab --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs @@ -0,0 +1,346 @@ +using AcDream.Core.Lighting; +using AcDream.Core.Plugins; +using AcDream.Core.Rendering; +using AcDream.Core.World; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Streaming; + +/// +/// Retained receipt for the DAT-static presentation stages of one landblock +/// publication. The physics publisher calls the light/translucency stage at +/// the existing per-object point immediately before ordinary collision. +/// +public sealed class LandblockStaticPresentationPublication +{ + private readonly IReadOnlyDictionary _entities; + private readonly IReadOnlyDictionary _snapshots; + + internal LandblockStaticPresentationPublication( + object owner, + LandblockPhysicsPublication physicsPublication, + Dictionary entities, + Dictionary snapshots, + HashSet previouslyActiveIds) + { + Owner = owner; + PhysicsPublication = physicsPublication; + _entities = entities; + _snapshots = snapshots; + PreviouslyActiveIds = previouslyActiveIds; + } + + internal object Owner { get; } + internal LandblockPhysicsPublication PhysicsPublication { get; } + internal HashSet PreviouslyActiveIds { get; } + internal int PriorCleanupCursor { get; set; } + internal int PluginCursor { get; set; } + internal bool BeginCommitted { get; set; } + internal bool CompletionCommitted { get; set; } + + public uint LandblockId => PhysicsPublication.LandblockId; + public IReadOnlyDictionary Entities => _entities; + public IReadOnlyDictionary Snapshots => _snapshots; +} + +public readonly record struct LandblockStaticPresentationDiagnostics( + long BeginCount, + long CompleteCount, + long LightReplacementCount, + long PluginSpawnCount, + long PluginRefreshCount, + long PluginRemovalCount, + int ActiveLandblockCount, + int ActiveEntityCount); + +/// +/// Update-thread owner of DAT-static lights, translucency lifetime, and plugin +/// projection. Setup defaults remain activated exactly once at the existing +/// post-spatial render-pin boundary owned by . +/// +/// +/// Retail constructs each static CPhysicsObj from Setup defaults before +/// CObjCell::init_objects (0x0052B420) inserts/refloods it. The light +/// state follows CPhysicsObj::set_lights (0x0050FCF0); translucency +/// starts from the new object's Setup/material state before later FPHooks. +/// Plugin projection is an acdream adaptation and therefore emits at most one +/// logical spawn per retained static ID, while reapply only refreshes current +/// state. +/// +public sealed class LandblockStaticPresentationPublisher +{ + private readonly object _receiptOwner = new(); + private readonly LightingHookSink _lighting; + private readonly TranslucencyFadeManager _translucency; + private readonly WorldGameState _worldState; + private readonly WorldEvents _worldEvents; + private readonly Dictionary> + _activeByLandblock = new(); + private readonly Dictionary _landblockByEntityId = new(); + + private long _beginCount; + private long _completeCount; + private long _lightReplacementCount; + private long _pluginSpawnCount; + private long _pluginRefreshCount; + private long _pluginRemovalCount; + + public LandblockStaticPresentationPublisher( + LightingHookSink lighting, + TranslucencyFadeManager translucency, + WorldGameState worldState, + WorldEvents worldEvents) + { + _lighting = lighting ?? throw new ArgumentNullException(nameof(lighting)); + _translucency = translucency + ?? throw new ArgumentNullException(nameof(translucency)); + _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); + _worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents)); + } + + public LandblockStaticPresentationDiagnostics Diagnostics => new( + _beginCount, + _completeCount, + _lightReplacementCount, + _pluginSpawnCount, + _pluginRefreshCount, + _pluginRemovalCount, + _activeByLandblock.Count, + _landblockByEntityId.Count); + + public LandblockStaticPresentationPublication PreparePublication( + LandblockPhysicsPublication physicsPublication) + { + ArgumentNullException.ThrowIfNull(physicsPublication); + + uint canonical = Canonicalize(physicsPublication.LandblockId); + _activeByLandblock.TryGetValue( + canonical, + out Dictionary? active); + var entities = new Dictionary(); + var snapshots = new Dictionary(); + foreach (WorldEntity entity in physicsPublication.Build.Landblock.Entities) + { + if (entity.ServerGuid != 0) + continue; + if (!entities.TryAdd(entity.Id, entity)) + { + throw new InvalidOperationException( + $"Landblock 0x{canonical:X8} contains duplicate DAT-static ID " + + $"0x{entity.Id:X8}."); + } + if (_landblockByEntityId.TryGetValue(entity.Id, out uint owner) + && owner != canonical) + { + throw new InvalidOperationException( + $"DAT-static ID 0x{entity.Id:X8} is already owned by " + + $"landblock 0x{owner:X8}."); + } + if (active is not null + && active.TryGetValue(entity.Id, out WorldEntitySnapshot retained) + && retained.SourceId != entity.SourceGfxObjOrSetupId) + { + throw new InvalidOperationException( + $"Retained DAT-static ID 0x{entity.Id:X8} changed source " + + $"from 0x{retained.SourceId:X8} to " + + $"0x{entity.SourceGfxObjOrSetupId:X8}."); + } + + snapshots.Add(entity.Id, Snapshot(entity)); + } + + HashSet previous = active is not null + ? active.Keys.ToHashSet() + : new HashSet(); + return new LandblockStaticPresentationPublication( + _receiptOwner, + physicsPublication, + entities, + snapshots, + previous); + } + + /// + /// Retires omitted prior projections and detaches prior concrete lights + /// before replacement collision. Retained IDs keep their SetLight latch + /// and FPHook translucency state because rehydrate is not a logical create. + /// + public void BeginPublication(LandblockStaticPresentationPublication publication) + { + ValidateReceipt(publication); + if (publication.BeginCommitted) + return; + + uint[] priorIds = publication.PreviouslyActiveIds.Order().ToArray(); + while (publication.PriorCleanupCursor < priorIds.Length) + { + uint id = priorIds[publication.PriorCleanupCursor]; + bool retained = publication.Entities.ContainsKey(id); + _lighting.UnregisterOwner(id, forgetState: !retained); + if (!retained) + { + _translucency.ClearEntity(id); + _worldState.RemoveById(id); + _worldEvents.ForgetEntity(id); + _landblockByEntityId.Remove(id); + _pluginRemovalCount++; + } + publication.PriorCleanupCursor++; + } + + publication.BeginCommitted = true; + _beginCount++; + } + + /// + /// Exact replacement called by + /// immediately before registering this object's ordinary collision. + /// + public void PrepareEntityBeforeCollision( + LandblockStaticPresentationPublication publication, + WorldEntity entity) + { + ValidateReceipt(publication); + ArgumentNullException.ThrowIfNull(entity); + if (!publication.BeginCommitted) + throw new InvalidOperationException( + "Static presentation cannot prepare an entity before begin commits."); + if (entity.ServerGuid != 0) + return; + if (!publication.Entities.TryGetValue(entity.Id, out WorldEntity? expected) + || !ReferenceEquals(expected, entity)) + { + throw new InvalidOperationException( + $"DAT-static entity 0x{entity.Id:X8} does not belong to this publication."); + } + + bool retained = publication.PreviouslyActiveIds.Contains(entity.Id); + _lighting.UnregisterOwner(entity.Id, forgetState: !retained); + if (!retained) + _translucency.ClearEntity(entity.Id); + + PhysicsDatBundle datBundle = publication.PhysicsPublication.Build.Landblock.PhysicsDats + ?? PhysicsDatBundle.Empty; + uint sourceId = entity.SourceGfxObjOrSetupId; + if ((sourceId & 0xFF000000u) == 0x02000000u + && datBundle.Setups.TryGetValue(sourceId, out var setup)) + { + IReadOnlyList lights = LightInfoLoader.Load( + setup, + ownerId: entity.Id, + entityPosition: entity.Position, + entityRotation: entity.Rotation, + cellId: entity.ParentCellId ?? 0u); + for (int i = 0; i < lights.Count; i++) + _lighting.RegisterOwnedLight(lights[i]); + } + _lightReplacementCount++; + } + + /// + /// Publishes the exact current plugin snapshot after collision/reflood. + /// Existing IDs refresh replay state without emitting another logical + /// spawn; new IDs emit once. + /// + public void CompletePublication( + LandblockStaticPresentationPublication publication) + { + ValidateReceipt(publication); + if (!publication.BeginCommitted + || !publication.PhysicsPublication.CompletionCommitted) + { + throw new InvalidOperationException( + "Static presentation requires completed physics publication."); + } + if (publication.CompletionCommitted) + return; + + KeyValuePair[] snapshots = publication.Snapshots + .OrderBy(pair => pair.Key) + .ToArray(); + while (publication.PluginCursor < snapshots.Length) + { + (uint id, WorldEntitySnapshot snapshot) = snapshots[publication.PluginCursor]; + _worldState.Add(snapshot); + if (publication.PreviouslyActiveIds.Contains(id)) + { + _worldEvents.UpsertCurrent(snapshot); + _pluginRefreshCount++; + } + else + { + _worldEvents.FireEntitySpawned(snapshot); + _pluginSpawnCount++; + } + _landblockByEntityId[id] = Canonicalize(publication.LandblockId); + publication.PluginCursor++; + } + + uint canonical = Canonicalize(publication.LandblockId); + if (publication.Snapshots.Count == 0) + { + _activeByLandblock.Remove(canonical); + } + else + { + _activeByLandblock[canonical] = publication.Snapshots + .ToDictionary(pair => pair.Key, pair => pair.Value); + } + publication.CompletionCommitted = true; + _completeCount++; + } + + public void RemoveLighting(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + if (entity.ServerGuid == 0) + _lighting.UnregisterOwner(entity.Id); + } + + public void RemoveTranslucency(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + if (entity.ServerGuid == 0) + _translucency.ClearEntity(entity.Id); + } + + public void RemovePluginProjection(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + if (entity.ServerGuid != 0) + return; + + _worldState.RemoveById(entity.Id); + _worldEvents.ForgetEntity(entity.Id); + if (_landblockByEntityId.Remove(entity.Id, out uint landblockId) + && _activeByLandblock.TryGetValue( + landblockId, + out Dictionary? active)) + { + active.Remove(entity.Id); + if (active.Count == 0) + _activeByLandblock.Remove(landblockId); + } + _pluginRemovalCount++; + } + + private void ValidateReceipt(LandblockStaticPresentationPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (!ReferenceEquals(publication.Owner, _receiptOwner)) + { + throw new ArgumentException( + "The static presentation receipt belongs to another publisher.", + nameof(publication)); + } + } + + private static WorldEntitySnapshot Snapshot(WorldEntity entity) => new( + Id: entity.Id, + SourceId: entity.SourceGfxObjOrSetupId, + Position: entity.Position, + Rotation: entity.Rotation); + + private static uint Canonicalize(uint landblockId) => + (landblockId & 0xFFFF0000u) | 0xFFFFu; +} diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 9805d96f..7edfbf09 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -174,21 +174,23 @@ public sealed class StreamingController Action? clearPendingLoads = null, Action? onLandblockLoaded = null, Action? ensureEnvCellMeshes = null, - LandblockRetirementCoordinator? retirementCoordinator = null) + LandblockRetirementCoordinator? retirementCoordinator = null, + LandblockPresentationPipeline? presentationPipeline = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; _clearPendingLoads = clearPendingLoads; _state = state; - _presentation = new LandblockPresentationPipeline( - applyTerrain, - state, - onLandblockLoaded, - ensureEnvCellMeshes, - retirementCoordinator, - removeTerrain, - demoteNearLayer); + _presentation = presentationPipeline + ?? new LandblockPresentationPipeline( + applyTerrain, + state, + onLandblockLoaded, + ensureEnvCellMeshes, + retirementCoordinator, + removeTerrain, + demoteNearLayer); NearRadius = nearRadius; FarRadius = farRadius; } diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs index 7d3954ea..df37c5e7 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -64,6 +64,42 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests Assert.Equal(2, posePublishes); } + [Fact] + public void Rebind_RetainsSequencerAndPublishesIntoReplacementEntity() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => { }, + (_, _, _) => { }); + Setup setup = MakeSetup(); + WorldEntity first = MakeEntity(); + ScriptActivationInfo info = new( + ScriptId: 0, + PartTransforms: first.IndexedPartTransforms, + PartAvailability: first.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true); + scheduler.Register(first, info); + scheduler.Tick(1f / 60f); + float phaseBeforeRebind = first.MeshRefs[0].PartTransform.Translation.X; + + WorldEntity replacement = MakeEntity(); + replacement.Position = new Vector3(4f, 5f, 6f); + scheduler.Rebind(replacement, info with + { + PartTransforms = replacement.IndexedPartTransforms, + PartAvailability = replacement.IndexedPartAvailable, + }); + scheduler.Tick(1f / 60f); + + Assert.Equal(1, scheduler.Count); + Assert.True( + replacement.MeshRefs[0].PartTransform.Translation.X > phaseBeforeRebind); + } + [Fact] public void ShortStaticAnimFrame_RetainsCompleteTrailingPartState() { diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs index 0965dd1b..65c76a20 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -282,6 +282,7 @@ public sealed class EntityScriptActivatorTests registerCount++; registeredEntity = entity; Assert.Same(info, actual); + return true; }, unregisterDatStaticAnimationOwner: ownerId => { @@ -313,7 +314,11 @@ public sealed class EntityScriptActivatorTests ScriptId: 0, PartTransforms: Array.Empty(), DefaultAnimationId: 0x03000001u), - registerDatStaticAnimationOwner: (_, _) => registerCount++); + registerDatStaticAnimationOwner: (_, _) => + { + registerCount++; + return true; + }); activator.OnCreate(MakeEntity(0x70000420u, Vector3.Zero)); @@ -335,7 +340,11 @@ public sealed class EntityScriptActivatorTests PartTransforms: Array.Empty(), DefaultAnimationId: 0x03000001u, UsesStaticAnimationWorkset: true), - registerDatStaticAnimationOwner: (_, _) => registerCount++, + registerDatStaticAnimationOwner: (_, _) => + { + registerCount++; + return true; + }, unregisterDatStaticAnimationOwner: _ => unregisterCount++); WorldEntity entity = MakeEntity(0x70000421u, Vector3.Zero); @@ -378,6 +387,7 @@ public sealed class EntityScriptActivatorTests animationAttempts++; if (animationAttempts == 1) throw new InvalidOperationException("animation acquire fault"); + return true; }); Assert.Throws(() => activator.OnCreate(entity)); @@ -424,7 +434,7 @@ public sealed class EntityScriptActivatorTests if (effectReleaseAttempts == 1) throw new InvalidOperationException("effect release fault"); }, - registerDatStaticAnimationOwner: (_, _) => { }, + registerDatStaticAnimationOwner: (_, _) => true, unregisterDatStaticAnimationOwner: _ => { animationReleaseAttempts++; @@ -465,6 +475,97 @@ public sealed class EntityScriptActivatorTests Assert.Equal(first.Id, GameWindow.ParticleEntityKey(first)); } + [Fact] + public void DatStaticRebind_RetriesOnlyUnfinishedOwnersAndDoesNotReplayDefault() + { + var p = BuildPipeline( + (0xAAu, BuildScript((1.0, new CreateParticleHook { EmitterInfoId = 100 })))); + var setup = new DatReaderWriter.DBObjs.Setup(); + ScriptActivationInfo info = new( + ScriptId: 0xAAu, + PartTransforms: Array.Empty(), + EffectProfile: EntityEffectProfile.CreateDatStatic(setup), + Setup: setup, + DefaultAnimationId: 0x03000001u, + UsesStaticAnimationWorkset: true); + int effectUpdates = 0; + int animationRegistrations = 0; + int animationRebindAttempts = 0; + var activator = new EntityScriptActivator( + p.Runner, + p.Sink, + p.Poses, + _ => info, + registerDatStaticEffectOwner: (_, _, _) => effectUpdates++, + registerDatStaticAnimationOwner: (_, _) => + { + animationRegistrations++; + return true; + }, + rebindDatStaticAnimationOwner: (_, _) => + { + animationRebindAttempts++; + if (animationRebindAttempts == 1) + throw new InvalidOperationException("animation rebind fault"); + }); + WorldEntity first = MakeDatStatic(0x80A9B400u, Vector3.One); + WorldEntity replacement = MakeDatStatic( + first.Id, + new Vector3(7f, 8f, 9f)); + activator.OnCreate(first); + + Assert.Throws(() => + activator.OnRebindStatic(first, replacement)); + activator.OnRebindStatic(first, replacement); + activator.OnCreate(replacement); + + Assert.Equal(1, p.Runner.ActiveOwnerCount); + Assert.Equal(2, effectUpdates); + Assert.Equal(1, animationRegistrations); + Assert.Equal(2, animationRebindAttempts); + Assert.True(p.Poses.TryGetRootPose(first.Id, out Matrix4x4 root)); + Assert.Equal(replacement.Position, root.Translation); + p.Runner.Tick(1.1); + Assert.Single(p.Recording.Calls); + Assert.Equal(replacement.Position, p.Recording.Calls[0].Pos); + } + + [Fact] + public void DatStaticRebind_MissingDefaultAnimationNeverClaimsWorksetOwner() + { + var p = BuildPipeline(); + var setup = new DatReaderWriter.DBObjs.Setup(); + ScriptActivationInfo info = new( + ScriptId: 0, + PartTransforms: Array.Empty(), + Setup: setup, + DefaultAnimationId: 0x03000001u, + UsesStaticAnimationWorkset: true); + int registerAttempts = 0; + int rebindAttempts = 0; + var activator = new EntityScriptActivator( + p.Runner, + p.Sink, + p.Poses, + _ => info, + registerDatStaticAnimationOwner: (_, _) => + { + registerAttempts++; + return false; + }, + rebindDatStaticAnimationOwner: (_, _) => rebindAttempts++); + WorldEntity first = MakeDatStatic(0x80A9B400u, Vector3.Zero); + WorldEntity replacement = MakeDatStatic(first.Id, Vector3.One); + + activator.OnCreate(first); + activator.OnRebindStatic(first, replacement); + activator.OnCreate(replacement); + + Assert.Equal(2, registerAttempts); + Assert.Equal(0, rebindAttempts); + Assert.True(p.Poses.TryGetRootPose(first.Id, out _)); + } + [Fact] public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid() { diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs index a210a5f6..a265ed2a 100644 --- a/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateActivatorTests.cs @@ -35,6 +35,7 @@ public sealed class GpuWorldStateActivatorTests GpuWorldState State, PhysicsScriptRunner Runner, ParticleHookSink Sink, + EntityEffectPoseRegistry Poses, RecordingSink Recording); private static Pipeline BuildPipeline(uint scriptId) @@ -57,7 +58,7 @@ public sealed class GpuWorldStateActivatorTests _ => new ScriptActivationInfo(scriptId, Array.Empty())); var state = new GpuWorldState(entityScriptActivator: activator); - return new Pipeline(state, runner, sink, recording); + return new Pipeline(state, runner, sink, poses, recording); } private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities) @@ -135,6 +136,45 @@ public sealed class GpuWorldStateActivatorTests Assert.Equal(0, p.Runner.ActiveScriptCount); } + [Fact] + public void SameIdLandblockRehydrate_RebindsOwnerWithoutReplayingDefaultScript() + { + var p = BuildPipeline(scriptId: 0xAAu); + WorldEntity first = DatHydrated( + id: 0x40A9B401u, + pos: new Vector3(1f, 2f, 3f)); + WorldEntity replacement = DatHydrated( + id: first.Id, + pos: new Vector3(8f, 9f, 10f)); + + p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, first)); + Assert.Equal(1, p.Runner.ActiveScriptCount); + + p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, replacement)); + + Assert.Equal(1, p.Runner.ActiveScriptCount); + Assert.True(p.Poses.TryGetRootPose(first.Id, out Matrix4x4 root)); + Assert.Equal(replacement.Position, root.Translation); + p.Runner.Tick(0.001f); + Assert.Single(p.Recording.Calls); + Assert.Equal(replacement.Position, p.Recording.Calls[0].Pos); + } + + [Fact] + public void SameLandblockRehydrate_OmittedStaticRetiresScriptAndPose() + { + var p = BuildPipeline(scriptId: 0xAAu); + WorldEntity entity = DatHydrated(0x40A9B401u, Vector3.Zero); + p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, entity)); + Assert.Equal(1, p.Runner.ActiveScriptCount); + Assert.True(p.Poses.TryGetRootPose(entity.Id, out _)); + + p.State.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); + + Assert.Equal(0, p.Runner.ActiveScriptCount); + Assert.False(p.Poses.TryGetRootPose(entity.Id, out _)); + } + [Fact] public void AddEntitiesToExistingLandblock_FiresActivatorForEachPromoted() { diff --git a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs new file mode 100644 index 00000000..e4d84e69 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs @@ -0,0 +1,380 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.Core.Lighting; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; +using AcDream.Core.Rendering; +using AcDream.Core.Terrain; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; +using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockConcretePresentationPipelineTests +{ + private const uint LandblockId = 0xA9B4FFFFu; + + [Fact] + public void Loaded_ConcreteOwnersPreserveRetailPrefixAndSpatialOrder() + { + var calls = new List(); + ConcreteFixture fixture = Fixture( + calls, + commitEnvCells: _ => calls.Add("envcell")); + fixture.Events.EntitySpawned += _ => + { + Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount); + Assert.False(fixture.State.IsLoaded(LandblockId)); + calls.Add("plugin"); + }; + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + onLandblockLoaded: _ => calls.Add("live-recovery"), + retirementCoordinator: fixture.Retirements); + LandblockBuild build = Build(Entity(0x80A9B401u)); + + pipeline.PublishLoaded(Result(build)); + + Assert.Equal( + ["terrain", "envcell", "plugin", "pin", "live-recovery"], + calls); + Assert.True(fixture.State.IsNearTier(LandblockId)); + Assert.Equal(1, fixture.Render.Diagnostics.BeginCount); + Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount); + Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount); + } + + [Fact] + public void RenderSuffixFailure_ResumesConcreteReceiptsWithoutReplayingPrefixes() + { + var calls = new List(); + bool failEnvOnce = true; + ConcreteFixture fixture = Fixture( + calls, + commitEnvCells: _ => + { + calls.Add("envcell"); + if (failEnvOnce) + { + failEnvOnce = false; + throw new InvalidOperationException("injected envcell failure"); + } + }); + fixture.Events.EntitySpawned += _ => calls.Add("plugin"); + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + retirementCoordinator: fixture.Retirements); + LandblockBuild build = Build(Entity(0x80A9B401u)); + LandblockStreamResult.Loaded result = Result(build); + + Assert.Throws(() => pipeline.PublishLoaded(result)); + Assert.False(fixture.State.IsLoaded(LandblockId)); + Assert.True(pipeline.HasPendingPublication(result)); + Assert.Equal(1, fixture.Render.Diagnostics.BeginCount); + Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount); + Assert.Equal(0, fixture.Physics.Diagnostics.CompleteCount); + Assert.Equal(0, fixture.Static.Diagnostics.BeginCount); + + pipeline.ResumePublication(result); + + Assert.Equal(1, calls.Count(call => call == "terrain")); + Assert.Equal(2, calls.Count(call => call == "envcell")); + Assert.Equal(1, calls.Count(call => call == "plugin")); + Assert.Equal(1, calls.Count(call => call == "pin")); + Assert.Equal(1, fixture.Render.Diagnostics.BeginCount); + Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount); + Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount); + Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount); + Assert.False(pipeline.HasPendingPublication(result)); + Assert.True(fixture.State.IsLoaded(LandblockId)); + } + + [Fact] + public void CrossOwnerValidationFailure_BlocksBeforeAnyPresentationMutation() + { + var calls = new List(); + ConcreteFixture fixture = Fixture(calls); + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + retirementCoordinator: fixture.Retirements); + WorldEntity first = Entity(0x80A9B401u); + LandblockBuild build = Build(first, Entity(first.Id)); + LandblockStreamResult.Loaded result = Result(build); + + Assert.Throws(() => pipeline.PublishLoaded(result)); + + Assert.Empty(calls); + Assert.False(fixture.State.IsLoaded(LandblockId)); + Assert.Equal(0, fixture.Engine.LandblockCount); + Assert.Equal(0, fixture.Render.Diagnostics.BeginCount); + Assert.Equal(0, fixture.Physics.Diagnostics.BeginCount); + Assert.Equal(0, fixture.Static.Diagnostics.BeginCount); + Assert.True(pipeline.HasPendingPublication(result)); + Assert.Equal(1, pipeline.PendingPublicationCount); + } + + [Fact] + public void RetainedIdSourceChange_BlocksBeforeReplacementMutation() + { + var calls = new List(); + ConcreteFixture fixture = Fixture(calls); + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + retirementCoordinator: fixture.Retirements); + WorldEntity first = Entity(0x80A9B401u); + pipeline.PublishLoaded(Result(Build(first))); + long renderBegins = fixture.Render.Diagnostics.BeginCount; + long physicsBegins = fixture.Physics.Diagnostics.BeginCount; + long staticBegins = fixture.Static.Diagnostics.BeginCount; + calls.Clear(); + LandblockStreamResult.Loaded changed = Result(Build( + Entity(first.Id, sourceId: 0x01000002u))); + + Assert.Throws(() => + pipeline.PublishLoaded(changed)); + + Assert.Empty(calls); + Assert.Equal(renderBegins, fixture.Render.Diagnostics.BeginCount); + Assert.Equal(physicsBegins, fixture.Physics.Diagnostics.BeginCount); + Assert.Equal(staticBegins, fixture.Static.Diagnostics.BeginCount); + Assert.True(pipeline.HasPendingPublication(changed)); + } + + [Fact] + public void ConcreteConstructor_RequiresSharedRetirementCoordinator() + { + ConcreteFixture fixture = Fixture(new List()); + + Assert.Throws(() => + new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + retirementCoordinator: null)); + } + + [Fact] + public void SameIdReapply_RealActivatorRebindsWithoutDefaultReplay() + { + ConcreteFixture fixture = Fixture(new List()); + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + retirementCoordinator: fixture.Retirements); + WorldEntity first = Entity(0x80A9B401u); + WorldEntity replacement = Entity(first.Id); + + pipeline.PublishLoaded(Result(Build(first))); + Assert.Equal(1, fixture.Runner.ActiveOwnerCount); + + pipeline.PublishLoaded(Result(Build(replacement))); + + Assert.Equal(1, fixture.Runner.ActiveOwnerCount); + Assert.True(fixture.Poses.TryGetRootPose(first.Id, out _)); + } + + [Fact] + public void OmittedStaticReapply_RealActivatorBalancesScriptAndPoseOwners() + { + ConcreteFixture fixture = Fixture(new List()); + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + retirementCoordinator: fixture.Retirements); + WorldEntity entity = Entity(0x80A9B401u); + pipeline.PublishLoaded(Result(Build(entity))); + Assert.Equal(1, fixture.Runner.ActiveOwnerCount); + Assert.True(fixture.Poses.TryGetRootPose(entity.Id, out _)); + + pipeline.PublishLoaded(Result(Build())); + + Assert.Equal(0, fixture.Runner.ActiveOwnerCount); + Assert.False(fixture.Poses.TryGetRootPose(entity.Id, out _)); + } + + private static ConcreteFixture Fixture( + List calls, + Action? commitEnvCells = null) + { + var poses = new EntityEffectPoseRegistry(); + var particleSink = new ParticleHookSink( + new ParticleSystem(new EmitterDescRegistry()), + poses); + var script = new DatPhysicsScript(); + script.ScriptData.Add(new PhysicsScriptData + { + StartTime = 10.0, + Hook = new CreateParticleHook { EmitterInfoId = 100u }, + }); + var runner = new PhysicsScriptRunner( + id => id == 0xAAu ? script : null, + new NullHookSink()); + var activator = new EntityScriptActivator( + runner, + particleSink, + poses, + _ => new ScriptActivationInfo(0xAAu, Array.Empty())); + var state = new GpuWorldState( + new LandblockSpawnAdapter(new RecordingMeshAdapter(calls)), + entityScriptActivator: activator); + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + var render = new LandblockRenderPublisher( + (_, _, _) => calls.Add("terrain"), + static _ => { }, + new CellVisibility(), + state, + commitEnvCells: commitEnvCells); + var physics = new LandblockPhysicsPublisher(engine, new float[256]); + var lighting = new LightingHookSink( + new LightManager(), + new NullPoseSource()); + var events = new WorldEvents(); + var retirements = new LandblockRetirementCoordinator( + state, + static _ => { }, + static _ => LandblockRetirementStage.None); + return new ConcreteFixture( + state, + engine, + render, + physics, + new LandblockStaticPresentationPublisher( + lighting, + new TranslucencyFadeManager(), + new WorldGameState(), + events), + events, + retirements, + runner, + poses); + } + + private static LandblockStreamResult.Loaded Result(LandblockBuild build) => + new( + LandblockId, + LandblockStreamTier.Near, + build, + EmptyMesh()); + + private static LandblockBuild Build(params WorldEntity[] entities) + { + uint cellId = (LandblockId & 0xFFFF0000u) | 0x0100u; + var shell = new EnvCellShellPlacement( + cellId, + 0x2_0000_0001UL, + 0x0D000001u, + 1, + ImmutableArray.Empty, + Vector3.Zero, + Quaternion.Identity, + Matrix4x4.Identity, + new WbBoundingBox(Vector3.Zero, Vector3.One), + new WbBoundingBox(Vector3.Zero, Vector3.One)); + var envCells = new EnvCellLandblockBuild( + LandblockId, + Array.Empty(), + [shell]); + return new LandblockBuild( + new LoadedLandblock( + LandblockId, + new LandBlock + { + Terrain = new TerrainInfo[81], + Height = new byte[81], + }, + entities, + PhysicsDatBundle.Empty), + envCells, + new LandblockBuildOrigin(0xA9, 0xB4)); + } + + private static WorldEntity Entity( + uint id, + uint sourceId = 0x01000001u) => new() + { + Id = id, + SourceGfxObjOrSetupId = sourceId, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static LandblockMeshData EmptyMesh() => new( + Array.Empty(), + Array.Empty()); + + private sealed record ConcreteFixture( + GpuWorldState State, + PhysicsEngine Engine, + LandblockRenderPublisher Render, + LandblockPhysicsPublisher Physics, + LandblockStaticPresentationPublisher Static, + WorldEvents Events, + LandblockRetirementCoordinator Retirements, + PhysicsScriptRunner Runner, + EntityEffectPoseRegistry Poses); + + private sealed class NullHookSink : IAnimationHookSink + { + public void OnHook(uint entityId, Vector3 worldPosition, AnimationHook hook) + { + } + } + + private sealed class RecordingMeshAdapter(List calls) : IWbMeshAdapter + { + public void IncrementRefCount(ulong id) + { + } + + public void PinPreparedRenderData(ulong id) => calls.Add("pin"); + + public void DecrementRefCount(ulong id) + { + } + } + + private sealed class NullPoseSource : IEntityEffectPoseSource + { + public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld) + { + rootWorld = default; + return false; + } + + public bool TryGetPartPose( + uint localEntityId, + int partIndex, + out Matrix4x4 partLocal) + { + partLocal = default; + return false; + } + } +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index b82db487..6e7b5101 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -486,7 +486,8 @@ public sealed class LandblockPhysicsPublisherTests MethodInfo begin = Assert.Single( type.GetMethods(), - method => method.Name == nameof(LandblockPhysicsPublisher.BeginPublication)); + method => method.Name == nameof(LandblockPhysicsPublisher.BeginPublication) + && method.ReturnType == typeof(LandblockPhysicsPublication)); ParameterInfo parameter = Assert.Single(begin.GetParameters()); Assert.Equal(typeof(LandblockRenderPublication), parameter.ParameterType); Assert.DoesNotContain(type.GetConstructors().SelectMany(constructor => diff --git a/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs new file mode 100644 index 00000000..bc9e1abe --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LandblockStaticPresentationPublisherTests.cs @@ -0,0 +1,309 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.Core.Lighting; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; +using AcDream.Core.Rendering; +using AcDream.Core.Terrain; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LandblockStaticPresentationPublisherTests +{ + private const uint LandblockId = 0xA9B4FFFFu; + private const uint AdjacentLandblockId = 0xAAB4FFFFu; + private const uint SetupId = 0x02000042u; + private static readonly float[] HeightTable = new float[256]; + + [Fact] + public void Reapply_ReplacesLightsAndRefreshesPluginWithoutSpawnReplay() + { + var fixture = Fixture(); + int spawnCount = 0; + fixture.Events.EntitySpawned += _ => spawnCount++; + WorldEntity first = Entity(0x80A9B401u, new Vector3(12f, 12f, 0f)); + WorldEntity moved = Entity(first.Id, new Vector3(36f, 12f, 0f)); + + Publish(fixture, Build(LandblockId, [first], BundleWithLight())); + Assert.Equal(1, fixture.Lights.RegisteredCount); + Assert.Equal(1, spawnCount); + Assert.Equal(first.Position, Assert.Single(fixture.World.Entities).Position); + + fixture.Lighting.SetOwnerLighting(first.Id, enabled: false); + fixture.Translucency.StartPartFade(first.Id, 0u, 0f, 0.6f, 0f); + fixture.Translucency.StartPartFade(first.Id, 1u, 0f, 1f, 2f); + fixture.Translucency.AdvanceAll(0.5f); + Assert.True(fixture.Translucency.TryGetCurrentValue( + first.Id, + 1u, + out float activeFadeBefore)); + Publish(fixture, Build(LandblockId, [moved], BundleWithLight())); + + Assert.Equal(1, fixture.Lights.RegisteredCount); + Assert.Equal(1, spawnCount); + Assert.Equal(moved.Position, Assert.Single(fixture.World.Entities).Position); + Assert.False(Assert.Single(fixture.Lighting.GetOwnedLights(first.Id)!).IsLit); + Assert.Equal(1, fixture.Lighting.RetainedOwnerStateCount); + Assert.True(fixture.Translucency.TryGetCurrentValue( + first.Id, + 0u, + out float settled)); + Assert.Equal(0.6f, settled); + Assert.True(fixture.Translucency.TryGetCurrentValue( + first.Id, + 1u, + out float activeFadeAfter)); + Assert.Equal(activeFadeBefore, activeFadeAfter); + Assert.Equal(1, fixture.Publisher.Diagnostics.PluginSpawnCount); + Assert.Equal(1, fixture.Publisher.Diagnostics.PluginRefreshCount); + Assert.Equal(2, fixture.Publisher.Diagnostics.LightReplacementCount); + } + + [Fact] + public void Reapply_OmittedStaticRemovesLightTranslucencyAndPluginProjection() + { + var fixture = Fixture(); + WorldEntity entity = Entity(0x80A9B401u, new Vector3(12f, 12f, 0f)); + Publish(fixture, Build(LandblockId, [entity], BundleWithLight())); + fixture.Lighting.SetOwnerLighting(entity.Id, enabled: false); + fixture.Translucency.StartPartFade(entity.Id, 0u, 0f, 1f, 2f); + + Publish( + fixture, + Build(LandblockId, Array.Empty(), PhysicsDatBundle.Empty)); + + Assert.Equal(0, fixture.Lights.RegisteredCount); + Assert.Equal(0, fixture.Lighting.RetainedOwnerStateCount); + Assert.False(fixture.Translucency.TryGetCurrentValue(entity.Id, 0u, out _)); + Assert.Empty(fixture.World.Entities); + Assert.Equal(1, fixture.Publisher.Diagnostics.PluginRemovalCount); + Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveLandblockCount); + Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveEntityCount); + } + + [Fact] + public void RetirementThenReload_EmitsNewLogicalPluginSpawn() + { + var fixture = Fixture(); + int spawnCount = 0; + fixture.Events.EntitySpawned += _ => spawnCount++; + WorldEntity entity = Entity(0x80A9B401u, new Vector3(12f, 12f, 0f)); + Publish(fixture, Build(LandblockId, [entity], BundleWithLight())); + + fixture.Publisher.RemoveLighting(entity); + fixture.Publisher.RemoveTranslucency(entity); + fixture.Publisher.RemovePluginProjection(entity); + Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveEntityCount); + + Publish(fixture, Build(LandblockId, [entity], BundleWithLight())); + + Assert.Equal(2, spawnCount); + Assert.Equal(1, fixture.Lights.RegisteredCount); + Assert.Equal(1, fixture.Publisher.Diagnostics.ActiveEntityCount); + } + + [Fact] + public void Prepare_RejectsDuplicateAndCrossLandblockStaticIdsBeforeMutation() + { + var fixture = Fixture(); + WorldEntity first = Entity(0x80A9B401u, Vector3.Zero); + LandblockPhysicsPublication duplicatePhysics = PhysicsPrefix( + fixture, + Build(LandblockId, [first, Entity(first.Id, Vector3.One)])); + + Assert.Throws(() => + fixture.Publisher.PreparePublication(duplicatePhysics)); + Assert.Empty(fixture.World.Entities); + Assert.Equal(0, fixture.Lights.RegisteredCount); + + Publish(fixture, Build(LandblockId, [first])); + LandblockPhysicsPublication adjacentPhysics = PhysicsPrefix( + fixture, + Build(AdjacentLandblockId, [Entity(first.Id, new Vector3(192f, 0f, 0f))])); + Assert.Throws(() => + fixture.Publisher.PreparePublication(adjacentPhysics)); + Assert.Single(fixture.World.Entities); + } + + [Fact] + public void LiveProjectionInBuild_IsIgnoredByDatStaticPresentation() + { + var fixture = Fixture(); + WorldEntity live = Entity(7u, Vector3.Zero, serverGuid: 0x80000007u); + + Publish(fixture, Build(LandblockId, [live], BundleWithLight())); + + Assert.Empty(fixture.World.Entities); + Assert.Equal(0, fixture.Lights.RegisteredCount); + Assert.Equal(0, fixture.Publisher.Diagnostics.ActiveEntityCount); + } + + [Fact] + public void ForeignReceipt_IsRejectedWithoutChangingOwnerState() + { + var first = Fixture(); + var second = Fixture(); + LandblockPhysicsPublication physics = PhysicsPrefix( + first, + Build(LandblockId, [Entity(1u, Vector3.Zero)])); + LandblockStaticPresentationPublication receipt = + first.Publisher.PreparePublication(physics); + + Assert.Throws(() => + second.Publisher.BeginPublication(receipt)); + Assert.Equal(0, second.Publisher.Diagnostics.BeginCount); + } + + private static void Publish(FixtureState fixture, LandblockBuild build) + { + LandblockPhysicsPublication physics = PhysicsPrefix(fixture, build); + fixture.Render.CompletePublication(fixture.LastRender!); + LandblockStaticPresentationPublication presentation = + fixture.Publisher.PreparePublication(physics); + fixture.Publisher.BeginPublication(presentation); + fixture.Physics.CompletePublication( + physics, + entity => fixture.Publisher.PrepareEntityBeforeCollision( + presentation, + entity)); + fixture.Publisher.CompletePublication(presentation); + } + + private static LandblockPhysicsPublication PhysicsPrefix( + FixtureState fixture, + LandblockBuild build) + { + LandblockRenderPublication render = fixture.Render.BeginPublication( + build, + EmptyMesh()); + fixture.LastRender = render; + LandblockPhysicsPublication physics = + fixture.Physics.PreparePublication(render); + fixture.Physics.BeginPublication(physics); + return physics; + } + + private static FixtureState Fixture() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + var gpuState = new GpuWorldState(); + var render = new LandblockRenderPublisher( + static (_, _, _) => { }, + static _ => { }, + new CellVisibility(), + gpuState); + var physics = new LandblockPhysicsPublisher(engine, HeightTable); + var lights = new LightManager(); + var lighting = new LightingHookSink(lights, new NullPoseSource()); + var translucency = new TranslucencyFadeManager(); + var world = new WorldGameState(); + var events = new WorldEvents(); + return new FixtureState( + render, + physics, + new LandblockStaticPresentationPublisher( + lighting, + translucency, + world, + events), + lights, + lighting, + translucency, + world, + events); + } + + private static PhysicsDatBundle BundleWithLight() + { + var setup = new Setup(); + setup.Lights[0] = new LightInfo + { + ViewSpaceLocation = new Frame { Orientation = Quaternion.Identity }, + Color = new ColorARGB + { + Red = 255, + Green = 255, + Blue = 255, + Alpha = 255, + }, + Intensity = 1f, + Falloff = 8f, + }; + return new PhysicsDatBundle( + new LandBlockInfo(), + new Dictionary(), + new Dictionary(), + new Dictionary { [SetupId] = setup }, + new Dictionary()); + } + + private static LandblockBuild Build( + uint landblockId, + IReadOnlyList entities, + PhysicsDatBundle? bundle = null) => new( + new LoadedLandblock( + landblockId, + new LandBlock + { + Terrain = new TerrainInfo[81], + Height = new byte[81], + }, + entities, + bundle ?? PhysicsDatBundle.Empty), + Origin: new LandblockBuildOrigin(0xA9, 0xB4)); + + private static WorldEntity Entity( + uint id, + Vector3 position, + uint serverGuid = 0u) => new() + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = SetupId, + Position = position, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static LandblockMeshData EmptyMesh() => new( + Array.Empty(), + Array.Empty()); + + private sealed record FixtureState( + LandblockRenderPublisher Render, + LandblockPhysicsPublisher Physics, + LandblockStaticPresentationPublisher Publisher, + LightManager Lights, + LightingHookSink Lighting, + TranslucencyFadeManager Translucency, + WorldGameState World, + WorldEvents Events) + { + public LandblockRenderPublication? LastRender { get; set; } + } + + private sealed class NullPoseSource : IEntityEffectPoseSource + { + public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld) + { + rootWorld = default; + return false; + } + + public bool TryGetPartPose( + uint localEntityId, + int partIndex, + out Matrix4x4 partLocal) + { + partLocal = default; + return false; + } + } +} From dc39772bf9b8d32a8d73fc0e796e04be5e5cf483 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 21:46:02 +0200 Subject: [PATCH 65/98] docs(architecture): close landblock presentation checkpoint E Record the retained static-resource lifetime, exact receipt retry boundary, clean review cycle, and 7,026-pass Release gate before retirement ownership moves into the pipeline. Co-authored-by: Codex --- ...21-gamewindow-slice-5-landblock-presentation.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md index ed20ff07..fff3df07 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md +++ b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md @@ -66,8 +66,20 @@ test, and bisectable commit rather than being hidden in a mechanical move. footprints cross the seam. Three corrected-diff reviews are clean. Release gate: 7,008 passed / 5 skipped; zero-warning build; `GameWindow.cs` is 9,043 lines. -- [ ] E — extract static lighting, translucency, default-script, and plugin +- [x] E — extract static lighting, translucency, default-script, and plugin presentation; compose the small `LandblockPresentationPipeline`. + - Landed in `ea0da8c8`: render, physics, and DAT-static publishers now + preflight immutable receipts before mutation and retain exact per-stage + progress through retry. `LandblockStaticPresentationPublisher` owns static + lights, translucency lifetime, and plugin projection; `GpuWorldState` and + `EntityScriptActivator` explicitly rebind same-ID static snapshots without + replaying Setup defaults or resetting animation/light/translucency state, + while omitted IDs take full teardown. Source changes and duplicate IDs fail + before presentation mutation, missing DefaultAnimation never claims an + owner, and fallible reconciliation precedes pending-map consumption. Three + corrected-diff reviews are clean. Release gate: 7,026 passed / 5 skipped; + `GameWindow.cs` is temporarily 9,073 lines because production wiring lands + before checkpoint G deletes the old callback body. - [ ] F — move retryable demotion/full-retirement presentation under the pipeline without changing `LandblockRetirementCoordinator`'s ledger. - [ ] G — cut `StreamingController` and `GameWindow` directly to the pipeline; From ea7ffbc1861fe6f59628b85e21404329d09216a6 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 22:07:36 +0200 Subject: [PATCH 66/98] refactor(streaming): own presentation retirement in pipeline Move full and near-tier presentation retirement out of GameWindow and into a concrete pipeline-owned stage mapper. Harden the retry ledger against reentrancy and committed visibility observer failures, enforce a single state/resource ownership graph, and split concrete versus legacy controller construction so callbacks cannot be silently ignored. Co-authored-by: Codex --- src/AcDream.App/Rendering/GameWindow.cs | 88 +----- .../LandblockPresentationPipeline.cs | 39 ++- .../LandblockPresentationRetirementOwner.cs | 119 ++++++++ .../Streaming/LandblockRenderPublisher.cs | 3 + .../LandblockRetirementCoordinator.cs | 157 ++++++++-- .../LandblockStaticPresentationPublisher.cs | 6 + .../Streaming/StreamingController.cs | 52 +++- ...dblockConcretePresentationPipelineTests.cs | 267 ++++++++++++++++-- .../LandblockPresentationPipelineTests.cs | 57 ++++ .../LandblockRetirementCoordinatorTests.cs | 212 +++++++++++++- 10 files changed, 860 insertions(+), 140 deletions(-) create mode 100644 src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c4f3c771..2ef8d4c9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -150,7 +150,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.GpuWorldState _worldState = new(); - private AcDream.App.Streaming.LandblockRetirementCoordinator? _landblockRetirements; private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher; private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher; private AcDream.App.Streaming.LandblockStaticPresentationPublisher? @@ -2208,11 +2207,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext wbSpawnAdapter, onLandblockUnloaded: _classificationCache.InvalidateLandblock, entityScriptActivator: entityScriptActivator); - _landblockRetirements = - new AcDream.App.Streaming.LandblockRetirementCoordinator( - _worldState, - AdvanceLandblockPresentationRetirement); - _liveEntities = new AcDream.App.World.LiveEntityRuntime( _worldState, new AcDream.App.World.CompositeLiveEntityResourceLifecycle( @@ -2529,17 +2523,24 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext }); _streamer.Start(); + var landblockRetirementOwner = + new AcDream.App.Streaming.LandblockPresentationRetirementOwner( + _landblockRenderPublisher!, + _landblockPhysicsPublisher!, + _landblockStaticPresentationPublisher!, + _lightingSink!, + _translucencyFades); var landblockPresentationPipeline = new AcDream.App.Streaming.LandblockPresentationPipeline( _landblockRenderPublisher!, _landblockPhysicsPublisher!, _landblockStaticPresentationPublisher!, _worldState, + landblockRetirementOwner, onLandblockLoaded: loadedLandblockId => _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), ensureEnvCellMeshes: - _landblockRenderPublisher!.PrepareAfterRenderPins, - retirementCoordinator: _landblockRetirements); + _landblockRenderPublisher!.PrepareAfterRenderPins); _streamingController = new AcDream.App.Streaming.StreamingController( enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad( new AcDream.App.Streaming.LandblockBuildRequest( @@ -2551,18 +2552,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _liveCenterY))), enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation), drainCompletions: _streamer.DrainCompletions, - applyTerrain: ApplyLoadedTerrain, state: _worldState, nearRadius: _nearRadius, farRadius: _farRadius, clearPendingLoads: _streamer.ClearPendingLoads, - // #138: restore retained server objects when a landblock reloads + // Retained-object recovery runs after each // (dungeon-exit expand or Far→Near promote). ACE won't re-send the - // objects it thinks we still know, so we re-project them ourselves. - onLandblockLoaded: loadedLandblockId => - _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), - ensureEnvCellMeshes: _landblockRenderPublisher!.PrepareAfterRenderPins, - retirementCoordinator: _landblockRetirements, + // objects it still considers known. presentationPipeline: landblockPresentationPipeline); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; @@ -3805,65 +3801,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext $"live: AdminEnvirons sound cue = {name} " + $"(0x{environChangeType:X2}) — audio binding pending"); } - private void AdvanceLandblockPresentationRetirement( - AcDream.App.Streaming.LandblockRetirementTicket ticket) - { - bool staticOnly = - ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.NearLayer; - - ticket.RunForEachEntity( - AcDream.App.Streaming.LandblockRetirementStage.EntityLighting, - entity => !staticOnly || entity.ServerGuid == 0, - entity => - { - if (entity.ServerGuid == 0) - _landblockStaticPresentationPublisher!.RemoveLighting(entity); - else - _lightingSink?.UnregisterOwner(entity.Id); - }); - ticket.RunForEachEntity( - AcDream.App.Streaming.LandblockRetirementStage.EntityTranslucency, - entity => !staticOnly || entity.ServerGuid == 0, - entity => - { - if (entity.ServerGuid == 0) - _landblockStaticPresentationPublisher!.RemoveTranslucency(entity); - else - _translucencyFades.ClearEntity(entity.Id); - }); - ticket.RunForEachEntity( - AcDream.App.Streaming.LandblockRetirementStage.PluginProjection, - static entity => entity.ServerGuid == 0, - entity => _landblockStaticPresentationPublisher! - .RemovePluginProjection(entity)); - - if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full) - { - ticket.RunOnce( - AcDream.App.Streaming.LandblockRetirementStage.Terrain, - () => _landblockRenderPublisher?.RemoveTerrain(ticket.LandblockId)); - } - - ticket.RunOnce( - AcDream.App.Streaming.LandblockRetirementStage.Physics, - () => - { - if (ticket.Kind == AcDream.App.Streaming.LandblockRetirementKind.Full) - _landblockPhysicsPublisher!.RemoveLandblock(ticket.LandblockId); - else - _landblockPhysicsPublisher!.DemoteToTerrain(ticket.LandblockId); - }); - ticket.RunOnce( - AcDream.App.Streaming.LandblockRetirementStage.CellVisibility, - () => _landblockRenderPublisher?.RemoveCellVisibility(ticket.LandblockId)); - ticket.RunOnce( - AcDream.App.Streaming.LandblockRetirementStage.BuildingRegistry, - () => _landblockRenderPublisher?.RemoveBuildingRegistry(ticket.LandblockId)); - ticket.RunOnce( - AcDream.App.Streaming.LandblockRetirementStage.EnvironmentCells, - () => _landblockRenderPublisher?.RemoveEnvironmentCells(ticket.LandblockId)); - } - /// /// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick /// whenever a new landblock's terrain + entities are ready for GPU upload. @@ -5961,7 +5898,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext LiveEntities: _liveEntities?.Count ?? 0, MaterializedLiveEntities: _liveEntities?.MaterializedCount ?? 0, PendingLiveTeardowns: _liveEntities?.PendingTeardownCount ?? 0, - PendingLandblockRetirements: _landblockRetirements?.PendingCount ?? 0, + PendingLandblockRetirements: + _streamingController?.PendingRetirementCount ?? 0, ParticleEmitters: _particleSystem?.ActiveEmitterCount ?? 0, Particles: _particleSystem?.ActiveParticleCount ?? 0, ParticleBindings: _particleSink?.ActiveBindingCount ?? 0, diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index bc7585e4..640ebb6a 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -79,6 +79,13 @@ public sealed class LandblockPresentationPipeline _state = state; _onLandblockLoaded = onLandblockLoaded; _ensureEnvCellMeshes = ensureEnvCellMeshes; + if (retirementCoordinator is not null + && !retirementCoordinator.MatchesState(_state)) + { + throw new ArgumentException( + "The retirement coordinator must own the pipeline's world state.", + nameof(retirementCoordinator)); + } _retirements = retirementCoordinator ?? LandblockRetirementCoordinator.CreateLegacy( state, @@ -96,9 +103,9 @@ public sealed class LandblockPresentationPipeline LandblockPhysicsPublisher physicsPublisher, LandblockStaticPresentationPublisher staticPublisher, GpuWorldState state, + LandblockPresentationRetirementOwner retirementOwner, Action? onLandblockLoaded = null, - Action? ensureEnvCellMeshes = null, - LandblockRetirementCoordinator? retirementCoordinator = null) + Action? ensureEnvCellMeshes = null) { _renderPublisher = renderPublisher ?? throw new ArgumentNullException(nameof(renderPublisher)); @@ -107,19 +114,35 @@ public sealed class LandblockPresentationPipeline _staticPublisher = staticPublisher ?? throw new ArgumentNullException(nameof(staticPublisher)); _state = state ?? throw new ArgumentNullException(nameof(state)); + if (!_renderPublisher.MatchesState(_state)) + { + throw new ArgumentException( + "The render publisher must own the pipeline's world state.", + nameof(state)); + } + ArgumentNullException.ThrowIfNull(retirementOwner); + if (!retirementOwner.Matches( + _renderPublisher, + _physicsPublisher, + _staticPublisher)) + { + throw new ArgumentException( + "The retirement owner must reference the pipeline's concrete publishers.", + nameof(retirementOwner)); + } _onLandblockLoaded = onLandblockLoaded; _ensureEnvCellMeshes = ensureEnvCellMeshes; - // Until Slice 5F internalizes concrete retirement ownership, a - // concrete publication pipeline must share the production coordinator. - // A legacy no-op retirement suffix would detach spatial state while - // leaking the concrete render/physics/static owners. - _retirements = retirementCoordinator - ?? throw new ArgumentNullException(nameof(retirementCoordinator)); + _retirements = new LandblockRetirementCoordinator( + state, + retirementOwner.Advance); } public int PendingRetirementCount => _retirements.PendingCount; public int PendingPublicationCount => _publications.Count; + internal bool MatchesState(GpuWorldState state) => + ReferenceEquals(_state, state); + public bool IsRetirementPending(uint landblockId) => _retirements.IsPending(landblockId); diff --git a/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs b/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs new file mode 100644 index 00000000..9c409891 --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs @@ -0,0 +1,119 @@ +using AcDream.Core.Lighting; +using AcDream.Core.Rendering; +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +/// +/// Composes the presentation-owner half of one detach-first landblock +/// retirement. remains the exact +/// retry ledger; this class only maps each stage to its concrete owner. +/// +/// +/// Retail separates CLandBlock::release_all (0x0052FCF0), +/// CLandBlock::destroy_static_objects (0x0052FA50), and +/// CLandBlock::Destroy (0x0052FAA0). Acdream's asynchronous equivalent +/// detaches spatial reachability first, then advances these concrete owners +/// from the retained ticket without replaying successful stages or entities. +/// +public sealed class LandblockPresentationRetirementOwner +{ + private readonly LandblockRenderPublisher _render; + private readonly LandblockPhysicsPublisher _physics; + private readonly LandblockStaticPresentationPublisher _staticPresentation; + private readonly LightingHookSink _lighting; + private readonly TranslucencyFadeManager _translucency; + + public LandblockPresentationRetirementOwner( + LandblockRenderPublisher render, + LandblockPhysicsPublisher physics, + LandblockStaticPresentationPublisher staticPresentation, + LightingHookSink lighting, + TranslucencyFadeManager translucency) + { + _render = render ?? throw new ArgumentNullException(nameof(render)); + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _staticPresentation = staticPresentation + ?? throw new ArgumentNullException(nameof(staticPresentation)); + _lighting = lighting ?? throw new ArgumentNullException(nameof(lighting)); + _translucency = translucency + ?? throw new ArgumentNullException(nameof(translucency)); + if (!_staticPresentation.MatchesResources(_lighting, _translucency)) + { + throw new ArgumentException( + "The retirement resources must be those owned by the static " + + "presentation publisher.", + nameof(staticPresentation)); + } + } + + internal bool Matches( + LandblockRenderPublisher render, + LandblockPhysicsPublisher physics, + LandblockStaticPresentationPublisher staticPresentation) => + ReferenceEquals(_render, render) + && ReferenceEquals(_physics, physics) + && ReferenceEquals(_staticPresentation, staticPresentation) + && _staticPresentation.MatchesResources(_lighting, _translucency); + + public void Advance(LandblockRetirementTicket ticket) + { + ArgumentNullException.ThrowIfNull(ticket); + bool staticOnly = ticket.Kind == LandblockRetirementKind.NearLayer; + + ticket.RunForEachEntity( + LandblockRetirementStage.EntityLighting, + entity => !staticOnly || entity.ServerGuid == 0, + RemoveLighting); + ticket.RunForEachEntity( + LandblockRetirementStage.EntityTranslucency, + entity => !staticOnly || entity.ServerGuid == 0, + RemoveTranslucency); + ticket.RunForEachEntity( + LandblockRetirementStage.PluginProjection, + static entity => entity.ServerGuid == 0, + _staticPresentation.RemovePluginProjection); + + 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)); + ticket.RunOnce( + LandblockRetirementStage.BuildingRegistry, + () => _render.RemoveBuildingRegistry(ticket.LandblockId)); + ticket.RunOnce( + LandblockRetirementStage.EnvironmentCells, + () => _render.RemoveEnvironmentCells(ticket.LandblockId)); + } + + private void RemoveLighting(WorldEntity entity) + { + if (entity.ServerGuid == 0) + _staticPresentation.RemoveLighting(entity); + else + _lighting.UnregisterOwner(entity.Id); + } + + private void RemoveTranslucency(WorldEntity entity) + { + if (entity.ServerGuid == 0) + _staticPresentation.RemoveTranslucency(entity); + else + _translucency.ClearEntity(entity.Id); + } +} diff --git a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs index 2e240ba3..3b768918 100644 --- a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs @@ -145,6 +145,9 @@ public sealed class LandblockRenderPublisher _buildingRegistryRemovalCount, _envCellRemovalCount); + internal bool MatchesState(GpuWorldState state) => + ReferenceEquals(_worldState, state); + /// /// Publishes the prefix that retail establishes before physics/static /// activation: terrain, complete visibility cells, and spatial bounds. diff --git a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs index c1f98912..03fea9d6 100644 --- a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs +++ b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs @@ -136,14 +136,23 @@ public sealed class LandblockRetirementTicket } /// -/// Game-window-lifetime owner for landblock retirement. World-state detachment +/// Pipeline-lifetime owner for landblock retirement. World-state detachment /// commits first; renderer/cache/script cleanup advances afterward from a -/// per-owner ledger. The coordinator is intentionally shared by replacement -/// instances so a quality-setting rebuild -/// cannot abandon an unfinished retirement. +/// per-owner ledger. Reentrant begin/advance requests are serialized so an +/// owner callback cannot recursively replay its active stage or mutate the +/// pending-ticket map while it is being enumerated. /// public sealed class LandblockRetirementCoordinator { + private enum RequestKind : byte + { + BeginFull, + BeginNearLayer, + Advance, + } + + private readonly record struct Request(RequestKind Kind, uint LandblockId); + private const LandblockRetirementStage CoreStages = LandblockRetirementStage.MeshReferences | LandblockRetirementStage.StaticScripts @@ -165,6 +174,9 @@ public sealed class LandblockRetirementCoordinator _requiredPresentationStages; private readonly Dictionary> _pending = new(); private readonly List _completedIds = new(); + private readonly Queue _requests = new(); + private readonly HashSet _seenDuringDrain = new(); + private bool _drainingRequests; public LandblockRetirementCoordinator( GpuWorldState state, @@ -186,13 +198,19 @@ public sealed class LandblockRetirementCoordinator public bool IsPending(uint landblockId) => _pending.ContainsKey(Canonicalize(landblockId)); - public void BeginFull(uint landblockId) => - Begin(landblockId, LandblockRetirementKind.Full); + internal bool MatchesState(GpuWorldState state) => + ReferenceEquals(_state, state); - public void BeginNearLayer(uint landblockId) => - Begin(landblockId, LandblockRetirementKind.NearLayer); + public void BeginFull(uint landblockId) => EnqueueRequest( + new Request(RequestKind.BeginFull, Canonicalize(landblockId))); - public void Advance() + public void BeginNearLayer(uint landblockId) => EnqueueRequest( + new Request(RequestKind.BeginNearLayer, Canonicalize(landblockId))); + + public void Advance() => EnqueueRequest( + new Request(RequestKind.Advance, 0u)); + + private void AdvanceCore() { if (_pending.Count == 0) return; @@ -250,7 +268,7 @@ public sealed class LandblockRetirementCoordinator return new LandblockRetirementCoordinator(state, AdvanceLegacy, Required); } - private void Begin(uint landblockId, LandblockRetirementKind kind) + private void BeginCore(uint landblockId, LandblockRetirementKind kind) { uint canonical = Canonicalize(landblockId); if (_pending.TryGetValue(canonical, out List? existing)) @@ -259,20 +277,52 @@ public sealed class LandblockRetirementCoordinator { if (existing[i].Kind == kind) { - AdvanceTicket(existing[i]); + LandblockRetirementTicket existingTicket = existing[i]; + AdvanceTicket(existingTicket); + if (existingTicket.IsComplete) + { + existing.RemoveAt(i); + PendingCount--; + if (existing.Count == 0) + _pending.Remove(canonical); + } return; } } } - GpuLandblockRetirement? stateRetirement = kind == LandblockRetirementKind.Full - ? _state.DetachLandblock(canonical) - : _state.DetachNearLayer(canonical); - if (stateRetirement is null) - return; - LandblockRetirementStage required = CoreStages | _requiredPresentationStages(kind); + GpuLandblockRetirement? stateRetirement = null; + Exception? detachmentObserverFailure = null; + GpuWorldState.MutationBatch mutation = _state.BeginMutationBatch(); + try + { + // The detach methods create a nested batch. Holding this outer + // transaction lets us retain their returned teardown context + // before visibility observers run at the outer commit boundary. + stateRetirement = kind == LandblockRetirementKind.Full + ? _state.DetachLandblock(canonical) + : _state.DetachNearLayer(canonical); + } + finally + { + try + { + mutation.Dispose(); + } + catch (Exception error) + { + detachmentObserverFailure = error; + } + } + if (stateRetirement is null) + { + if (detachmentObserverFailure is not null) + throw detachmentObserverFailure; + return; + } + var ticket = new LandblockRetirementTicket(stateRetirement, required); if (existing is null) { @@ -290,6 +340,13 @@ public sealed class LandblockRetirementCoordinator if (existing.Count == 0) _pending.Remove(canonical); } + // Visibility is a delivered-by-attempt broadcast: every subscriber is + // invoked and failures are aggregated, but failed subscribers are not + // retained as replayable work. Complete the presentation teardown + // from the preserved detach receipt, then surface that committed-edge + // failure to the caller without inventing a one-frame pending fence. + if (detachmentObserverFailure is not null) + throw detachmentObserverFailure; } private void AdvanceTicket(LandblockRetirementTicket ticket) @@ -325,4 +382,70 @@ public sealed class LandblockRetirementCoordinator private static uint Canonicalize(uint id) => (id & 0xFFFF0000u) | 0xFFFFu; + + private void EnqueueRequest(Request request) + { + if (_drainingRequests) + { + if (_seenDuringDrain.Add(request)) + _requests.Enqueue(request); + return; + } + + _drainingRequests = true; + _seenDuringDrain.Clear(); + _requests.Clear(); + _seenDuringDrain.Add(request); + _requests.Enqueue(request); + List? failures = null; + try + { + while (_requests.TryDequeue(out Request pending)) + { + try + { + switch (pending.Kind) + { + case RequestKind.BeginFull: + BeginCore( + pending.LandblockId, + LandblockRetirementKind.Full); + break; + case RequestKind.BeginNearLayer: + BeginCore( + pending.LandblockId, + LandblockRetirementKind.NearLayer); + break; + case RequestKind.Advance: + AdvanceCore(); + break; + default: + throw new InvalidOperationException( + $"Unknown retirement request {pending.Kind}."); + } + } + catch (Exception error) + { + (failures ??= new List()).Add(error); + } + } + } + finally + { + _requests.Clear(); + _seenDuringDrain.Clear(); + _drainingRequests = false; + } + + if (failures is { Count: 1 }) + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(failures[0]) + .Throw(); + if (failures is { Count: > 1 }) + { + throw new AggregateException( + "One or more serialized landblock retirement requests failed.", + failures); + } + } } diff --git a/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs b/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs index 345369ab..5d4081bb 100644 --- a/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs @@ -108,6 +108,12 @@ public sealed class LandblockStaticPresentationPublisher _activeByLandblock.Count, _landblockByEntityId.Count); + internal bool MatchesResources( + LightingHookSink lighting, + TranslucencyFadeManager translucency) => + ReferenceEquals(_lighting, lighting) + && ReferenceEquals(_translucency, translucency); + public LandblockStaticPresentationPublication PreparePublication( LandblockPhysicsPublication physicsPublication) { diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 7edfbf09..0d76a543 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -115,6 +115,7 @@ public sealed class StreamingController // deferred-LOAD backlog. A non-zero value during/after a teleport is the // GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface. public int DeferredApplyBacklog => _deferredApply.Count; + public int PendingRetirementCount => _presentation.PendingRetirementCount; /// /// True once every in-bounds landblock in the requested Chebyshev ring has @@ -174,8 +175,40 @@ public sealed class StreamingController Action? clearPendingLoads = null, Action? onLandblockLoaded = null, Action? ensureEnvCellMeshes = null, - LandblockRetirementCoordinator? retirementCoordinator = null, - LandblockPresentationPipeline? presentationPipeline = null) + LandblockRetirementCoordinator? retirementCoordinator = null) + : this( + enqueueLoad, + enqueueUnload, + drainCompletions, + state, + nearRadius, + farRadius, + new LandblockPresentationPipeline( + applyTerrain, + state, + onLandblockLoaded, + ensureEnvCellMeshes, + retirementCoordinator, + removeTerrain, + demoteNearLayer), + clearPendingLoads) + { + } + + /// + /// Concrete presentation path. Its pipeline is the sole presentation and + /// retirement owner, so no legacy publication delegate can be supplied or + /// silently ignored. + /// + public StreamingController( + Action enqueueLoad, + Action enqueueUnload, + Func> drainCompletions, + GpuWorldState state, + int nearRadius, + int farRadius, + LandblockPresentationPipeline presentationPipeline, + Action? clearPendingLoads = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; @@ -183,14 +216,13 @@ public sealed class StreamingController _clearPendingLoads = clearPendingLoads; _state = state; _presentation = presentationPipeline - ?? new LandblockPresentationPipeline( - applyTerrain, - state, - onLandblockLoaded, - ensureEnvCellMeshes, - retirementCoordinator, - removeTerrain, - demoteNearLayer); + ?? throw new ArgumentNullException(nameof(presentationPipeline)); + if (!_presentation.MatchesState(_state)) + { + throw new ArgumentException( + "The presentation pipeline must own the controller's world state.", + nameof(presentationPipeline)); + } NearRadius = nearRadius; FarRadius = farRadius; } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs index e4d84e69..fa7f87a2 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs @@ -40,8 +40,9 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, + fixture.RetirementOwner, onLandblockLoaded: _ => calls.Add("live-recovery"), - retirementCoordinator: fixture.Retirements); + ensureEnvCellMeshes: null); LandblockBuild build = Build(Entity(0x80A9B401u)); pipeline.PublishLoaded(Result(build)); @@ -77,7 +78,7 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, - retirementCoordinator: fixture.Retirements); + fixture.RetirementOwner); LandblockBuild build = Build(Entity(0x80A9B401u)); LandblockStreamResult.Loaded result = Result(build); @@ -113,7 +114,7 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, - retirementCoordinator: fixture.Retirements); + fixture.RetirementOwner); WorldEntity first = Entity(0x80A9B401u); LandblockBuild build = Build(first, Entity(first.Id)); LandblockStreamResult.Loaded result = Result(build); @@ -140,7 +141,7 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, - retirementCoordinator: fixture.Retirements); + fixture.RetirementOwner); WorldEntity first = Entity(0x80A9B401u); pipeline.PublishLoaded(Result(Build(first))); long renderBegins = fixture.Render.Diagnostics.BeginCount; @@ -161,7 +162,7 @@ public sealed class LandblockConcretePresentationPipelineTests } [Fact] - public void ConcreteConstructor_RequiresSharedRetirementCoordinator() + public void ConcreteConstructor_RequiresMatchingRetirementOwner() { ConcreteFixture fixture = Fixture(new List()); @@ -171,7 +172,51 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, - retirementCoordinator: null)); + retirementOwner: null!)); + + var foreignRender = new LandblockRenderPublisher( + static (_, _, _) => { }, + static _ => { }, + new CellVisibility(), + fixture.State); + var foreignOwner = new LandblockPresentationRetirementOwner( + foreignRender, + fixture.Physics, + fixture.Static, + fixture.Lighting, + fixture.Translucency); + Assert.Throws(() => + new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + foreignOwner)); + + Assert.Throws(() => + new LandblockPresentationRetirementOwner( + fixture.Render, + fixture.Physics, + fixture.Static, + new LightingHookSink( + new LightManager(), + new NullPoseSource()), + fixture.Translucency)); + Assert.Throws(() => + new LandblockPresentationRetirementOwner( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.Lighting, + new TranslucencyFadeManager())); + + Assert.Throws(() => + new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + new GpuWorldState(), + fixture.RetirementOwner)); } [Fact] @@ -183,7 +228,7 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, - retirementCoordinator: fixture.Retirements); + fixture.RetirementOwner); WorldEntity first = Entity(0x80A9B401u); WorldEntity replacement = Entity(first.Id); @@ -205,7 +250,7 @@ public sealed class LandblockConcretePresentationPipelineTests fixture.Physics, fixture.Static, fixture.State, - retirementCoordinator: fixture.Retirements); + fixture.RetirementOwner); WorldEntity entity = Entity(0x80A9B401u); pipeline.PublishLoaded(Result(Build(entity))); Assert.Equal(1, fixture.Runner.ActiveOwnerCount); @@ -217,9 +262,158 @@ public sealed class LandblockConcretePresentationPipelineTests Assert.False(fixture.Poses.TryGetRootPose(entity.Id, out _)); } + [Fact] + public void FullRetirement_DetachesFirstAndBalancesEveryConcreteOwner() + { + var calls = new List(); + ConcreteFixture fixture = Fixture(calls); + var pipeline = Pipeline(fixture); + WorldEntity staticEntity = Entity(0x80A9B401u); + WorldEntity liveEntity = Entity( + 7u, + serverGuid: 0x80000007u); + pipeline.PublishLoaded(Result(Build(staticEntity, liveEntity))); + fixture.Lighting.RegisterOwnedLight(new LightSource + { + OwnerId = staticEntity.Id, + Kind = LightKind.Point, + }); + fixture.Lighting.RegisterOwnedLight(new LightSource + { + OwnerId = liveEntity.Id, + Kind = LightKind.Point, + }); + fixture.Translucency.StartPartFade(staticEntity.Id, 0u, 0f, 1f, 2f); + fixture.Translucency.StartPartFade(liveEntity.Id, 0u, 0f, 1f, 2f); + calls.Clear(); + + pipeline.BeginFullRetirement(LandblockId); + + Assert.False(fixture.State.IsLoaded(LandblockId)); + Assert.Equal(0, pipeline.PendingRetirementCount); + Assert.Equal(0, fixture.Runner.ActiveOwnerCount); + Assert.False(fixture.Poses.TryGetRootPose(staticEntity.Id, out _)); + Assert.Equal(0, fixture.Lights.RegisteredCount); + Assert.False(fixture.Translucency.TryGetCurrentValue( + staticEntity.Id, + 0u, + out _)); + Assert.False(fixture.Translucency.TryGetCurrentValue( + liveEntity.Id, + 0u, + out _)); + Assert.Empty(fixture.World.Entities); + Assert.Equal(1, fixture.Physics.Diagnostics.FullRemovalCount); + Assert.Equal(0, fixture.Physics.Diagnostics.DemotionCount); + Assert.Equal(1, fixture.Render.Diagnostics.TerrainRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.CellVisibilityRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.BuildingRegistryRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount); + Assert.Contains("unpin", calls); + Assert.Contains("terrain-remove", calls); + Assert.Contains("envcell-remove", calls); + } + + [Fact] + public void NearRetirement_KeepsTerrainAndLivePresentationButRetiresNearOwners() + { + var calls = new List(); + ConcreteFixture fixture = Fixture(calls); + var pipeline = Pipeline(fixture); + WorldEntity staticEntity = Entity(0x80A9B401u); + WorldEntity liveEntity = Entity( + 7u, + serverGuid: 0x80000007u); + pipeline.PublishLoaded(Result(Build(staticEntity, liveEntity))); + fixture.Lighting.RegisterOwnedLight(new LightSource + { + OwnerId = staticEntity.Id, + Kind = LightKind.Point, + }); + fixture.Lighting.RegisterOwnedLight(new LightSource + { + OwnerId = liveEntity.Id, + Kind = LightKind.Point, + }); + fixture.Translucency.StartPartFade(staticEntity.Id, 0u, 0f, 1f, 2f); + fixture.Translucency.StartPartFade(liveEntity.Id, 0u, 0f, 1f, 2f); + calls.Clear(); + + pipeline.BeginNearLayerRetirement(LandblockId); + + Assert.True(fixture.State.IsLoaded(LandblockId)); + Assert.False(fixture.State.IsNearTier(LandblockId)); + Assert.Single(fixture.State.Entities); + Assert.Same(liveEntity, fixture.State.Entities[0]); + Assert.Equal(0, fixture.Runner.ActiveOwnerCount); + Assert.False(fixture.Poses.TryGetRootPose(staticEntity.Id, out _)); + Assert.Single(fixture.Lighting.GetOwnedLights(liveEntity.Id)!); + Assert.Null(fixture.Lighting.GetOwnedLights(staticEntity.Id)); + Assert.False(fixture.Translucency.TryGetCurrentValue( + staticEntity.Id, + 0u, + out _)); + Assert.True(fixture.Translucency.TryGetCurrentValue( + liveEntity.Id, + 0u, + out _)); + Assert.Empty(fixture.World.Entities); + Assert.Equal(0, fixture.Physics.Diagnostics.FullRemovalCount); + Assert.Equal(1, fixture.Physics.Diagnostics.DemotionCount); + Assert.Equal(0, fixture.Render.Diagnostics.TerrainRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.CellVisibilityRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.BuildingRegistryRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount); + Assert.DoesNotContain("terrain-remove", calls); + } + + [Fact] + public void FullRetirementFailure_RetriesOnlyUnfinishedConcreteStage() + { + int terrainAttempts = 0; + var calls = new List(); + ConcreteFixture fixture = Fixture( + calls, + removeTerrain: _ => + { + terrainAttempts++; + if (terrainAttempts == 1) + throw new InvalidOperationException("injected terrain removal failure"); + }); + var pipeline = Pipeline(fixture); + pipeline.PublishLoaded(Result(Build(Entity(0x80A9B401u)))); + + pipeline.BeginFullRetirement(LandblockId); + + Assert.False(fixture.State.IsLoaded(LandblockId)); + Assert.Equal(1, pipeline.PendingRetirementCount); + Assert.Equal(1, terrainAttempts); + Assert.Equal(0, fixture.Render.Diagnostics.TerrainRemovalCount); + Assert.Equal(1, fixture.Physics.Diagnostics.FullRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount); + + pipeline.AdvanceRetirements(); + + Assert.Equal(0, pipeline.PendingRetirementCount); + Assert.Equal(2, terrainAttempts); + Assert.Equal(1, fixture.Render.Diagnostics.TerrainRemovalCount); + Assert.Equal(1, fixture.Physics.Diagnostics.FullRemovalCount); + Assert.Equal(1, fixture.Render.Diagnostics.EnvCellRemovalCount); + } + + private static LandblockPresentationPipeline Pipeline( + ConcreteFixture fixture) => new( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + fixture.RetirementOwner); + private static ConcreteFixture Fixture( List calls, - Action? commitEnvCells = null) + Action? commitEnvCells = null, + Action? removeTerrain = null, + Action? removeEnvCells = null) { var poses = new EntityEffectPoseRegistry(); var particleSink = new ParticleHookSink( @@ -246,33 +440,44 @@ public sealed class LandblockConcretePresentationPipelineTests var engine = new PhysicsEngine { DataCache = cache }; var render = new LandblockRenderPublisher( (_, _, _) => calls.Add("terrain"), - static _ => { }, + removeTerrain ?? (_ => calls.Add("terrain-remove")), new CellVisibility(), state, - commitEnvCells: commitEnvCells); + commitEnvCells: commitEnvCells, + removeEnvCells: removeEnvCells ?? (_ => calls.Add("envcell-remove"))); var physics = new LandblockPhysicsPublisher(engine, new float[256]); + var lights = new LightManager(); var lighting = new LightingHookSink( - new LightManager(), + lights, new NullPoseSource()); + var translucency = new TranslucencyFadeManager(); + var world = new WorldGameState(); var events = new WorldEvents(); - var retirements = new LandblockRetirementCoordinator( - state, - static _ => { }, - static _ => LandblockRetirementStage.None); + var staticPresentation = new LandblockStaticPresentationPublisher( + lighting, + translucency, + world, + events); + var retirementOwner = new LandblockPresentationRetirementOwner( + render, + physics, + staticPresentation, + lighting, + translucency); return new ConcreteFixture( state, engine, render, physics, - new LandblockStaticPresentationPublisher( - lighting, - new TranslucencyFadeManager(), - new WorldGameState(), - events), + staticPresentation, events, - retirements, + retirementOwner, runner, - poses); + poses, + lights, + lighting, + translucency, + world); } private static LandblockStreamResult.Loaded Result(LandblockBuild build) => @@ -316,9 +521,11 @@ public sealed class LandblockConcretePresentationPipelineTests private static WorldEntity Entity( uint id, - uint sourceId = 0x01000001u) => new() + uint sourceId = 0x01000001u, + uint serverGuid = 0u) => new() { Id = id, + ServerGuid = serverGuid, SourceGfxObjOrSetupId = sourceId, Position = Vector3.Zero, Rotation = Quaternion.Identity, @@ -336,9 +543,13 @@ public sealed class LandblockConcretePresentationPipelineTests LandblockPhysicsPublisher Physics, LandblockStaticPresentationPublisher Static, WorldEvents Events, - LandblockRetirementCoordinator Retirements, + LandblockPresentationRetirementOwner RetirementOwner, PhysicsScriptRunner Runner, - EntityEffectPoseRegistry Poses); + EntityEffectPoseRegistry Poses, + LightManager Lights, + LightingHookSink Lighting, + TranslucencyFadeManager Translucency, + WorldGameState World); private sealed class NullHookSink : IAnimationHookSink { @@ -355,9 +566,7 @@ public sealed class LandblockConcretePresentationPipelineTests public void PinPreparedRenderData(ulong id) => calls.Add("pin"); - public void DecrementRefCount(ulong id) - { - } + public void DecrementRefCount(ulong id) => calls.Add("unpin"); } private sealed class NullPoseSource : IEntityEffectPoseSource diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs index 0357c1b2..df731675 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -12,6 +12,63 @@ namespace AcDream.App.Tests.Streaming; public sealed class LandblockPresentationPipelineTests { + [Fact] + public void ControllerRejectsPresentationPipelineFromForeignWorldState() + { + var controllerState = new GpuWorldState(); + var foreignState = new GpuWorldState(); + var foreignPipeline = new LandblockPresentationPipeline( + static (_, _) => { }, + foreignState); + + Assert.Throws(() => new StreamingController( + enqueueLoad: static (_, _, _) => { }, + enqueueUnload: static (_, _) => { }, + drainCompletions: static _ => Array.Empty(), + controllerState, + nearRadius: 0, + farRadius: 0, + presentationPipeline: foreignPipeline)); + } + + [Fact] + public void LegacyPipelineRejectsRetirementCoordinatorFromForeignWorldState() + { + var pipelineState = new GpuWorldState(); + var foreignCoordinator = new LandblockRetirementCoordinator( + new GpuWorldState(), + static _ => { }); + + Assert.Throws(() => + new LandblockPresentationPipeline( + static (_, _) => { }, + pipelineState, + retirementCoordinator: foreignCoordinator)); + } + + [Fact] + public void ControllerConcreteConstructorHasNoLegacyPresentationParameters() + { + string[] legacyNames = + [ + "applyTerrain", + "removeTerrain", + "demoteNearLayer", + "onLandblockLoaded", + "ensureEnvCellMeshes", + "retirementCoordinator", + ]; + + System.Reflection.ConstructorInfo concrete = Assert.Single( + typeof(StreamingController).GetConstructors(), + constructor => constructor.GetParameters().Any( + parameter => parameter.Name == "presentationPipeline")); + string[] names = concrete.GetParameters() + .Select(parameter => parameter.Name!) + .ToArray(); + Assert.DoesNotContain(names, legacyNames.Contains); + } + [Fact] public void Loaded_PreservesPresentationSpatialPinReplayAndLiveRecoveryOrder() { diff --git a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs index a1325e27..65c1a335 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs @@ -137,6 +137,203 @@ public sealed class LandblockRetirementCoordinatorTests Assert.False(coordinator.IsPending(landblockId)); } + [Fact] + public void ReentrantSameIdBegin_DoesNotReplaySuccessfulStage() + { + const uint landblockId = 0x4040FFFFu; + var state = StateWith(landblockId, Entity(1)); + int lightingCalls = 0; + LandblockRetirementCoordinator? coordinator = null; + coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + lighting: _ => + { + lightingCalls++; + coordinator!.BeginFull(landblockId); + })); + + coordinator.BeginFull(landblockId); + + Assert.Equal(1, lightingCalls); + Assert.Equal(0, coordinator.PendingCount); + Assert.False(coordinator.IsPending(landblockId)); + } + + [Fact] + public void ReentrantAdvance_RetriesFailedStageWithoutRecursion() + { + const uint landblockId = 0x4141FFFFu; + var state = StateWith(landblockId, Entity(1)); + int lightingCalls = 0; + LandblockRetirementCoordinator? coordinator = null; + coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + lighting: _ => + { + lightingCalls++; + coordinator!.Advance(); + if (lightingCalls == 1) + throw new InvalidOperationException("injected first failure"); + })); + + coordinator.BeginFull(landblockId); + + Assert.Equal(2, lightingCalls); + Assert.Equal(0, coordinator.PendingCount); + Assert.False(coordinator.IsPending(landblockId)); + } + + [Fact] + public void ReentrantDifferentIdBegin_IsDeferredUntilActiveAdvanceCompletes() + { + const uint firstId = 0x4242FFFFu; + const uint secondId = 0x4343FFFFu; + var state = StateWith(firstId); + state.AddLandblock(new LoadedLandblock( + secondId, + new LandBlock(), + Array.Empty())); + int firstAttempts = 0; + int secondAttempts = 0; + LandblockRetirementCoordinator? coordinator = null; + coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + terrain: () => + { + if (ticket.LandblockId == firstId) + { + firstAttempts++; + if (firstAttempts == 1) + throw new InvalidOperationException("leave first pending"); + coordinator!.BeginFull(secondId); + } + else + { + secondAttempts++; + } + })); + coordinator.BeginFull(firstId); + Assert.Equal(1, coordinator.PendingCount); + + coordinator.Advance(); + + Assert.Equal(2, firstAttempts); + Assert.Equal(1, secondAttempts); + Assert.False(state.IsLoaded(secondId)); + Assert.Equal(0, coordinator.PendingCount); + } + + [Fact] + public void RepeatedBegin_RemovesTicketWhenRetryCompletes() + { + const uint landblockId = 0x4444FFFFu; + var state = StateWith(landblockId); + int terrainAttempts = 0; + int requiredStageCalls = 0; + var coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + terrain: () => + { + terrainAttempts++; + if (terrainAttempts == 1) + throw new InvalidOperationException("injected first failure"); + }), + _ => + { + requiredStageCalls++; + return LandblockRetirementCoordinator.ProductionPresentationStages; + }); + + coordinator.BeginFull(landblockId); + Assert.Equal(1, coordinator.PendingCount); + + coordinator.BeginFull(landblockId); + + Assert.Equal(2, terrainAttempts); + Assert.Equal(1, requiredStageCalls); + Assert.Equal(0, coordinator.PendingCount); + Assert.False(coordinator.IsPending(landblockId)); + } + + [Fact] + public void VisibilityObserverFailure_CompletesReceiptThenRethrowsCommittedEdge() + { + const uint landblockId = 0x4545FFFFu; + WorldEntity live = Entity(1, serverGuid: 0x70000001u); + var state = StateWith(landblockId, live); + state.LiveProjectionVisibilityChanged += (_, _) => + throw new InvalidOperationException("injected visibility failure"); + int terrainCalls = 0; + var coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation( + ticket, + terrain: () => terrainCalls++)); + + Assert.Throws(() => + coordinator.BeginFull(landblockId)); + + Assert.False(state.IsLoaded(landblockId)); + Assert.Equal(1, terrainCalls); + Assert.Equal(0, coordinator.PendingCount); + Assert.False(coordinator.IsPending(landblockId)); + } + + [Fact] + public void RequiredStageFailure_HappensBeforeSpatialDetachment() + { + const uint landblockId = 0x4646FFFFu; + var state = StateWith(landblockId); + var coordinator = new LandblockRetirementCoordinator( + state, + static _ => { }, + static _ => throw new InvalidOperationException( + "injected required-stage failure")); + + Assert.Throws(() => + coordinator.BeginFull(landblockId)); + + Assert.True(state.IsLoaded(landblockId)); + Assert.Equal(0, coordinator.PendingCount); + } + + [Fact] + public void ObserverFailure_DrainsReentrantDifferentIdBeginBeforeRethrow() + { + const uint firstId = 0x4747FFFFu; + const uint secondId = 0x4848FFFFu; + WorldEntity live = Entity(1, serverGuid: 0x70000002u); + var state = StateWith(firstId, live); + state.AddLandblock(new LoadedLandblock( + secondId, + new LandBlock(), + Array.Empty())); + LandblockRetirementCoordinator? coordinator = null; + coordinator = new LandblockRetirementCoordinator( + state, + ticket => CompletePresentation(ticket)); + state.LiveProjectionVisibilityChanged += (_, _) => + { + coordinator.BeginFull(secondId); + throw new InvalidOperationException("injected visibility failure"); + }; + + Assert.Throws(() => + coordinator.BeginFull(firstId)); + + Assert.False(state.IsLoaded(firstId)); + Assert.False(state.IsLoaded(secondId)); + Assert.Equal(0, coordinator.PendingCount); + } + private static StreamingController Controller( GpuWorldState state, LandblockRetirementCoordinator coordinator, @@ -187,15 +384,28 @@ public sealed class LandblockRetirementCoordinatorTests ticket.RunOnce(LandblockRetirementStage.EnvironmentCells, static () => { }); } - private static WorldEntity Entity(uint id) => new() + private static WorldEntity Entity(uint id, uint serverGuid = 0u) => new() { Id = id, + ServerGuid = serverGuid, SourceGfxObjOrSetupId = 0x01000000u + id, Position = Vector3.Zero, Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), }; + private static GpuWorldState StateWith( + uint landblockId, + params WorldEntity[] entities) + { + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblockId, + new LandBlock(), + entities)); + return state; + } + private static LandblockMeshData EmptyMesh() => new( Array.Empty(), Array.Empty()); From 801d8a189c26381e6c77e6cd9c377180b275c3ef Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 22:09:16 +0200 Subject: [PATCH 67/98] docs(architecture): record pipeline-owned retirement Close Slice 5F in the execution ledger, update the milestone and roadmap measurements, and advance the active checkpoint to direct cutover and dead-facade cleanup. Co-authored-by: Codex --- docs/architecture/code-structure.md | 6 +++++- docs/plans/2026-04-11-roadmap.md | 10 ++++++---- docs/plans/2026-05-12-milestones.md | 11 +++++++---- ...-gamewindow-slice-5-landblock-presentation.md | 16 +++++++++++++++- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index c32b9a1b..6698e2aa 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -542,7 +542,11 @@ keeps a second identity dictionary. ForcePosition serializes and stamps one exact canonical outbound Position. Three corrected-diff reviews, the complete 6,940-test Release suite, and the 310-second seven-destination connected R6 route all passed. `GameWindow` is 10,301 lines / 267 fields / 163 methods. -Slice 5 is active next. +Slice 5 checkpoints A–F are complete. The pipeline owns the serialized worker +build, concrete render/physics/static publication, and retryable Near/full +retirement; `GameWindow.cs` is 9,011 lines and no longer owns a retirement +coordinator or callback body. Checkpoint G direct cutover/dead-facade cleanup +is active next, followed by deterministic and connected closeout in H. #### Slice 5 — extract landblock presentation diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 9909f504..4ef6f227 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -47,10 +47,12 @@ passes 6,940 tests with five intentional skips. The 310-second connected R6 gate passed seven destinations/revisits, movement/jump/combat exercises, and graceful close. -**Next:** Slice 5 extracts DAT-backed landblock build, publication, demotion, -and retirement into `LandblockPresentationPipeline` while preserving -`StreamingController` residency decisions and `GpuWorldState` spatial -ownership. +**Current:** Slice 5 checkpoints A–F have extracted the DAT-backed worker +build plus render, physics, DAT-static, and retryable Near/full presentation +ownership into `LandblockPresentationPipeline`. `GameWindow` is 9,011 lines +and the Release suite passes 7,039 tests with five intentional skips. Slice 5G +now removes the obsolete callback bodies and scratch/facade state before the +deterministic and connected closeout in H. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index f26351e9..3c75ea9d 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -19,10 +19,13 @@ layer, the active structural prerequisite is the behavior-preserving The 2026-07-21 audit baseline was 15,723 lines, 278 fields, and 205 methods. Selection/interaction Slice 1, live-animation-presentation Slice 2, complete live-session ownership Slice 3, and live-entity App integration Slice 4 are -complete. `GameWindow` is now 10,301 lines, 267 fields, and 163 methods. The -full Release suite passes 6,940 tests / 5 skips, and Slice 4's seven-destination -connected R6 route passed in 310 seconds. Slice 5 — landblock presentation — -is active next. +complete. Slice 5 landblock-presentation checkpoints A–F are also complete: +the worker DAT transaction, render/physics/static publishers, and retryable +Near/full retirement now have focused owners. `GameWindow` is 9,011 lines, +down 1,290 from the Slice-5 baseline and 6,712 from the campaign baseline. The +full Release suite passes 7,039 tests / 5 skips; Slice 4's seven-destination +connected R6 route passed in 310 seconds. Slice 5G direct cutover and dead +facade/scratch cleanup is active next. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md index fff3df07..e1493beb 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md +++ b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md @@ -80,8 +80,22 @@ test, and bisectable commit rather than being hidden in a mechanical move. corrected-diff reviews are clean. Release gate: 7,026 passed / 5 skipped; `GameWindow.cs` is temporarily 9,073 lines because production wiring lands before checkpoint G deletes the old callback body. -- [ ] F — move retryable demotion/full-retirement presentation under the +- [x] F — move retryable demotion/full-retirement presentation under the pipeline without changing `LandblockRetirementCoordinator`'s ledger. + - Landed in `ea7ffbc1`: the pipeline now owns a focused + `LandblockPresentationRetirementOwner` and its exact detach-first ledger; + `GameWindow` no longer owns a coordinator or retirement callback body. + Full and Near retirements preserve their distinct terrain/physics/live + owner matrices, successful stages and entity cursors never replay, and + reentrant Begin/Advance requests serialize without mutating an active + ticket enumeration. A committed visibility-observer failure retains the + detach receipt, completes presentation teardown, drains deferred work, + then rethrows. Constructor identity guards enforce one world-state, + publisher, lighting, and translucency graph, while explicit concrete and + legacy controller overloads make ignored presentation callbacks + impossible. Three corrected-diff reviews are clean. Release gate: 7,039 + passed / 5 skipped; build green with 17 pre-existing test-project warnings; + `GameWindow.cs` is 9,011 lines. - [ ] G — cut `StreamingController` and `GameWindow` directly to the pipeline; delete old bodies, scratch state, and callback facades. - [ ] H — three-agent corrected-diff review, full Release suite, deterministic From c79d0a49da52c5077550b15336cc51dc4684e606 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 22:47:30 +0200 Subject: [PATCH 68/98] refactor(streaming): complete landblock presentation cutover Remove the legacy GameWindow apply path and make the concrete render, physics, and static publishers the only production owner graph. Serialize full-window retirement with shared-origin teleport and session boundaries so old coordinate-frame resources cannot survive into a new world or login. --- src/AcDream.App/Rendering/CellVisibility.cs | 4 +- src/AcDream.App/Rendering/GameWindow.cs | 420 +++--------- .../Streaming/LandblockBuildFactory.cs | 18 +- .../Streaming/LandblockPhysicsPublisher.cs | 6 +- .../LandblockPresentationPipeline.cs | 38 +- .../Streaming/StreamingController.cs | 344 +++++++++- .../StreamingOriginRecenterCoordinator.cs | 192 ++++++ src/AcDream.Core/Physics/PhysicsEngine.cs | 3 +- src/AcDream.Core/World/PhysicsDatBundle.cs | 9 +- .../Net/LiveSessionResetPlanTests.cs | 60 ++ .../Streaming/LandblockBuildOriginTests.cs | 84 ++- .../LandblockPresentationPipelineTests.cs | 597 +++++++++++++++++- 12 files changed, 1373 insertions(+), 402 deletions(-) create mode 100644 src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs index 035016b0..31b9ebca 100644 --- a/src/AcDream.App/Rendering/CellVisibility.cs +++ b/src/AcDream.App/Rendering/CellVisibility.cs @@ -291,8 +291,8 @@ public sealed class CellVisibility /// /// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a - /// landblock prefix. Used by GameWindow.ApplyLoadedTerrainLocked when - /// building the per-landblock BuildingRegistry — the per-frame + /// landblock prefix. Used by LandblockRenderPublisher when building + /// the per-landblock BuildingRegistry — the per-frame /// drainedCells dict misses cells loaded on prior frames, so the /// stamping loop in needs access to /// every cell currently in the landblock to ensure BuildingId is set. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2ef8d4c9..e6a816be 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -93,46 +93,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private int _terrainCpuSampleCursor; private long _terrainLastDiagTick; - // [FRAME-DIAG] (ACDREAM_WB_DIAG=1): per-frame cost split for the FPS deep-dive. - // The crux this measures: terrain APPLY runs in OnUpdate (under _datLock — - // dat reads + physics/ShadowObjects/BSP registration + the terrain GPU - // upload), while the DRAW + the title-bar ms run in OnRender, so a heavy - // apply stalls the loop turn yet is INVISIBLE to the render-only ms. We - // accumulate apply cost across each OnUpdate and flush it next to the - // [TERRAIN-DIAG] draw cost. Samples are µs×100 over a rolling 256-ring, - // reusing the [TERRAIN-DIAG] median/p95 helpers. Removable (mirrors the - // probes stripped in 92e95be). Gated once at construction — zero cost off. + // [FRAME-DIAG]: retain only the render-thread entity upload distribution. + // Landblock publication timing/counts live with the focused publishers and + // are read from their typed snapshots when diagnostics flush. private readonly bool _frameDiag = string.Equals( - System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", System.StringComparison.Ordinal); - private long _applyAccumTicks; // apply CPU accumulated across this OnUpdate - private long _applyUploadAccumTicks; // terrain glBufferSubData sub-span this OnUpdate - private int _appliesThisUpdate; // # landblock LOADS applied this OnUpdate - private int _frameDiagMaxAppliesPerUpdate; // burst size over the 5s window - private readonly long[] _applyCpuSamples = new long[256]; // apply µs×100 / Update - private int _applyCpuSampleCursor; - private readonly long[] _applyUploadSamples = new long[256]; // terrain-upload µs×100 / Update - private int _applyUploadSampleCursor; - private readonly long[] _entityUploadSamples = new long[256]; // _wbMeshAdapter.Tick µs×100 / Render - private int _entityUploadSampleCursor; - // Apply CPU split into three sub-spans (to name WHICH part of the 40-110ms - // apply dominates): cell-build (EnvCell dat reads + physics surfaces + - // buildings), gfxobj-BSP (per-entity physics BSP cache), ShadowObjects+lights - // registration. Accumulated per OnUpdate via checkpoints in ApplyLoadedTerrainLocked. - private long _applyCellAccumTicks; - private long _applyBspAccumTicks; - private long _applyShadowAccumTicks; - private readonly long[] _applyCellSamples = new long[256]; - private int _applyCellSampleCursor; - private readonly long[] _applyBspSamples = new long[256]; - private int _applyBspSampleCursor; - private readonly long[] _applyShadowSamples = new long[256]; - private int _applyShadowSampleCursor; - // Direct measure of the _datLock acquisition wait (the streaming worker holds - // the lock for its full per-landblock build, stalling this apply). Confirms the - // apply≫sub-span gap is lock contention, not in-lock work. - private long _applyLockWaitAccumTicks; - private readonly long[] _applyLockWaitSamples = new long[256]; - private int _applyLockWaitSampleCursor; + System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), + "1", + System.StringComparison.Ordinal); + private readonly long[] _entityUploadSamples = new long[256]; + private int _entityUploadSampleCursor; // MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call // per OnRender + three stage scopes. All logic lives in @@ -150,10 +119,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.GpuWorldState _worldState = new(); - private AcDream.App.Streaming.LandblockRenderPublisher? _landblockRenderPublisher; - private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher; - private AcDream.App.Streaming.LandblockStaticPresentationPublisher? - _landblockStaticPresentationPublisher; + private AcDream.App.Streaming.LandblockPresentationPipeline? + _landblockPresentationPipeline; private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Streaming.StreamingController? _streamingController; private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; @@ -1630,9 +1597,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext int centerX = (int)((centerLandblockId >> 24) & 0xFFu); int centerY = (int)((centerLandblockId >> 16) & 0xFFu); - // Build blending context from the terrain atlas. Stored as fields so - // ApplyLoadedTerrain (render-thread callback invoked per streamed lb) - // can call LandblockMesh.Build without re-deriving these every time. + // Build blending context from the terrain atlas. The worker-side + // LandblockBuildFactory captures this immutable table for each build. var terrainTypeToLayerBytes = new Dictionary(terrainAtlas.TerrainTypeToLayer.Count); foreach (var kvp in terrainAtlas.TerrainTypeToLayer) terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value; @@ -2410,7 +2376,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer( _gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum); _envCellRenderer.Initialize(_meshShader!); - _landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher( + var landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher( publishTerrain: (landblockId, meshData, origin) => _terrain!.AddLandblockWithMesh(landblockId, meshData, origin), removeTerrain: landblockId => _terrain!.RemoveLandblock(landblockId), @@ -2422,16 +2388,34 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext build, _wbMeshAdapter.MeshManager!), removeEnvCells: _envCellRenderer.RemoveLandblock); - _landblockPhysicsPublisher = + var landblockPhysicsPublisher = new AcDream.App.Streaming.LandblockPhysicsPublisher( _physicsEngine, _heightTable!); - _landblockStaticPresentationPublisher = + var landblockStaticPresentationPublisher = new AcDream.App.Streaming.LandblockStaticPresentationPublisher( _lightingSink!, _translucencyFades, _worldGameState, _worldEvents); + var landblockRetirementOwner = + new AcDream.App.Streaming.LandblockPresentationRetirementOwner( + landblockRenderPublisher, + landblockPhysicsPublisher, + landblockStaticPresentationPublisher, + _lightingSink!, + _translucencyFades); + _landblockPresentationPipeline = + new AcDream.App.Streaming.LandblockPresentationPipeline( + landblockRenderPublisher, + landblockPhysicsPublisher, + landblockStaticPresentationPublisher, + _worldState, + landblockRetirementOwner, + onLandblockLoaded: loadedLandblockId => + _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), + ensureEnvCellMeshes: + landblockRenderPublisher.PrepareAfterRenderPins); _clipFrame ??= ClipFrame.NoClip(); _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( @@ -2523,24 +2507,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext }); _streamer.Start(); - var landblockRetirementOwner = - new AcDream.App.Streaming.LandblockPresentationRetirementOwner( - _landblockRenderPublisher!, - _landblockPhysicsPublisher!, - _landblockStaticPresentationPublisher!, - _lightingSink!, - _translucencyFades); - var landblockPresentationPipeline = - new AcDream.App.Streaming.LandblockPresentationPipeline( - _landblockRenderPublisher!, - _landblockPhysicsPublisher!, - _landblockStaticPresentationPublisher!, - _worldState, - landblockRetirementOwner, - onLandblockLoaded: loadedLandblockId => - _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), - ensureEnvCellMeshes: - _landblockRenderPublisher!.PrepareAfterRenderPins); _streamingController = new AcDream.App.Streaming.StreamingController( enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad( new AcDream.App.Streaming.LandblockBuildRequest( @@ -2559,7 +2525,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Retained-object recovery runs after each // (dungeon-exit expand or Far→Near promote). ACE won't re-send the // objects it still considers known. - presentationPipeline: landblockPresentationPipeline); + presentationPipeline: _landblockPresentationPipeline!); + _streamingOriginRecenter = + new AcDream.App.Streaming.StreamingOriginRecenterCoordinator( + _streamingController, + _liveWorldOrigin); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; _worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator( @@ -3297,6 +3267,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private readonly TeleportViewPlaneController _teleportViewPlane = new(); private readonly AcDream.App.Streaming.TeleportTransitCoordinator< AcDream.Core.Net.WorldSession.EntityPositionUpdate> _teleportTransit = new(); + private AcDream.App.Streaming.StreamingOriginRecenterCoordinator? + _streamingOriginRecenter; private System.Numerics.Vector3 _pendingTeleportPos; private uint _pendingTeleportCell; private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout) @@ -3365,22 +3337,21 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext System.Numerics.Vector3 newWorldPos; if (streamingCenterChanges) { - // #145: drop the stale STREAMING center landblock from the physics engine - // BEFORE recentering. The destination loads at world-offset (0,0) (the new - // center), but the prior center was ALSO loaded at offset (0,0) and its - // offset is never re-based — so the two overlap, and the Z-agnostic outdoor - // cell-snap resolves the player into the stale center. On a replacement this - // can be the abandoned first destination, not the unplaced player's source. - _landblockPhysicsPublisher!.RemoveLandblock(streamingOriginLandblockId); - - _liveWorldOrigin.Recenter(lbX, lbY); + // #145: retire the complete old streaming window while its shared + // origin is still current. Destination streaming remains blocked + // until every retained presentation ticket converges. This prevents + // old terrain and collision, which were baked in the prior frame, + // from overlapping the destination after the origin changes. + var recenter = _streamingOriginRecenter + ?? throw new InvalidOperationException( + "A teleport changed streaming center before the recenter coordinator was wired."); + recenter.Begin( + lbX, + lbY, + IsSealedDungeonCell(p.LandblockId)); newWorldPos = new System.Numerics.Vector3( p.PositionX, p.PositionY, p.PositionZ); - if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId)) - _streamingController.PreCollapseToDungeon(lbX, lbY); - else - _streamingController?.ForceReloadWindow(); } else { @@ -3418,6 +3389,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// private void ResetTeleportTransitState(bool clearSession = false) { + bool originRecenterConverged = + _streamingOriginRecenter?.Reset(sessionEnding: clearSession) ?? true; if (clearSession) { _teleportTransit.ClearSession(); @@ -3440,6 +3413,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _streamingController.PriorityLandblockId = 0u; _streamingController.PriorityRadius = 0; } + + if (clearSession && !originRecenterConverged) + { + throw new InvalidOperationException( + "The ending session's streaming-origin retirement has not converged."); + } } // #145: the LANDBLOCK-relative (cell-local) position used to SEED the player @@ -3801,183 +3780,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext $"live: AdminEnvirons sound cue = {name} " + $"(0x{environChangeType:X2}) — audio binding pending"); } - /// - /// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick - /// whenever a new landblock's terrain + entities are ready for GPU upload. - /// Phase A.5 T12: the worker pre-builds off the - /// render thread via ; - /// this callback no longer pays that CPU cost. - /// Must only be called from the render thread. - /// - private void ApplyLoadedTerrain(AcDream.App.Streaming.LandblockBuild build, - AcDream.Core.Terrain.LandblockMeshData meshData) - { - var lb = build.Landblock; - if (_terrain is null || _dats is null) return; - AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("APPLY", lb.LandblockId); - - // datLock fix (2026-06-23): ApplyLoadedTerrainLocked now makes ZERO - // DatCollection calls — every dat it needs was pre-read by the streaming - // worker into lb.PhysicsDats. Its remaining mutations are update-thread- - // only (physics engine, ShadowObjects, renderer, _worldState) or already - // concurrent-safe (PhysicsDataCache is ConcurrentDictionary). _datLock's - // only cross-thread job was serializing DatCollection, so with no Get call - // here the lock is unnecessary — removing it eliminates the measured - // 24ms-median / 88ms-p95 lockwait stall (the FPS 30↔200 swing). The method - // keeps its historical "Locked" name; the worker still serializes its OWN - // dat reads on _datLock — only the apply stops contending for it. - // [FRAME-DIAG]: lockwait now measures ~0 — the before/after proof. - long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; - if (_frameDiag) - _applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0; - try - { - ApplyLoadedTerrainLocked(build, meshData); - } - catch (AcDream.App.Streaming.StreamingMutationException) - { - throw; - } - catch (Exception error) - { - // This legacy body spans multiple concrete owners and cannot yet - // identify an exact failed suffix. Conservatively report that a - // prefix may have committed so the transaction coordinator never - // replays terrain/cells/collision/lights/plugin events. Slice-5 - // checkpoints C-E replace this wrapper with per-owner receipts. - throw new AcDream.App.Streaming.StreamingMutationException( - $"Landblock 0x{lb.LandblockId:X8} presentation failed after an unknown commit point.", - mutationCommitted: true, - error); - } - if (_frameDiag) - { - _applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0; - _appliesThisUpdate++; - } - } - - private void PublishLandblockStaticLightingBeforeCollision( - AcDream.Core.World.WorldEntity entity, - AcDream.Core.World.PhysicsDatBundle datBundle) - { - if (_lightingSink is null || _dats is null) - return; - - uint sourceId = entity.SourceGfxObjOrSetupId; - if ((sourceId & 0xFF000000u) != 0x02000000u) - return; - if (!datBundle.Setups.TryGetValue(sourceId, out var setup) - || setup.Lights.Count == 0) - { - return; - } - - // A landblock can reapply without an unload (#168 ForceReloadWindow - // and Far-to-Near promotion). Static IDs are deterministic, so replace - // the prior owner state before registering the Setup lights. - uint effectOwnerId = entity.Id; - _lightingSink.UnregisterOwner(effectOwnerId); - _translucencyFades.ClearEntity(effectOwnerId); - var lights = AcDream.Core.Lighting.LightInfoLoader.Load( - setup, - ownerId: effectOwnerId, - entityPosition: entity.Position, - entityRotation: entity.Rotation, - cellId: entity.ParentCellId ?? 0u); - foreach (var light in lights) - _lightingSink.RegisterOwnedLight(light); - } - - private void ApplyLoadedTerrainLocked( - AcDream.App.Streaming.LandblockBuild build, - AcDream.Core.Terrain.LandblockMeshData meshData) - { - AcDream.Core.World.LoadedLandblock landblock = build.Landblock; - if (_landblockRenderPublisher is null || _landblockPhysicsPublisher is null) - return; - - AcDream.Core.World.PhysicsDatBundle datBundle = - landblock.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty; - AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderBefore = - _landblockRenderPublisher.Diagnostics; - AcDream.App.Streaming.LandblockRenderPublication renderPublication = - _landblockRenderPublisher.BeginPublication(build, meshData); - System.Numerics.Vector3 origin = renderPublication.Origin; - - AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderPrefix = - _landblockRenderPublisher.Diagnostics; - if (_frameDiag) - { - long terrainTicks = - renderPrefix.TerrainPublishTicks - renderBefore.TerrainPublishTicks; - _applyUploadAccumTicks += terrainTicks; - _applyCellAccumTicks += - renderPrefix.BeginPublishTicks - - renderBefore.BeginPublishTicks - - terrainTicks; - } - - AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsBefore = - _landblockPhysicsPublisher.Diagnostics; - AcDream.App.Streaming.LandblockPhysicsPublication physicsPublication = - _landblockPhysicsPublisher.BeginPublication(renderPublication); - - // Retail initializes buildings/visible cells before ordinary static - // collision and scripts. The two receipts retain one captured origin - // while each focused owner commits its exact stage. - _landblockRenderPublisher.CompletePublication(renderPublication); - _landblockPhysicsPublisher.CompletePublication( - physicsPublication, - entity => PublishLandblockStaticLightingBeforeCollision( - entity, - datBundle)); - - if (_frameDiag) - { - AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsAfter = - _landblockPhysicsPublisher.Diagnostics; - AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderAfter = - _landblockRenderPublisher.Diagnostics; - long gfxCacheTicks = - physicsAfter.GfxCacheTicks - physicsBefore.GfxCacheTicks; - _applyCellAccumTicks += - physicsAfter.BasePublishTicks - physicsBefore.BasePublishTicks; - _applyCellAccumTicks += - renderAfter.CompletePublishTicks - - renderPrefix.CompletePublishTicks; - _applyBspAccumTicks += gfxCacheTicks; - _applyShadowAccumTicks += - physicsAfter.CompletePublishTicks - - physicsBefore.CompletePublishTicks - - gfxCacheTicks; - } - - long pluginStarted = - _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; - foreach (AcDream.Core.World.WorldEntity entity in landblock.Entities) - { - // Live objects publish through LiveEntityRuntime exactly once per - // accepted incarnation. This callback owns DAT statics only. - if (entity.ServerGuid != 0) - continue; - - var snapshot = - new AcDream.Plugin.Abstractions.WorldEntitySnapshot( - Id: entity.Id, - SourceId: entity.SourceGfxObjOrSetupId, - Position: entity.Position, - Rotation: entity.Rotation); - _worldGameState.Add(snapshot); - _worldEvents.FireEntitySpawned(snapshot); - } - if (_frameDiag) - { - _applyShadowAccumTicks += - System.Diagnostics.Stopwatch.GetTimestamp() - pluginStarted; - } - } - private void OnUpdate(double dt) { using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update); @@ -4009,31 +3811,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _physicsScriptGameTime += frameSeconds; _scriptRunner?.PublishTime(_physicsScriptGameTime); - // [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick; - // flush the PREVIOUS OnUpdate's accumulated apply cost here (a clean per-frame - // boundary, independent of where in OnUpdate the applies landed) and reset. - if (_frameDiag) - { - if (_appliesThisUpdate > 0) - { - FrameDiagPush(_applyCpuSamples, ref _applyCpuSampleCursor, _applyAccumTicks); - FrameDiagPush(_applyUploadSamples, ref _applyUploadSampleCursor, _applyUploadAccumTicks); - FrameDiagPush(_applyCellSamples, ref _applyCellSampleCursor, _applyCellAccumTicks); - FrameDiagPush(_applyBspSamples, ref _applyBspSampleCursor, _applyBspAccumTicks); - FrameDiagPush(_applyShadowSamples, ref _applyShadowSampleCursor, _applyShadowAccumTicks); - FrameDiagPush(_applyLockWaitSamples, ref _applyLockWaitSampleCursor, _applyLockWaitAccumTicks); - if (_appliesThisUpdate > _frameDiagMaxAppliesPerUpdate) - _frameDiagMaxAppliesPerUpdate = _appliesThisUpdate; - } - _applyAccumTicks = 0; - _applyUploadAccumTicks = 0; - _applyCellAccumTicks = 0; - _applyBspAccumTicks = 0; - _applyShadowAccumTicks = 0; - _applyLockWaitAccumTicks = 0; - _appliesThisUpdate = 0; - } - // Phase A.1: advance the streaming controller FIRST so the initial // landblocks are loaded into GpuWorldState before live-session // CreateObject events drain. The earlier order (live tick first, @@ -4059,6 +3836,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // The world-geometry RENDER gate (IsLiveModeWaitingForLogin at the // draw site) is unchanged — the pre-entry screen still shows sky // only. + // Recenter cancellation/session-reset requests must keep converging + // even when ordinary streaming is temporarily gated off. This call is + // a no-op outside a pending teleport or session-boundary origin + // retirement transaction. + _streamingOriginRecenter?.Advance(); + bool liveInWorld = _liveSessionController?.IsInWorld == true; // #192: liveInWorld alone used to be sufficient here — but InWorld fires // right after the login handshake, before the player's own spawn @@ -4078,10 +3861,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace) { // Teleport hold (#135): the local player position is frozen at the - // PRE-teleport spot, expressed in the OLD center frame, but - // _liveCenterX/_liveCenterY were already recentered onto the - // destination landblock (OnLivePositionUpdated). Follow the - // destination directly — the stale position-derived offset + // PRE-teleport spot, expressed in the OLD center frame. The + // origin coordinator commits _liveCenterX/Y to the destination + // only after old-window presentation retirement converges; the + // streaming controller remains gated until that edge. Follow + // the active shared origin directly — the stale position-derived offset // (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off // the dungeon and trip ExitDungeonExpand, re-streaming the very // neighbor window the pre-collapse just suppressed. Correct for an @@ -4266,8 +4050,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext if (_teleportTransit.IsActive) { bool haveDest = _pendingTeleportCell != 0u; - bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell); - if (haveDest && !ready) + bool originReady = _streamingOriginRecenter?.IsPending != true; + bool ready = haveDest + && originReady + && TeleportWorldReady(_pendingTeleportCell); + if (haveDest && originReady && !ready) { _teleportHoldSeconds += frameDelta; if (_teleportHoldSeconds >= TeleportMaxHoldSeconds) @@ -5059,7 +4846,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // routes interior-root look-ins to its landscape-stage sub-pass // (DrawBuildingLookIns); the root's own building self-excludes // via the seed eye-side test. - foreach (var registry in _landblockRenderPublisher?.BuildingRegistries + foreach (var registry in _landblockPresentationPipeline?.BuildingRegistries ?? Array.Empty()) { foreach (var b in registry.All()) @@ -8712,43 +8499,38 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext $"loaded={_terrain?.LoadedSlots ?? 0} " + $"capacity={_terrain?.CapacitySlots ?? 0}"); - // [FRAME-DIAG]: the FPS-deep-dive per-frame cost split + leak/churn counters. - // apply_us = terrain-apply CPU in OnUpdate (dat reads + physics/ShadowObjects - // registration + terrain upload) — INVISIBLE to the title-bar ms (H1). - // upl_us = the terrain glBufferSubData sub-span of that apply (expected tiny). - // entUpl_us= the OnRender _wbMeshAdapter.Tick entity-mesh GPU drain (H2). - // For entity-DRAW CPU/GPU see the [WB-DIAG] line; walked = resident-N proxy (H3). - double applyMedUs = TerrainDiagMedianMicros(_applyCpuSamples) / 100.0; - double applyP95Us = TerrainDiagPercentile95Micros(_applyCpuSamples) / 100.0; - double uplMedUs = TerrainDiagMedianMicros(_applyUploadSamples) / 100.0; - double uplP95Us = TerrainDiagPercentile95Micros(_applyUploadSamples) / 100.0; + // Publication metrics are cumulative typed snapshots from the focused + // owners. The only GameWindow-local distribution left is the render- + // thread entity upload drain. + AcDream.App.Streaming.LandblockPresentationDiagnostics presentation = + _landblockPresentationPipeline?.Diagnostics ?? default; + AcDream.App.Streaming.LandblockRenderPublisherDiagnostics render = + presentation.Render; + AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physics = + presentation.Physics; + AcDream.App.Streaming.LandblockStaticPresentationDiagnostics statics = + presentation.Statics; + double ticksToMicros = + 1_000_000.0 / System.Diagnostics.Stopwatch.Frequency; double entUplMedUs = TerrainDiagMedianMicros(_entityUploadSamples) / 100.0; double entUplP95Us = TerrainDiagPercentile95Micros(_entityUploadSamples) / 100.0; - // apply CPU split: cell-build / gfxobj-BSP / ShadowObjects+lights — names - // WHICH part of the apply dominates (decides the fix shape). - double cellMedUs = TerrainDiagMedianMicros(_applyCellSamples) / 100.0; - double cellP95Us = TerrainDiagPercentile95Micros(_applyCellSamples) / 100.0; - double bspMedUs = TerrainDiagMedianMicros(_applyBspSamples) / 100.0; - double bspP95Us = TerrainDiagPercentile95Micros(_applyBspSamples) / 100.0; - double shadMedUs = TerrainDiagMedianMicros(_applyShadowSamples) / 100.0; - double shadP95Us = TerrainDiagPercentile95Micros(_applyShadowSamples) / 100.0; - double lockMedUs = TerrainDiagMedianMicros(_applyLockWaitSamples) / 100.0; - double lockP95Us = TerrainDiagPercentile95Micros(_applyLockWaitSamples) / 100.0; int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0; Console.WriteLine( - $"[FRAME-DIAG] apply_us={applyMedUs:F1}m/{applyP95Us:F1}p95 " + - $"lockwait={lockMedUs:F1}m/{lockP95Us:F1}p95 " + - $"[cell={cellMedUs:F1}m/{cellP95Us:F1}p95 bsp={bspMedUs:F1}m/{bspP95Us:F1}p95 " + - $"shadow={shadMedUs:F1}m/{shadP95Us:F1}p95] " + - $"(upl={uplMedUs:F1}m/{uplP95Us:F1}p95) " + + $"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} " + + $"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} " + + $"begin={render.BeginPublishTicks * ticksToMicros:F1} " + + $"complete={render.CompletePublishTicks * ticksToMicros:F1}] " + + $"physics_total_us=[base={physics.BasePublishTicks * ticksToMicros:F1} " + + $"gfx={physics.GfxCacheTicks * ticksToMicros:F1} " + + $"complete={physics.CompletePublishTicks * ticksToMicros:F1}] " + + $"static={statics.BeginCount}/{statics.CompleteCount}" + + $"(active={statics.ActiveEntityCount}) " + $"entUpl_us={entUplMedUs:F1}m/{entUplP95Us:F1}p95 " + - $"applies_max/upd={_frameDiagMaxAppliesPerUpdate} " + $"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " + - $"forceReload={_streamingController?.ForceReloadCount ?? 0}" + - $"(drop={_streamingController?.LastForceReloadDropCount ?? 0}) " + + $"fullRetire={_streamingController?.FullWindowRetirementCount ?? 0}" + + $"(landblocks={_streamingController?.LastFullWindowRetirementLandblockCount ?? 0}) " + $"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " + $"resident={_worldState.Entities.Count} walked={walked}"); - _frameDiagMaxAppliesPerUpdate = 0; // reset the per-window burst tracker _terrainLastDiagTick = now; } diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs index 1fc1903a..6b46beea 100644 --- a/src/AcDream.App/Streaming/LandblockBuildFactory.cs +++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs @@ -129,8 +129,8 @@ public sealed class LandblockBuildFactory // Hydrate the stabs: same logic as the old OnLoad preload. Each stab // entity from LandblockLoader carries a SourceGfxObjOrSetupId that we // expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build. - // GPU upload (EnsureUploaded) happens on the render thread in - // ApplyLoadedTerrain — NOT here. + // GPU upload happens later through the render-thread presentation + // pipeline, never in this worker-side build. var hydrated = new List(baseLoaded.Entities.Count); foreach (var e in baseLoaded.Entities) { @@ -364,7 +364,8 @@ public sealed class LandblockBuildFactory if (gfx is not null) { _physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx); - // Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain. + // Sub-meshes are CPU-built here; the presentation pipeline + // defers upload to the render thread. _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value); @@ -417,9 +418,8 @@ public sealed class LandblockBuildFactory // _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at // build time this landblock is NOT registered in physics yet, so that query // could only return null (→ this same own-heightmap) or a STALE neighbor's - // height — the previous location's terrain, still registered after a teleport - // recenter (which drops only the single stale CENTER landblock, GameWindow - // :5444) until streaming unloads it — planting scenery at the old location's + // height — the previous location's terrain before the full old-window + // recenter retirement converges — planting scenery at the old location's // altitude (trees-in-sky, deltaZ up to +500m; confirmed via the // [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every // case, so the global query is removed (also drops its per-spawn cost). @@ -526,7 +526,7 @@ public sealed class LandblockBuildFactory /// Portal cells and drawable shell placements are accumulated in the /// transaction-local . The render thread /// cannot observe this landblock until the streaming completion carries the - /// finished transaction to ApplyLoadedTerrain. + /// finished transaction to LandblockPresentationPipeline. /// /// Ported from pre-streaming preload lines 407-565. /// @@ -683,8 +683,8 @@ public sealed class LandblockBuildFactory // Setup carries real Lights — a "light attach point" fixture (e.g. the Town // Network fountain room's ceiling light, Setup 0x02000365). Track the dat // Setup's Lights.Count here so the meshRefs==0 gate below doesn't also drop - // the entity that would otherwise carry those lights to the registration - // pass (GameWindow.cs ~7900). + // the entity that otherwise carries those lights to the static + // presentation publisher. int stabLightCount = 0; if ((stab.Id & 0xFF000000u) == 0x01000000u) { diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index c13e21ea..d107db85 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -326,9 +326,9 @@ public sealed class LandblockPhysicsPublisher /// /// Publishes ordinary static collision and refloods after the caller has - /// completed the render-owned building/EnvCell suffix. The callback keeps - /// the shipped per-entity light-before-collision order until checkpoint E - /// moves static presentation into the transaction coordinator. + /// completed the render-owned building/EnvCell suffix. The concrete + /// presentation pipeline supplies the static-presentation owner callback + /// that preserves per-entity light-before-collision order. /// public void CompletePublication( LandblockPhysicsPublication publication, diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 640ebb6a..9139b85d 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -4,14 +4,23 @@ using AcDream.Core.World; namespace AcDream.App.Streaming; +/// +/// Read-only presentation state needed by render traversal and diagnostics. +/// Keeping this projection on the pipeline prevents the composition root from +/// retaining each concrete publication owner independently. +/// +public readonly record struct LandblockPresentationDiagnostics( + LandblockRenderPublisherDiagnostics Render, + LandblockPhysicsPublisherDiagnostics Physics, + LandblockStaticPresentationDiagnostics Statics); + /// /// Render-thread transaction coordinator for one accepted landblock streaming /// result. Streaming policy (desired tier, generation, priority, and apply /// budget) remains in ; spatial buckets remain /// in . This owner fixes the presentation ordering -/// independently of those policies so the concrete render, physics, and static -/// publishers can be extracted behind it without returning callbacks to the -/// game window. +/// independently of those policies. The concrete render, physics, and static +/// publishers remain private implementation participants behind this boundary. /// /// /// Retail establishes cells/buildings before static objects in @@ -19,9 +28,9 @@ namespace AcDream.App.Streaming; /// CLandBlock::release_all @ 0x0052FCF0 (objects and visible cells) from /// later destruction in CLandBlock::Destroy @ 0x0052FAA0 (static objects /// and buildings). Acdream's detach-first retry ledger is the existing -/// asynchronous lifetime adaptation. The injected publication operation -/// currently contains the already-shipped production transaction; later -/// Slice-5 checkpoints replace it with focused publishers. +/// asynchronous lifetime adaptation. Production always uses the focused owner +/// graph; the internal delegate constructor exists only for hermetic policy and +/// retry characterization tests. /// public sealed class LandblockPresentationPipeline { @@ -63,7 +72,7 @@ public sealed class LandblockPresentationPipeline private readonly Dictionary _publications = new(ReferenceEqualityComparer.Instance); - public LandblockPresentationPipeline( + internal LandblockPresentationPipeline( Action publishBeforeSpatialCommit, GpuWorldState state, Action? onLandblockLoaded = null, @@ -137,6 +146,21 @@ public sealed class LandblockPresentationPipeline retirementOwner.Advance); } + /// + /// Building visibility registries published by the render owner. Legacy + /// test pipelines have no render owner and therefore expose an empty view. + /// + public IReadOnlyCollection BuildingRegistries => + _renderPublisher?.BuildingRegistries ?? Array.Empty(); + + /// + /// Cumulative diagnostics from the three concrete presentation owners. + /// + public LandblockPresentationDiagnostics Diagnostics => new( + _renderPublisher?.Diagnostics ?? default, + _physicsPublisher?.Diagnostics ?? default, + _staticPublisher?.Diagnostics ?? default); + public int PendingRetirementCount => _retirements.PendingCount; public int PendingPublicationCount => _publications.Count; diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 0d76a543..0581647d 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -18,6 +18,21 @@ namespace AcDream.App.Streaming; /// public sealed class StreamingController { + private sealed class OriginRecenterRetirement + { + public bool RadiiConverged; + public bool GenerationAdvanced; + public bool PendingLoadsCleared; + public bool DeferredApplyCleared; + public bool RegionCleared; + public List? ResidentIds; + public int RetirementCursor; + public bool PreparationCommitted; + public (int X, int Y, bool IsSealedDungeon)? Destination; + public bool DestinationConfigured; + public bool DestinationLoadEnqueued; + } + private readonly Action _enqueueLoad; private readonly Action _enqueueUnload; private readonly Func> _drainCompletions; @@ -29,6 +44,8 @@ public sealed class StreamingController private RadiiReconfiguration? _pendingRadiiReconfiguration; private bool _advancingRadiiReconfiguration; private (int NearRadius, int FarRadius)? _deferredRadiiRequest; + private OriginRecenterRetirement? _originRecenterRetirement; + private bool _advancingOriginRecenter; // Hard streaming boundaries advance this token. Worker completions carry // the generation captured at enqueue time, so an old overlapping load or // unload cannot mutate the replacement window after a portal recenter. @@ -116,6 +133,7 @@ public sealed class StreamingController // GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface. public int DeferredApplyBacklog => _deferredApply.Count; public int PendingRetirementCount => _presentation.PendingRetirementCount; + internal bool IsCollapsedToDungeon => _collapsed; /// /// True once every in-bounds landblock in the requested Chebyshev ring has @@ -154,15 +172,21 @@ public sealed class StreamingController return true; } - // [FRAME-DIAG]: how many times ForceReloadWindow has fired (each one drops + - // re-uploads the WHOLE window) and the landblock count it dropped last time. - public int ForceReloadCount { get; private set; } - public int LastForceReloadDropCount { get; private set; } + // [FRAME-DIAG]: full-window presentation retirements include explicit + // reloads and shared-origin recenter barriers. Both retire and later + // re-upload the complete resident window. + public int FullWindowRetirementCount { get; private set; } + public int LastFullWindowRetirementLandblockCount { get; private set; } // Completions that were drained past a priority item get buffered here // so they still apply over subsequent frames without loss. private readonly List _deferredApply = new(); - public StreamingController( + /// + /// Internal compatibility seam for hermetic controller policy tests. Live + /// composition cannot supply presentation callbacks; it must use the + /// concrete pipeline constructor below. + /// + internal StreamingController( Action enqueueLoad, Action enqueueUnload, Func> drainCompletions, @@ -245,6 +269,15 @@ public sealed class StreamingController nameof(farRadius), "Far radius must be greater than or equal to near radius."); + // A radius transaction may not admit work after an origin-recenter + // snapshot has been captured. Retain only the latest requested radii; + // the first Tick after origin commit applies them to the new frame. + if (_originRecenterRetirement is not null) + { + _deferredRadiiRequest = (nearRadius, farRadius); + return; + } + // Queue callbacks are injected seams and can synchronously reenter the // controller. Last-request-wins deferral keeps the active mutation // cursor stable; the request is applied after the current transaction @@ -427,6 +460,12 @@ public sealed class StreamingController /// public void Tick(int observerCx, int observerCy, bool insideDungeon = false) { + if (_originRecenterRetirement is not null) + { + _presentation.AdvanceRetirements(); + return; + } + if (_pendingRadiiReconfiguration is not null) AdvanceRadiiReconfiguration(); else if (_deferredRadiiRequest is { } deferred) @@ -762,18 +801,262 @@ public sealed class StreamingController } /// - /// 2026-06-22: an OUTDOOR teleport moved the render origin (_liveCenter). Every - /// resident terrain block was baked relative to the OLD origin, so any block that survives - /// an INCREMENTAL recenter — which happens on a NEARBY jump where the old and new windows - /// overlap, e.g. (170,168)→(169,180) — renders shifted by the jump distance: the confirmed - /// "terrain in the sky" arcs (the stale slots were all offset by exactly deltaLB×192). - /// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none - /// survives into the next frame stale, and no async unload can race a re-bake, then null - /// the region so the next re-bootstraps the WHOLE window fresh at - /// the new origin (the near ring is priority-applied during portal travel; the rest streams in). - /// A sealed-dungeon destination uses instead. + /// Starts a reload of the current streaming window. Logical residency is + /// detached immediately; render, physics, and static-lighting owners then + /// converge through the retained presentation-retirement ledger before + /// bootstraps the window again. + /// Shared-origin teleports use instead so + /// every old-window owner retires while the old coordinate frame is active. /// public void ForceReloadWindow() + { + BeginFullWindowRetirement(); + } + + /// + /// Starts the old-window half of a shared-origin recenter. All accepted + /// worker work is invalidated and every resident landblock is detached + /// through the retryable presentation ledger while the old coordinate + /// frame is still active. remains blocked until + /// succeeds. + /// + internal void BeginOriginRecenter() + { + _originRecenterRetirement ??= new OriginRecenterRetirement(); + } + + /// + /// Advances every retained old-window teardown and reports whether the + /// composition root may safely change the shared world origin. + /// + internal bool IsOriginRecenterRetirementComplete() + { + if (_originRecenterRetirement is null) + throw new InvalidOperationException( + "No streaming-origin recenter transaction is pending."); + + if (!TryAdvanceOriginRecenterPreparation()) + return false; + _presentation.AdvanceRetirements(); + return _presentation.PendingRetirementCount == 0; + } + + /// + /// Releases the streaming bootstrap gate after the composition root has + /// committed the new shared origin. No old-window presentation owner may + /// still be pending at this edge. + /// + internal bool TryCommitOriginRecenter( + int destinationX, + int destinationY, + bool isSealedDungeon) + { + if (_advancingOriginRecenter) + return false; + + _advancingOriginRecenter = true; + try + { + return TryCommitOriginRecenterCore( + destinationX, + destinationY, + isSealedDungeon); + } + finally + { + _advancingOriginRecenter = false; + } + } + + private bool TryCommitOriginRecenterCore( + int destinationX, + int destinationY, + bool isSealedDungeon) + { + OriginRecenterRetirement transaction = _originRecenterRetirement + ?? throw new InvalidOperationException( + "No streaming-origin recenter transaction is pending."); + if (!transaction.PreparationCommitted) + throw new InvalidOperationException( + "Streaming-origin retirement preparation has not completed."); + if (_presentation.PendingRetirementCount != 0) + throw new InvalidOperationException( + "The streaming origin cannot change while old-window presentation retirement is pending."); + + var destination = (destinationX, destinationY, isSealedDungeon); + if (transaction.Destination is { } retained && retained != destination) + { + throw new InvalidOperationException( + "A recenter transaction cannot commit two different destinations."); + } + transaction.Destination ??= destination; + + if (!transaction.DestinationConfigured) + { + _collapsed = isSealedDungeon; + _collapsedCenter = isSealedDungeon + ? StreamingRegion.EncodeLandblockId(destinationX, destinationY) + : 0u; + if (isSealedDungeon) + { + _region = new StreamingRegion( + destinationX, + destinationY, + nearRadius: 0, + farRadius: 0); + _region.MarkResidentFromBootstrap(); + } + else + { + _region = null; + } + transaction.DestinationConfigured = true; + } + + if (isSealedDungeon && !transaction.DestinationLoadEnqueued) + { + try + { + EnqueueLoad( + StreamingRegion.EncodeLandblockId(destinationX, destinationY), + LandblockStreamJobKind.LoadNear); + transaction.DestinationLoadEnqueued = true; + } + catch (StreamingMutationException error) when (error.MutationCommitted) + { + transaction.DestinationLoadEnqueued = true; + Console.WriteLine( + $"streaming: committed dungeon recenter enqueue reported failure: {error}"); + return false; + } + catch (Exception error) + { + Console.WriteLine( + $"streaming: dungeon recenter enqueue will resume: {error}"); + return false; + } + } + + _originRecenterRetirement = null; + return true; + } + + /// + /// Releases a fully retired origin transaction at a session boundary + /// without bootstrapping a destination from the ending session. + /// + internal bool TryCancelOriginRecenter() + { + if (_originRecenterRetirement is not { PreparationCommitted: true }) + return false; + if (_presentation.PendingRetirementCount != 0) + return false; + + _collapsed = false; + _collapsedCenter = 0u; + _region = null; + _originRecenterRetirement = null; + return true; + } + + private bool TryAdvanceOriginRecenterPreparation() + { + OriginRecenterRetirement transaction = _originRecenterRetirement + ?? throw new InvalidOperationException( + "No streaming-origin recenter transaction is pending."); + if (transaction.PreparationCommitted) + return true; + if (_advancingOriginRecenter) + return false; + + _advancingOriginRecenter = true; + try + { + if (!transaction.RadiiConverged) + { + if (_pendingRadiiReconfiguration is not null) + AdvanceRadiiReconfiguration(); + if (_pendingRadiiReconfiguration is not null) + return false; + transaction.RadiiConverged = true; + } + if (!transaction.GenerationAdvanced) + { + AdvanceGeneration(); + transaction.GenerationAdvanced = true; + } + if (!transaction.PendingLoadsCleared) + { + try + { + _clearPendingLoads?.Invoke(); + transaction.PendingLoadsCleared = true; + } + catch (StreamingMutationException error) when (error.MutationCommitted) + { + transaction.PendingLoadsCleared = true; + Console.WriteLine( + $"streaming: committed pending-load clear reported failure: {error}"); + return false; + } + } + if (!transaction.DeferredApplyCleared) + { + _deferredApply.Clear(); + transaction.DeferredApplyCleared = true; + } + if (!transaction.RegionCleared) + { + _collapsed = false; + _region = null; + transaction.RegionCleared = true; + } + if (transaction.ResidentIds is null) + { + transaction.ResidentIds = new List(_state.LoadedLandblockIds); + FullWindowRetirementCount++; + LastFullWindowRetirementLandblockCount = transaction.ResidentIds.Count; + } + + while (transaction.RetirementCursor < transaction.ResidentIds.Count) + { + uint id = transaction.ResidentIds[transaction.RetirementCursor]; + try + { + _presentation.BeginFullRetirement(id); + transaction.RetirementCursor++; + } + catch (Exception error) + { + // A delivered visibility-observer failure may surface after + // detachment has committed. Advance that exact cursor only + // when world state proves the old resident is unreachable; + // otherwise retain it for the next frame. + if (!_state.IsLoaded(id)) + transaction.RetirementCursor++; + Console.WriteLine( + $"streaming: origin-recenter retirement for 0x{id:X8} " + + $"will resume: {error}"); + return false; + } + } + + transaction.PreparationCommitted = true; + return true; + } + catch (Exception error) + { + Console.WriteLine( + $"streaming: origin-recenter preparation will resume: {error}"); + return false; + } + finally + { + _advancingOriginRecenter = false; + } + } + + private void BeginFullWindowRetirement() { AdvanceGeneration(); _collapsed = false; @@ -785,8 +1068,8 @@ public sealed class StreamingController _region = null; // Snapshot — RemoveLandblock mutates the loaded set we're iterating. var ids = new List(_state.LoadedLandblockIds); - ForceReloadCount++; // [FRAME-DIAG] churn counter - LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume + FullWindowRetirementCount++; // [FRAME-DIAG] churn counter + LastFullWindowRetirementLandblockCount = ids.Count; // upcoming re-upload volume foreach (var id in ids) _presentation.BeginFullRetirement(id); } @@ -838,14 +1121,13 @@ public sealed class StreamingController } catch { - // The presentation pipeline retains its exact committed - // stage after the coarse presentation callback commits. - // Preserve it and every untouched completion from this - // already-drained chunk in original FIFO order. A - // presentation-stage failure is deliberately fail-fast - // until the focused publishers replace that callback; - // its result is not retryable, but the untouched suffix - // must still survive. + // The concrete presentation pipeline retains the exact + // unfinished stage. Preserve it and every untouched + // completion from this already-drained chunk in + // original FIFO order. Internal compatibility tests may + // use a callback pipeline that cannot retain a receipt; + // its failed result is consumed, but the untouched + // suffix must still survive. if (_presentation.HasPendingPublication(result)) _deferredApply.Add(result); for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++) @@ -890,12 +1172,12 @@ public sealed class StreamingController } catch { - // A suffix-stage failure keeps its pipeline receipt and - // must remain at this exact FIFO position. The still- - // monolithic presentation callback deliberately removes - // its receipt on failure because its internal committed - // prefix is unknown; consume only that failed result so a - // later frame cannot replay it automatically. + // A concrete suffix-stage failure keeps its pipeline + // receipt and must remain at this exact FIFO position. The + // internal compatibility callback cannot report its exact + // committed prefix and therefore removes its receipt; + // consume only that failed result so a later frame cannot + // replay it automatically. if (!_presentation.HasPendingPublication(result)) read++; throw; diff --git a/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs b/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs new file mode 100644 index 00000000..73fd4c40 --- /dev/null +++ b/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs @@ -0,0 +1,192 @@ +using AcDream.App.World; + +namespace AcDream.App.Streaming; + +/// +/// Coordinates streaming-origin lifetime boundaries. Presentation must retire +/// the complete old window first; a teleport then changes +/// before destination streaming resumes, +/// while a session boundary releases the controller without recentering. The +/// transaction remains pending across frames when an owner teardown needs retry. +/// +internal sealed class StreamingOriginRecenterCoordinator +{ + private readonly record struct Request( + int DestinationX, + int DestinationY, + bool IsSealedDungeon, + bool CancelAtSessionBoundary = false); + + private readonly StreamingController _streaming; + private readonly LiveWorldOriginState _origin; + private Request? _pending; + private Request? _replacement; + private bool _originCommitted; + private bool _acceptReplacement; + private bool _sourceWasSealedDungeon; + private bool _advancing; + + public StreamingOriginRecenterCoordinator( + StreamingController streaming, + LiveWorldOriginState origin) + { + _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + } + + public bool IsPending => _pending is not null; + + /// + /// Begins a new recenter transaction and performs its first retirement + /// attempt immediately. A teleport replacement must call + /// before supplying a different destination. + /// + public bool Begin(int destinationX, int destinationY, bool isSealedDungeon) + { + var request = new Request(destinationX, destinationY, isSealedDungeon); + if (_pending is { } pending && pending != request) + { + if (!_acceptReplacement) + { + throw new InvalidOperationException( + "A different streaming-origin recenter is already pending."); + } + + _acceptReplacement = false; + if (_originCommitted) + _replacement = request; + else + _pending = request; + } + + if (_pending is null) + { + _pending = request; + _acceptReplacement = false; + _sourceWasSealedDungeon = _streaming.IsCollapsedToDungeon; + _streaming.BeginOriginRecenter(); + } + + return Advance(); + } + + /// + /// Advances retained presentation teardown. Returns true only after the + /// new origin is committed and destination streaming has been unblocked. + /// + public bool Advance() + { + if (_advancing) + return false; + + _advancing = true; + try + { + while (_pending is { } request) + { + if (!_originCommitted) + { + if (!_streaming.IsOriginRecenterRetirementComplete()) + return false; + + if (_pending is not { } current || current != request) + continue; + + // A session-boundary cancellation only releases the old + // controller gate. Session identity owns resetting and + // initializing the next live origin, so an asynchronously + // converging teardown must never overwrite it. + if (!request.CancelAtSessionBoundary) + _origin.Recenter(request.DestinationX, request.DestinationY); + _originCommitted = true; + } + + bool destinationCommitted = request.CancelAtSessionBoundary + ? _streaming.TryCancelOriginRecenter() + : _streaming.TryCommitOriginRecenter( + request.DestinationX, + request.DestinationY, + request.IsSealedDungeon); + if (!destinationCommitted) + return false; + + if (_pending is not { } committed || committed != request) + { + _originCommitted = false; + _streaming.BeginOriginRecenter(); + continue; + } + + _pending = null; + _originCommitted = false; + _acceptReplacement = false; + + if (_replacement is not { } replacement) + return true; + + _replacement = null; + _pending = replacement; + _sourceWasSealedDungeon = _streaming.IsCollapsedToDungeon; + _streaming.BeginOriginRecenter(); + } + + return true; + } + finally + { + _advancing = false; + } + } + + /// + /// Converts an uncommitted request into a same-origin convergence request + /// at a teleport-lifetime boundary. This cannot orphan the controller's + /// bootstrap gate: a replacement may reuse the retained old-window barrier, + /// while cancellation/session reset completes it at the current origin. + /// + public bool Reset(bool sessionEnding = false) + { + if (_pending is null) + { + if (!sessionEnding) + return true; + + // Every character-session boundary invalidates geometry baked in + // the old shared coordinate frame, even when no teleport was in + // flight. Retire the complete window before session identity may + // allow the next player spawn to initialize a different origin. + _pending = new Request( + _origin.CenterX, + _origin.CenterY, + IsSealedDungeon: false, + CancelAtSessionBoundary: true); + _replacement = null; + _originCommitted = false; + _acceptReplacement = false; + _sourceWasSealedDungeon = _streaming.IsCollapsedToDungeon; + _streaming.BeginOriginRecenter(); + return Advance(); + } + + _replacement = null; + _acceptReplacement = true; + if (!_originCommitted) + { + _pending = new Request( + _origin.CenterX, + _origin.CenterY, + IsSealedDungeon: !sessionEnding && _sourceWasSealedDungeon, + CancelAtSessionBoundary: sessionEnding); + } + else if (sessionEnding) + { + _pending = new Request( + _origin.CenterX, + _origin.CenterY, + IsSealedDungeon: false, + CancelAtSessionBoundary: true); + } + + return Advance(); + } +} diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index a2c24799..a11d30b4 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -127,7 +127,8 @@ public sealed class PhysicsEngine DataCache?.RemoveBuildingsForLandblock(landblockId); // #145: if the player's current cell belonged to the landblock being removed (a teleport - // drops the stale source center via OnLivePositionUpdated), clear it. Otherwise CurrCell + // retires the stale source through StreamingOriginRecenterCoordinator and the presentation + // pipeline), clear it. Otherwise CurrCell // dangles on an orphaned cell and the dungeon-streaming gate — keyed on CurrCell — keeps // streaming collapsed onto the gone landblock, so the destination never streams in and // only the skybox renders. Clearing it lets the gate read "not in a dungeon" → the diff --git a/src/AcDream.Core/World/PhysicsDatBundle.cs b/src/AcDream.Core/World/PhysicsDatBundle.cs index 3c7b68ed..42606e2b 100644 --- a/src/AcDream.Core/World/PhysicsDatBundle.cs +++ b/src/AcDream.Core/World/PhysicsDatBundle.cs @@ -6,11 +6,10 @@ using DatEnvironment = DatReaderWriter.DBObjs.Environment; namespace AcDream.Core.World; /// -/// The parsed dat objects ApplyLoadedTerrainLocked needs, pre-read by the -/// streaming worker under _datLock so the apply makes ZERO DatCollection -/// calls and the update thread never blocks on the worker's lock (the FPS -/// 30↔200 swing was that lock-wait). Keyed by the same dat id the apply would -/// have passed to _dats.Get<T>. +/// Parsed DAT closure produced by LandblockBuildFactory under the shared +/// reader gate and consumed by the render-thread presentation publishers. +/// Publication makes zero DatCollection calls, so the update thread never +/// blocks on worker DAT access. Entries retain their original DAT IDs. /// public sealed record PhysicsDatBundle( LandBlockInfo? Info, diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs index cce541e2..9321e78a 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs @@ -101,6 +101,66 @@ public sealed class LiveSessionResetPlanTests Assert.Equal(2, secondCalls); } + [Fact] + public void Execute_BlocksNextSessionUntilOriginRetirementConverges() + { + const uint sourceId = 0x5353FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + sourceId, + new LandBlock(), + Array.Empty())); + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x53, 0x53)); + bool failRetirement = true; + var controller = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + removeTerrain: _ => + { + if (failRetirement) + throw new InvalidOperationException("injected ending-session failure"); + }); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + int identityResets = 0; + var plan = new LiveSessionResetPlan( + [ + new("teleport transit", () => + { + if (!recenter.Reset(sessionEnding: true)) + { + throw new InvalidOperationException( + "streaming-origin retirement remains pending"); + } + }), + new("session identity", () => + { + identityResets++; + origin.Reset(); + }), + ]); + + AggregateException blocked = Assert.Throws(plan.Execute); + LiveSessionResetStageException failure = Assert.IsType( + Assert.Single(blocked.InnerExceptions)); + Assert.Equal("teleport transit", failure.StageName); + Assert.Equal(1, identityResets); + + failRetirement = false; + plan.Execute(); + + Assert.Equal(2, identityResets); + Assert.False(recenter.IsPending); + Assert.False(origin.IsKnown); + Assert.True(origin.TryInitialize(0x61, 0x62)); + Assert.Equal((0x61, 0x62), (origin.CenterX, origin.CenterY)); + } + [Fact] public void Execute_ReentrantAttemptIsReportedButLaterStagesStillRun() { diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs index 9ec20493..9553ea8a 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs @@ -232,7 +232,7 @@ public sealed class LandblockBuildOriginTests } [Fact] - public void BuildFactoryAndGameWindowPublication_UseCapturedOriginInsteadOfLiveCenter() + public void BuildFactoryAndRenderPublisher_UseCapturedOriginWithoutGameWindowFacade() { string root = FindRepoRoot(); string gameWindowSource = File.ReadAllText(Path.Combine( @@ -253,11 +253,12 @@ public sealed class LandblockBuildOriginTests "AcDream.App", "Streaming", "LandblockRenderPublisher.cs")); - string publicationSection = Slice( - gameWindowSource, - "private void ApplyLoadedTerrainLocked(", - "private void OnUpdate(double dt)"); - + string recenterSource = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Streaming", + "StreamingOriginRecenterCoordinator.cs")); Assert.Contains("request.Origin", buildSource, StringComparison.Ordinal); Assert.DoesNotContain("_liveCenterX", buildSource, StringComparison.Ordinal); Assert.DoesNotContain("_liveCenterY", buildSource, StringComparison.Ordinal); @@ -277,12 +278,68 @@ public sealed class LandblockBuildOriginTests "BuildPhysicsDatBundle", gameWindowSource, StringComparison.Ordinal); + Assert.DoesNotContain( + "ApplyLoadedTerrain", + gameWindowSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "PublishLandblockStaticLightingBeforeCollision", + gameWindowSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "_landblockPhysicsPublisher!.RemoveLandblock", + gameWindowSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "_landblockRenderPublisher", + gameWindowSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "_landblockPhysicsPublisher", + gameWindowSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "_landblockStaticPresentationPublisher", + gameWindowSource, + StringComparison.Ordinal); + Assert.Contains( + "_landblockPresentationPipeline", + gameWindowSource, + StringComparison.Ordinal); + Assert.Contains( + "new AcDream.App.Streaming.LandblockRenderPublisher(", + gameWindowSource, + StringComparison.Ordinal); + Assert.Contains( + "new AcDream.App.Streaming.LandblockPhysicsPublisher(", + gameWindowSource, + StringComparison.Ordinal); + Assert.Contains( + "new AcDream.App.Streaming.LandblockStaticPresentationPublisher(", + gameWindowSource, + StringComparison.Ordinal); + Assert.DoesNotContain("applyTerrain:", gameWindowSource, StringComparison.Ordinal); + Assert.DoesNotContain("demoteNearLayer:", gameWindowSource, StringComparison.Ordinal); + Assert.DoesNotContain("retirementCoordinator:", gameWindowSource, StringComparison.Ordinal); + Assert.DoesNotContain( + "_liveWorldOrigin.Recenter(lbX, lbY)", + gameWindowSource, + StringComparison.Ordinal); + int retirementBarrier = recenterSource.IndexOf( + "IsOriginRecenterRetirementComplete()", + StringComparison.Ordinal); + int originCommit = recenterSource.IndexOf( + "_origin.Recenter(", + StringComparison.Ordinal); + int destinationCommit = recenterSource.IndexOf( + "_streaming.TryCommitOriginRecenter(", + StringComparison.Ordinal); + Assert.True(retirementBarrier >= 0); + Assert.True(originCommit > retirementBarrier); + Assert.True(destinationCommit > originCommit); Assert.Contains("ComputeOrigin(landblockId, build.Origin)", renderPublisherSource, StringComparison.Ordinal); Assert.DoesNotContain("_liveCenterX", renderPublisherSource, StringComparison.Ordinal); Assert.DoesNotContain("_liveCenterY", renderPublisherSource, StringComparison.Ordinal); - Assert.Contains("renderPublication.Origin", publicationSection, StringComparison.Ordinal); - Assert.DoesNotContain("_liveCenterX", publicationSection, StringComparison.Ordinal); - Assert.DoesNotContain("_liveCenterY", publicationSection, StringComparison.Ordinal); } private static LandblockBuild EmptyBuild(uint landblockId, LandblockBuildOrigin origin) => @@ -316,15 +373,6 @@ public sealed class LandblockBuildOriginTests return results; } - private static string Slice(string source, string startMarker, string endMarker) - { - int start = source.IndexOf(startMarker, StringComparison.Ordinal); - Assert.True(start >= 0, $"Missing source marker: {startMarker}"); - int end = source.IndexOf(endMarker, start, StringComparison.Ordinal); - Assert.True(end > start, $"Missing source marker: {endMarker}"); - return source[start..end]; - } - private static string FindRepoRoot() { string? dir = AppContext.BaseDirectory; diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs index df731675..a7871301 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -3,6 +3,7 @@ using System.Numerics; using AcDream.App.Rendering; using AcDream.App.Rendering.Wb; using AcDream.App.Streaming; +using AcDream.App.World; using AcDream.Core.Terrain; using AcDream.Core.World; using DatReaderWriter.DBObjs; @@ -47,7 +48,7 @@ public sealed class LandblockPresentationPipelineTests } [Fact] - public void ControllerConcreteConstructorHasNoLegacyPresentationParameters() + public void PublicPresentationConstructorsExposeOnlyConcreteOwnerGraph() { string[] legacyNames = [ @@ -59,14 +60,595 @@ public sealed class LandblockPresentationPipelineTests "retirementCoordinator", ]; - System.Reflection.ConstructorInfo concrete = Assert.Single( - typeof(StreamingController).GetConstructors(), - constructor => constructor.GetParameters().Any( - parameter => parameter.Name == "presentationPipeline")); - string[] names = concrete.GetParameters() + System.Reflection.ConstructorInfo controller = Assert.Single( + typeof(StreamingController).GetConstructors()); + Assert.Contains( + controller.GetParameters(), + parameter => parameter.Name == "presentationPipeline"); + string[] names = controller.GetParameters() .Select(parameter => parameter.Name!) .ToArray(); Assert.DoesNotContain(names, legacyNames.Contains); + + System.Reflection.ConstructorInfo pipeline = Assert.Single( + typeof(LandblockPresentationPipeline).GetConstructors()); + Type[] pipelineParameterTypes = pipeline.GetParameters() + .Select(parameter => parameter.ParameterType) + .ToArray(); + Assert.Contains(typeof(LandblockRenderPublisher), pipelineParameterTypes); + Assert.Contains(typeof(LandblockPhysicsPublisher), pipelineParameterTypes); + Assert.Contains(typeof(LandblockStaticPresentationPublisher), pipelineParameterTypes); + Assert.DoesNotContain( + pipeline.GetParameters(), + parameter => parameter.Name == "publishBeforeSpatialCommit"); + + string[] coordinatorOnlyMethods = + [ + "BeginOriginRecenter", + "IsOriginRecenterRetirementComplete", + "TryCommitOriginRecenter", + "TryCancelOriginRecenter", + ]; + foreach (string methodName in coordinatorOnlyMethods) + { + Assert.Null(typeof(StreamingController).GetMethod(methodName)); + Assert.NotNull(typeof(StreamingController).GetMethod( + methodName, + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic)); + } + } + + [Fact] + public void OriginRecenter_BlocksOriginAndBootstrapUntilEveryOldOwnerConverges() + { + const uint centerId = 0x1919FFFFu; + const uint neighborId = 0x191AFFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + centerId, + new LandBlock(), + Array.Empty())); + state.AddLandblock(new LoadedLandblock( + neighborId, + new LandBlock(), + Array.Empty())); + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x19, 0x19)); + var enqueued = new List(); + bool failNeighbor = true; + int neighborAttempts = 0; + var controller = new StreamingController( + enqueueLoad: (id, _) => enqueued.Add(id), + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + removeTerrain: id => + { + if (id != neighborId) + return; + neighborAttempts++; + if (failNeighbor) + throw new InvalidOperationException("injected non-center failure"); + }); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x22, 0x23, isSealedDungeon: false)); + + Assert.Equal((0x19, 0x19), (origin.CenterX, origin.CenterY)); + Assert.False(state.IsLoaded(centerId)); + Assert.False(state.IsLoaded(neighborId)); + Assert.Equal(1, controller.PendingRetirementCount); + Assert.Equal(2, controller.LastFullWindowRetirementLandblockCount); + + controller.Tick(0x19, 0x19); + Assert.Empty(enqueued); + Assert.True(neighborAttempts >= 2); + + failNeighbor = false; + Assert.True(recenter.Advance()); + + Assert.Equal((0x22, 0x23), (origin.CenterX, origin.CenterY)); + Assert.Equal(0, controller.PendingRetirementCount); + controller.Tick(0x22, 0x23); + Assert.Equal([0x2223FFFFu], enqueued); + } + + [Fact] + public void OriginRecenter_PreparationFailureBeforeDetachRetainsOldOriginAndResidents() + { + const uint landblockId = 0x3030FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty())); + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x30, 0x30)); + bool failClear = true; + int clearAttempts = 0; + var controller = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + clearPendingLoads: () => + { + clearAttempts++; + if (failClear) + throw new InvalidOperationException("injected clear failure"); + }); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x40, 0x41, isSealedDungeon: false)); + Assert.Equal((0x30, 0x30), (origin.CenterX, origin.CenterY)); + Assert.True(state.IsLoaded(landblockId)); + Assert.Equal(0, controller.PendingRetirementCount); + + failClear = false; + Assert.True(recenter.Advance()); + + Assert.Equal(2, clearAttempts); + Assert.False(state.IsLoaded(landblockId)); + Assert.Equal((0x40, 0x41), (origin.CenterX, origin.CenterY)); + } + + [Fact] + public void OriginRecenter_ObserverFailureHalfwayRetainsBarrierUntilSnapshotFinishes() + { + const uint firstId = 0x4242FFFFu; + const uint secondId = 0x4243FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + firstId, + new LandBlock(), + Array.Empty())); + state.AddLandblock(new LoadedLandblock( + secondId, + new LandBlock(), + [Entity(2, serverGuid: 0x70000042u)])); + int observerCalls = 0; + state.LiveProjectionVisibilityChanged += (_, _) => + { + observerCalls++; + throw new InvalidOperationException("injected observer failure"); + }; + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x42, 0x42)); + var removed = new List(); + var controller = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + removeTerrain: id => removed.Add(id)); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x43, 0x44, isSealedDungeon: false)); + + Assert.Equal((0x42, 0x42), (origin.CenterX, origin.CenterY)); + Assert.False(state.IsLoaded(firstId)); + Assert.False(state.IsLoaded(secondId)); + Assert.Equal(1, observerCalls); + + Assert.True(recenter.Advance()); + Assert.Equal((0x43, 0x44), (origin.CenterX, origin.CenterY)); + Assert.Equal([firstId, secondId], removed); + } + + [Fact] + public void OriginRecenter_RetryPreservesLiveIdentityAndDoesNotRescueReusedGuid() + { + const uint oldId = 0x4545FFFFu; + const uint destinationId = 0x4646FFFFu; + const uint playerGuid = 0x50000045u; + const uint remoteGuid = 0x70000045u; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + oldId, + new LandBlock(), + Array.Empty())); + WorldEntity player = Entity(10, playerGuid); + WorldEntity remote = Entity(11, remoteGuid); + state.MarkPersistent(playerGuid); + state.PlaceLiveEntityProjection(oldId, player); + state.PlaceLiveEntityProjection(oldId, remote); + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x45, 0x45)); + bool failRetirement = true; + var controller = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + removeTerrain: _ => + { + if (failRetirement) + throw new InvalidOperationException("injected live-owner retry"); + }); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x46, 0x46, isSealedDungeon: false)); + + Assert.Same(player, Assert.Single(state.DrainRescued())); + Assert.Equal(1, state.PendingLiveEntityCount); + Assert.Empty(state.Entities); + + failRetirement = false; + Assert.True(recenter.Advance()); + Assert.Empty(state.DrainRescued()); + state.AddLandblock(new LoadedLandblock( + destinationId, + new LandBlock(), + Array.Empty())); + state.RebucketLiveEntity(player, destinationId); + Assert.Same(player, Assert.Single(state.Entities)); + + state.AddLandblock(new LoadedLandblock( + oldId, + new LandBlock(), + Array.Empty())); + Assert.Contains(state.Entities, entity => ReferenceEquals(entity, remote)); + + state.ForgetLiveEntity(playerGuid); + WorldEntity replacement = Entity(12, playerGuid); + state.MarkPersistent(playerGuid); + state.PlaceLiveEntityProjection(destinationId, replacement); + state.RemoveLandblock(destinationId); + + Assert.Same(replacement, Assert.Single(state.DrainRescued())); + Assert.DoesNotContain(state.Entities, entity => ReferenceEquals(entity, player)); + } + + [Fact] + public void OriginRecenter_ResetWithoutReplacementConvergesAtCurrentOrigin() + { + const uint landblockId = 0x5050FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty())); + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x50, 0x50)); + bool failRetirement = true; + var controller = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + removeTerrain: _ => + { + if (failRetirement) + throw new InvalidOperationException("injected retirement failure"); + }); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x60, 0x60, isSealedDungeon: false)); + recenter.Reset(); + Assert.True(recenter.IsPending); + + failRetirement = false; + Assert.True(recenter.Advance()); + + Assert.False(recenter.IsPending); + Assert.Equal((0x50, 0x50), (origin.CenterX, origin.CenterY)); + controller.Tick(0x50, 0x50); + } + + [Fact] + public void OriginRecenter_ResetBackToSealedSourceKeepsRadiusZeroMode() + { + const uint sourceId = 0x5252FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + sourceId, + new LandBlock(), + Array.Empty())); + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x52, 0x52)); + var enqueued = new List<(uint Id, LandblockStreamJobKind Kind)>(); + bool failRetirement = true; + var controller = new StreamingController( + enqueueLoad: (id, kind) => enqueued.Add((id, kind)), + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 2, + farRadius: 4, + removeTerrain: _ => + { + if (failRetirement) + throw new InvalidOperationException("injected retirement failure"); + }); + controller.PreCollapseToDungeon(0x52, 0x52); + enqueued.Clear(); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x60, 0x60, isSealedDungeon: false)); + recenter.Reset(); + failRetirement = false; + Assert.True(recenter.Advance()); + + Assert.Equal((0x52, 0x52), (origin.CenterX, origin.CenterY)); + Assert.Equal([(sourceId, LandblockStreamJobKind.LoadNear)], enqueued); + controller.Tick(0x52, 0x52, insideDungeon: true); + Assert.Single(enqueued); + } + + [Fact] + public void OriginRecenter_SessionResetConvergesWithoutBootstrappingEndingWorld() + { + const uint sourceId = 0x5353FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + sourceId, + new LandBlock(), + Array.Empty())); + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x53, 0x53)); + var enqueued = new List(); + bool failRetirement = true; + var controller = new StreamingController( + enqueueLoad: (id, _) => enqueued.Add(id), + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + removeTerrain: _ => + { + if (failRetirement) + throw new InvalidOperationException("injected session reset failure"); + }); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x54, 0x54, isSealedDungeon: true)); + recenter.Reset(sessionEnding: true); + failRetirement = false; + Assert.True(recenter.Advance()); + + Assert.Empty(enqueued); + Assert.Equal((0x53, 0x53), (origin.CenterX, origin.CenterY)); + controller.Tick(0x53, 0x53); + Assert.Equal([sourceId], enqueued); + } + + [Fact] + public void OriginRecenter_SessionResetCannotOverwriteNextSessionOrigin() + { + const uint sourceId = 0x5353FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + sourceId, + new LandBlock(), + Array.Empty())); + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x53, 0x53)); + bool failRetirement = true; + var controller = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + removeTerrain: _ => + { + if (failRetirement) + throw new InvalidOperationException("injected session reset failure"); + }); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x54, 0x54, isSealedDungeon: true)); + recenter.Reset(sessionEnding: true); + origin.Reset(); + Assert.True(origin.TryInitialize(0x61, 0x62)); + + failRetirement = false; + Assert.True(recenter.Advance()); + + Assert.Equal((0x61, 0x62), (origin.CenterX, origin.CenterY)); + } + + [Fact] + public void OriginRecenter_CommittedPendingClearFailureDoesNotReplayClear() + { + var state = new GpuWorldState(); + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x10, 0x10)); + int clearAttempts = 0; + var controller = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + clearPendingLoads: () => + { + clearAttempts++; + throw new StreamingMutationException( + "injected committed clear failure", + mutationCommitted: true); + }); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x11, 0x11, isSealedDungeon: false)); + Assert.True(recenter.Advance()); + + Assert.Equal(1, clearAttempts); + Assert.Equal((0x11, 0x11), (origin.CenterX, origin.CenterY)); + } + + [Fact] + public void OriginRecenter_ReentrantClearAndRetirementDoNotRecurseOrSkipResidents() + { + const uint firstId = 0x7070FFFFu; + const uint secondId = 0x7071FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + firstId, + new LandBlock(), + Array.Empty())); + state.AddLandblock(new LoadedLandblock( + secondId, + new LandBlock(), + Array.Empty())); + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x70, 0x70)); + StreamingController? controller = null; + int clearCalls = 0; + var removed = new List(); + controller = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + clearPendingLoads: () => + { + clearCalls++; + Assert.False(controller!.IsOriginRecenterRetirementComplete()); + }, + removeTerrain: id => + { + removed.Add(id); + if (id == firstId) + Assert.False(controller!.IsOriginRecenterRetirementComplete()); + }); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.True(recenter.Begin(0x71, 0x71, isSealedDungeon: false)); + + Assert.Equal(1, clearCalls); + Assert.Equal([firstId, secondId], removed); + Assert.False(state.IsLoaded(firstId)); + Assert.False(state.IsLoaded(secondId)); + } + + [Fact] + public void OriginRecenter_DefersRadiusChangeUntilDestinationCommit() + { + const uint oldId = 0x7272FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + oldId, + new LandBlock(), + Array.Empty())); + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x72, 0x72)); + var enqueued = new List(); + bool failRetirement = true; + var controller = new StreamingController( + enqueueLoad: (id, _) => enqueued.Add(id), + enqueueUnload: static _ => { }, + drainCompletions: static _ => Array.Empty(), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0, + removeTerrain: _ => + { + if (failRetirement) + throw new InvalidOperationException("injected radius barrier failure"); + }); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x73, 0x73, isSealedDungeon: false)); + controller.ReconfigureRadii(nearRadius: 1, farRadius: 2); + controller.Tick(0x72, 0x72); + + Assert.Equal(0, controller.NearRadius); + Assert.Equal(0, controller.FarRadius); + Assert.Empty(enqueued); + + failRetirement = false; + Assert.True(recenter.Advance()); + controller.Tick(0x73, 0x73); + + Assert.Equal(1, controller.NearRadius); + Assert.Equal(2, controller.FarRadius); + Assert.Equal(25, enqueued.Count); + } + + [Fact] + public void OriginRecenter_SealedDungeonPinsRadiusZeroBeforeCenterCompletion() + { + const uint oldId = 0x2020FFFFu; + const uint dungeonId = 0x3030FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + oldId, + new LandBlock(), + Array.Empty())); + var origin = new LiveWorldOriginState(); + Assert.True(origin.TryInitialize(0x20, 0x20)); + var outbox = new Queue(); + var enqueued = new List<(uint Id, LandblockStreamJobKind Kind)>(); + bool failDungeonEnqueue = true; + int enqueueAttempts = 0; + int publications = 0; + var controller = new StreamingController( + enqueueLoad: (id, kind) => + { + enqueueAttempts++; + if (failDungeonEnqueue) + throw new InvalidOperationException("injected dungeon enqueue failure"); + enqueued.Add((id, kind)); + }, + enqueueUnload: static _ => { }, + drainCompletions: max => Drain(outbox, max), + applyTerrain: (_, _) => publications++, + state, + nearRadius: 2, + farRadius: 4); + var recenter = new StreamingOriginRecenterCoordinator(controller, origin); + + Assert.False(recenter.Begin(0x30, 0x30, isSealedDungeon: true)); + Assert.True(recenter.IsPending); + controller.Tick(0x30, 0x30, insideDungeon: true); + Assert.Empty(enqueued); + + failDungeonEnqueue = false; + Assert.True(recenter.Advance()); + Assert.Equal(2, enqueueAttempts); + Assert.Equal([(dungeonId, LandblockStreamJobKind.LoadNear)], enqueued); + + outbox.Enqueue(new LandblockStreamResult.Loaded( + dungeonId, + LandblockStreamTier.Near, + new LoadedLandblock( + dungeonId, + new LandBlock(), + Array.Empty()), + EmptyMesh(), + generation: 1)); + controller.Tick(0x30, 0x30, insideDungeon: true); + + Assert.Equal(1, publications); + Assert.True(state.IsNearTier(dungeonId)); } [Fact] @@ -715,9 +1297,10 @@ public sealed class LandblockPresentationPipelineTests envCells); } - private static WorldEntity Entity(uint id) => new() + private static WorldEntity Entity(uint id, uint serverGuid = 0) => new() { Id = id, + ServerGuid = serverGuid, SourceGfxObjOrSetupId = 0x01000000u + id, Position = Vector3.Zero, Rotation = Quaternion.Identity, From db3ca59fd0b83b4f83dd14cd9e420d58d14e5c66 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 22:50:38 +0200 Subject: [PATCH 69/98] docs(architecture): record landblock cutover Mark Slice 5G complete, record the 44 percent GameWindow reduction and origin-lifetime barrier, and advance the active ledger to deterministic and connected closeout. --- AGENTS.md | 11 ++++++---- CLAUDE.md | 11 ++++++---- docs/architecture/code-structure.md | 15 ++++++++------ docs/plans/2026-04-11-roadmap.md | 13 +++++++----- docs/plans/2026-05-12-milestones.md | 13 ++++++------ ...mewindow-slice-5-landblock-presentation.md | 20 ++++++++++++++++++- 6 files changed, 57 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b372b44a..0c6f40d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 6,940 tests pass / 5 skip. +Release builds; 7,052 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -123,9 +123,12 @@ Selection/interaction Slice 1, live-animation-presentation Slice 2, complete live-session ownership Slice 3, and live-entity App integration Slice 4 are landed. Focused hydration, teardown, inbound-authority, network-update, and motion-runtime owners now sit over canonical `LiveEntityRuntime` without a -second GUID dictionary. `GameWindow` is 10,301 lines, 267 fields, and 163 -methods. Slice 5 extracts landblock presentation next; later slices finish -update/render orchestration and composition cleanup. +second GUID dictionary. Slice 5 checkpoints A–G have moved the complete DAT +build, render/physics/static publication, retryable retirement, and shared- +origin lifetime barrier out of `GameWindow`; it is now 8,793 raw lines, down +44% from the 15,723-line campaign baseline. Slice 5H lifecycle/connected +closeout is active; later slices finish update/render orchestration and +composition cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/CLAUDE.md b/CLAUDE.md index 44e9d230..eb7fed10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 6,940 tests pass / 5 skip. +Release builds; 7,052 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -121,9 +121,12 @@ Selection/interaction Slice 1, live-animation-presentation Slice 2, complete live-session ownership Slice 3, and live-entity App integration Slice 4 are landed. Focused hydration, teardown, inbound-authority, network-update, and motion-runtime owners now sit over canonical `LiveEntityRuntime` without a -second GUID dictionary. `GameWindow` is 10,301 lines, 267 fields, and 163 -methods. Slice 5 extracts landblock presentation next; later slices finish -update/render orchestration and composition cleanup. +second GUID dictionary. Slice 5 checkpoints A–G have moved the complete DAT +build, render/physics/static publication, retryable retirement, and shared- +origin lifetime barrier out of `GameWindow`; it is now 8,793 raw lines, down +44% from the 15,723-line campaign baseline. Slice 5H lifecycle/connected +closeout is active; later slices finish update/render orchestration and +composition cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 6698e2aa..9878bb36 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -27,6 +27,7 @@ after Slice 1 14,912 lines / 278 fields / 191 method after Slice 2 14,546 lines / 277 fields / 190 methods after Slice 3 14,310 lines / 274 fields / 190 methods after Slice 4 10,301 lines / 267 fields / 163 methods +after Slice 5G 8,793 lines (field/method closeout in H) ``` `GameWindow` is the single object that: @@ -399,7 +400,7 @@ useful ordering seam, but its ownership status is **partial**. | World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. | | Retained gameplay UI | **Mostly complete feature ownership** | `RetailUiRuntime` and focused panel/controllers own layout and behavior. `GameWindow.OnLoad` still performs substantial service composition, which is allowed until the final composition cleanup. | | Selection/interaction | **Complete** | `WorldSelectionQuery` owns read-only picking/classification/range queries; `SelectionInteractionController` owns selection intent, Use/PickUp transport, exact-incarnation queues, and auto-walk deferral; `ItemInteractionController` owns ItemHolder policy plus the shared retail inventory-request transaction. `GameWindow` retains construction and narrow lifecycle forwarding only. | -| Landblock presentation | **Not extracted** | Residency scheduling is extracted, but DAT build, scenery/EnvCell construction, apply, collision/resource publication, and retirement glue remain a large `GameWindow` body. | +| Landblock presentation | **Complete; closeout active** | `LandblockBuildFactory` owns the captured-origin DAT transaction; concrete render/physics/DAT-static publishers and `LandblockPresentationPipeline` own publication and exact retryable retirement. `StreamingOriginRecenterCoordinator` serializes old-window retirement with teleport/session origin lifetimes. `GameWindow` retains construction and one pipeline field only (`c79d0a49`). | | Render-frame orchestration | **Not extracted** | `OnRender` plus its portal/PView/alpha/particle helpers still own the draw graph and frame-local scratch state. There is no `RenderFrameOrchestrator` yet. | | Unified `GameEntity` | **Deferred target** | `LiveEntityRuntime` already supplies the critical canonical owner. Full type aggregation is a separate high-risk migration and is not required to make `GameWindow` thin. | @@ -542,13 +543,15 @@ keeps a second identity dictionary. ForcePosition serializes and stamps one exact canonical outbound Position. Three corrected-diff reviews, the complete 6,940-test Release suite, and the 310-second seven-destination connected R6 route all passed. `GameWindow` is 10,301 lines / 267 fields / 163 methods. -Slice 5 checkpoints A–F are complete. The pipeline owns the serialized worker +Slice 5 checkpoints A–G are complete. The pipeline owns the serialized worker build, concrete render/physics/static publication, and retryable Near/full -retirement; `GameWindow.cs` is 9,011 lines and no longer owns a retirement -coordinator or callback body. Checkpoint G direct cutover/dead-facade cleanup -is active next, followed by deterministic and connected closeout in H. +retirement. The direct cutover deleted the legacy apply/facade/scratch body and +added one retained shared-origin lifetime transaction for teleport, logout, +and fast relogin. `GameWindow.cs` is 8,793 raw lines and no longer owns +landblock build/apply/retirement behavior. Deterministic and connected closeout +is active in H. -#### Slice 5 — extract landblock presentation +#### Slice 5 — extract landblock presentation — CHECKPOINTS A–G COMPLETE Detailed execution ledger: [`docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md`](../plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 4ef6f227..417106a9 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -47,12 +47,15 @@ passes 6,940 tests with five intentional skips. The 310-second connected R6 gate passed seven destinations/revisits, movement/jump/combat exercises, and graceful close. -**Current:** Slice 5 checkpoints A–F have extracted the DAT-backed worker +**Current:** Slice 5 checkpoints A–G have extracted the DAT-backed worker build plus render, physics, DAT-static, and retryable Near/full presentation -ownership into `LandblockPresentationPipeline`. `GameWindow` is 9,011 lines -and the Release suite passes 7,039 tests with five intentional skips. Slice 5G -now removes the obsolete callback bodies and scratch/facade state before the -deterministic and connected closeout in H. +ownership into `LandblockPresentationPipeline`. Production has no callback +facade back into the deleted GameWindow apply bodies. A retained shared-origin +transaction retires the complete old window before teleport recenter or +session replacement, including failure/retry and fast relogin. `GameWindow` is +8,793 raw lines (44% below the campaign baseline) and the Release suite passes +7,052 tests with five intentional skips. Slice 5H deterministic and connected +closeout is active. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 3c75ea9d..3ac80c58 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -19,13 +19,14 @@ layer, the active structural prerequisite is the behavior-preserving The 2026-07-21 audit baseline was 15,723 lines, 278 fields, and 205 methods. Selection/interaction Slice 1, live-animation-presentation Slice 2, complete live-session ownership Slice 3, and live-entity App integration Slice 4 are -complete. Slice 5 landblock-presentation checkpoints A–F are also complete: +complete. Slice 5 landblock-presentation checkpoints A–G are also complete: the worker DAT transaction, render/physics/static publishers, and retryable -Near/full retirement now have focused owners. `GameWindow` is 9,011 lines, -down 1,290 from the Slice-5 baseline and 6,712 from the campaign baseline. The -full Release suite passes 7,039 tests / 5 skips; Slice 4's seven-destination -connected R6 route passed in 310 seconds. Slice 5G direct cutover and dead -facade/scratch cleanup is active next. +Near/full retirement now have focused owners. The direct production cutover +also serializes complete old-window retirement with teleport and session +origin lifetimes. `GameWindow` is 8,793 raw lines, down 1,508 from the Slice-5 +baseline and 6,930 (44%) from the campaign baseline. The full Release suite +passes 7,052 tests / 5 skips; Slice 4's seven-destination connected R6 route +passed in 310 seconds. Slice 5H deterministic/connected closeout is active. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md index e1493beb..750ae702 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md +++ b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md @@ -96,7 +96,7 @@ test, and bisectable commit rather than being hidden in a mechanical move. impossible. Three corrected-diff reviews are clean. Release gate: 7,039 passed / 5 skipped; build green with 17 pre-existing test-project warnings; `GameWindow.cs` is 9,011 lines. -- [ ] G — cut `StreamingController` and `GameWindow` directly to the pipeline; +- [x] G — cut `StreamingController` and `GameWindow` directly to the pipeline; delete old bodies, scratch state, and callback facades. - [ ] H — three-agent corrected-diff review, full Release suite, deterministic lifecycle gate, seven-destination resource soak, documentation, and durable @@ -375,8 +375,26 @@ AdvanceLandblockPresentationRetirement apply-diagnostic scratch state that now have focused owners. - Add structural source tests and remeasure GameWindow lines/fields/methods. +**Complete in `c79d0a49`.** Production constructs one concrete pipeline; +legacy callback constructors are internal test seams and structural tests keep +them out of the composition root. The old GameWindow apply methods and their +building/diagnostic scratch state are deleted. A retained +`StreamingOriginRecenterCoordinator` now joins complete old-window +presentation retirement to teleport recenter and session cancellation before +the shared origin may be reused. Reviews closed full-window overlap, radius +reconfiguration, sealed-dungeon bootstrap, committed-callback retry, +reentrancy, fast-relogin, and ordinary-logout gaps. Three final reviews are +clean; the zero-warning Release build and 7,052 tests pass with five skips. +`GameWindow.cs` is 8,793 raw lines, 1,508 below the Slice-5 baseline and 6,930 +(44%) below the campaign baseline. + ### H — closeout +**Active.** No new landblock ownership moves are planned in H; it proves the +landed graph under deterministic and connected lifecycle churn, reconciles +divergence pointers/documentation, and records the final Slice-5 structural +measurement before Slice 6 begins. + - Run focused App/Core/Net tests after each checkpoint. - After each code checkpoint, run three independent read-only reviews: retail/ WorldBuilder conformance, architecture/integration, and adversarial failure From 4a205a3e56ebf89850198b6d1994c380ea48ed1a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 23:41:15 +0200 Subject: [PATCH 70/98] test(streaming): close lifecycle gate after cutover --- src/AcDream.App/Net/LiveSessionController.cs | 5 ++ src/AcDream.App/Rendering/GameWindow.cs | 30 ++++++++--- .../Net/LiveSessionControllerTests.cs | 43 +++++++++++++++ .../Streaming/LandblockBuildOriginTests.cs | 52 +++++++++++++++++++ tools/connected-r6-soak.route.txt | 27 ++++++++++ tools/run-connected-r6-soak.ps1 | 31 +++++++---- tools/run-connected-world-lifecycle-gate.ps1 | 2 + 7 files changed, 173 insertions(+), 17 deletions(-) diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs index 13acfa40..eecab525 100644 --- a/src/AcDream.App/Net/LiveSessionController.cs +++ b/src/AcDream.App/Net/LiveSessionController.cs @@ -259,6 +259,11 @@ public sealed class LiveSessionController : IDisposable get { lock (_gate) return _generation; } } + internal bool IsDisposalComplete + { + get { lock (_gate) return _disposed; } + } + internal LiveSessionStartResult Start( RuntimeOptions options, ILiveSessionLifecycleHost host) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e6a816be..e4a0f07e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -8606,7 +8606,30 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext } private ResourceShutdownTransaction CreateShutdownTransaction() => new( - new ResourceShutdownStage("session owners", + // Live-session reset retires the complete streaming window. Every + // reset callback owner, especially LandblockStreamer, must remain live + // until that transaction converges. + new ResourceShutdownStage("session lifetime", + [ + new("live session", () => + { + AcDream.App.Net.LiveSessionController? controller = + _liveSessionController; + if (controller is null) + return; + + controller.Dispose(); + if (!controller.IsDisposalComplete) + { + throw new InvalidOperationException( + "Live-session disposal was deferred by a reentrant lifecycle callback."); + } + + if (ReferenceEquals(_liveSessionController, controller)) + _liveSessionController = null; + }), + ]), + new ResourceShutdownStage("session dependents", [ new("mouse capture", EndMouseLookAndRestoreCursor), new("retail UI", () => @@ -8641,11 +8664,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _magicCatalog = null; }), new("streamer", () => _streamer?.Dispose()), - new("live session", () => - { - _liveSessionController?.Dispose(); - _liveSessionController = null; - }), new("equipped children", () => _equippedChildRenderer?.Dispose()), ]), // Retained tombstones are retried while every callback owner is alive. diff --git a/tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs index 3c56847d..4b4539fc 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionControllerTests.cs @@ -1,5 +1,6 @@ using System.Net; using AcDream.App.Net; +using AcDream.App.Rendering; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.UI.Abstractions; @@ -833,6 +834,48 @@ public sealed class LiveSessionControllerTests Assert.Throws(() => controller.Start(LiveOptions(), host)); } + [Fact] + public void ReentrantShutdownRetainsControllerAndDependentsUntilDeferredDisposeCompletes() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + controller.Start(LiveOptions(), host); + bool dependentDisposed = false; + var shutdown = new ResourceShutdownTransaction( + new ResourceShutdownStage("session lifetime", + [ + new("live session", () => + { + controller.Dispose(); + if (!controller.IsDisposalComplete) + { + throw new InvalidOperationException( + "live-session disposal is still deferred"); + } + }), + ]), + new ResourceShutdownStage("session dependents", + [ + new("streamer", () => dependentDisposed = true), + ])); + operations.OnTick = () => + { + Assert.Throws(shutdown.CompleteOrThrow); + Assert.Equal(0, shutdown.CurrentStage); + Assert.False(dependentDisposed); + }; + + controller.Tick(); + + Assert.True(controller.IsDisposalComplete); + Assert.False(dependentDisposed); + shutdown.CompleteOrThrow(); + Assert.True(shutdown.IsComplete); + Assert.True(dependentDisposed); + } + private static RuntimeOptions LiveOptions( bool live = true, string? user = "user") diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs index 9553ea8a..db902231 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildOriginTests.cs @@ -342,6 +342,58 @@ public sealed class LandblockBuildOriginTests Assert.DoesNotContain("_liveCenterY", renderPublisherSource, StringComparison.Ordinal); } + [Fact] + public void GameWindowShutdownKeepsStreamerAliveUntilSessionResetConverges() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + int sessionStage = source.IndexOf( + "new ResourceShutdownStage(\"session lifetime\"", + StringComparison.Ordinal); + Assert.True(sessionStage >= 0); + + int sessionOperation = source.IndexOf( + "new(\"live session\", () =>", + sessionStage, + StringComparison.Ordinal); + Assert.True(sessionOperation > sessionStage); + + int sessionDispose = source.IndexOf( + "controller.Dispose()", + sessionOperation, + StringComparison.Ordinal); + Assert.True(sessionDispose > sessionOperation); + + int disposalCompletionBarrier = source.IndexOf( + "if (!controller.IsDisposalComplete)", + sessionDispose, + StringComparison.Ordinal); + Assert.True(disposalCompletionBarrier > sessionDispose); + + int sessionReferenceRelease = source.IndexOf( + "_liveSessionController = null;", + disposalCompletionBarrier, + StringComparison.Ordinal); + Assert.True(sessionReferenceRelease > disposalCompletionBarrier); + + int dependentStage = source.IndexOf( + "new ResourceShutdownStage(\"session dependents\"", + sessionReferenceRelease, + StringComparison.Ordinal); + Assert.True(dependentStage > sessionReferenceRelease); + + int streamerDispose = source.IndexOf( + "new(\"streamer\", () => _streamer?.Dispose())", + dependentStage, + StringComparison.Ordinal); + + Assert.True(streamerDispose > dependentStage); + } + private static LandblockBuild EmptyBuild(uint landblockId, LandblockBuildOrigin origin) => new( new LoadedLandblock( diff --git a/tools/connected-r6-soak.route.txt b/tools/connected-r6-soak.route.txt index 4e7b457b..a99d417b 100644 --- a/tools/connected-r6-soak.route.txt +++ b/tools/connected-r6-soak.route.txt @@ -6,6 +6,7 @@ sleep 15000 # Caul baseline + local movement/jump/combat exercise. command /teleloc 0xC95B0001 14.8 0.3 12.005 +wait materialized 1 60000 sleep 8000 input down MovementTurnRight sleep 6000 @@ -42,6 +43,7 @@ sleep 7400 # Sawato baseline. command /teleloc 0x3032001C 83.3 89.2 133.005 +wait materialized 2 60000 sleep 8000 input down MovementTurnRight sleep 6000 @@ -50,6 +52,7 @@ sleep 22000 # Rynthid world-edge streaming. command /teleloc 0xF6820033 145.7 49.855 58.010 +wait materialized 3 60000 sleep 8000 input down MovementTurnRight sleep 6000 @@ -58,6 +61,7 @@ sleep 22000 # Aerlinthe dense island. command /teleloc 0x09040008 11.4 188.6 87.705 +wait materialized 4 60000 sleep 8000 input down MovementTurnRight sleep 6000 @@ -66,6 +70,7 @@ sleep 22000 # Same-location lifecycle revisit. command /teleloc 0x3032001C 83.3 89.2 133.005 +wait materialized 5 60000 sleep 8000 input down MovementTurnRight sleep 6000 @@ -74,6 +79,7 @@ sleep 22000 # Holtburg mixed-town workload + local exercise. command /teleloc 0xA9B40019 84.0 7.1 94.005 +wait materialized 6 60000 sleep 8000 input down MovementTurnRight sleep 6000 @@ -110,6 +116,7 @@ sleep 7400 # Caul return/leak oracle + final local exercise. command /teleloc 0xC95B0001 14.8 0.3 12.005 +wait materialized 7 60000 sleep 8000 input down MovementTurnRight sleep 6000 @@ -143,3 +150,23 @@ input up MovementForward sleep 700 input press CombatToggleCombat sleep 15000 + +# A second warm-cache Sawato/Caul revisit is the plateau oracle. The first +# circuit intentionally warms bounded DAT/GPU caches, so comparing the cold +# first Caul against its warm return would punish successful source-window +# retirement. This circuit loads no new destination content. +command /teleloc 0x3032001C 83.3 89.2 133.005 +wait materialized 8 60000 +sleep 8000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 22000 + +command /teleloc 0xC95B0001 14.8 0.3 12.005 +wait materialized 9 60000 +sleep 8000 +input down MovementTurnRight +sleep 6000 +input up MovementTurnRight +sleep 22000 diff --git a/tools/run-connected-r6-soak.ps1 b/tools/run-connected-r6-soak.ps1 index 1afbaf6b..460d0c56 100644 --- a/tools/run-connected-r6-soak.ps1 +++ b/tools/run-connected-r6-soak.ps1 @@ -20,7 +20,9 @@ $destinations = @( [pscustomobject]@{ Name = 'Aerlinthe'; Occurrence = 4; Exercise = $false }, [pscustomobject]@{ Name = 'Sawato revisit'; Occurrence = 5; Exercise = $false }, [pscustomobject]@{ Name = 'Holtburg'; Occurrence = 6; Exercise = $true }, - [pscustomobject]@{ Name = 'Caul return'; Occurrence = 7; Exercise = $true } + [pscustomobject]@{ Name = 'Caul return'; Occurrence = 7; Exercise = $true }, + [pscustomobject]@{ Name = 'Sawato plateau'; Occurrence = 8; Exercise = $false }, + [pscustomobject]@{ Name = 'Caul plateau'; Occurrence = 9; Exercise = $false } ) $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' @@ -32,6 +34,7 @@ $stderrLog = "$prefix.err.log" $markerLog = "$prefix.markers.log" $sampleCsv = "$prefix.samples.csv" $reportJson = "$prefix.report.json" +$artifactDir = "$prefix.artifacts" $routePath = Join-Path $Repository 'tools\connected-r6-soak.route.txt' $exe = Join-Path $Repository 'src\AcDream.App\bin\Release\net10.0\AcDream.App.exe' @@ -59,7 +62,7 @@ function Get-PatternCount([string]$Path, [string]$Pattern) { } function Get-FrameProfiles([string]$Path) { - return @(Read-LogLines $Path | Where-Object { $_.StartsWith('[frame-prof]', [StringComparison]::Ordinal) }) + return @(Read-LogLines $Path | Where-Object { $_.StartsWith('[frame-prof] n=', [StringComparison]::Ordinal) }) } function Wait-ForLogPattern( @@ -206,6 +209,12 @@ function Capture-Sample( Profile = $ProfileLine } $samples.Add($sample) + if ($null -eq $sample.ProfileFrames -or + $null -eq $sample.CpuP95Ms -or + $null -eq $sample.AllocP50Kb -or + $null -eq $sample.UpdateP95Ms) { + $failures.Add("${Destination}/${Phase}: frame-prof sample was missing required parsed metrics") + } return $sample } @@ -245,14 +254,11 @@ function Observe-R6Exercise( } function Add-RelativeGateFailures { - $caulFirst = $samples | Where-Object { $_.Destination -eq 'Caul' -and $_.Phase -eq 'stationary' } | Select-Object -First 1 $caulReturn = $samples | Where-Object { $_.Destination -eq 'Caul return' -and $_.Phase -eq 'stationary' } | Select-Object -First 1 - $sawatoFirst = $samples | Where-Object { $_.Destination -eq 'Sawato' -and $_.Phase -eq 'stationary' } | Select-Object -First 1 - $sawatoReturn = $samples | Where-Object { $_.Destination -eq 'Sawato revisit' -and $_.Phase -eq 'stationary' } | Select-Object -First 1 + $caulPlateau = $samples | Where-Object { $_.Destination -eq 'Caul plateau' -and $_.Phase -eq 'stationary' } | Select-Object -First 1 foreach ($pair in @( - [pscustomobject]@{ Label = 'Caul return'; First = $caulFirst; Return = $caulReturn }, - [pscustomobject]@{ Label = 'Sawato revisit'; First = $sawatoFirst; Return = $sawatoReturn })) + [pscustomobject]@{ Label = 'Caul plateau'; First = $caulReturn; Return = $caulPlateau })) { if ($null -eq $pair.First -or $null -eq $pair.Return) { $failures.Add("$($pair.Label): missing same-location stationary sample") @@ -273,8 +279,8 @@ function Add-RelativeGateFailures { $warnings.Add("$($pair.Label): connected animation population changed $($pair.First.AnimationCount) -> $($pair.Return.AnimationCount)") } - $privateLimit = [Math]::Max(384.0, $pair.First.PrivateMiB * 0.35) - $workingLimit = [Math]::Max(384.0, $pair.First.WorkingSetMiB * 0.35) + $privateLimit = [Math]::Max(192.0, $pair.First.PrivateMiB * 0.20) + $workingLimit = [Math]::Max(192.0, $pair.First.WorkingSetMiB * 0.20) if (($pair.Return.PrivateMiB - $pair.First.PrivateMiB) -gt $privateLimit) { $failures.Add("$($pair.Label): private memory grew by $([Math]::Round($pair.Return.PrivateMiB - $pair.First.PrivateMiB, 1)) MiB") } @@ -305,7 +311,9 @@ function Add-LogFailures { 'WeenieError', 'device removed', 'GPU reset', - 'live: disconnected' + 'live: disconnected', + '[shutdown]', + 'ObjectDisposedException' ) foreach ($pattern in $fatalPatterns) { $count = (Get-PatternCount $stdoutLog $pattern) + (Get-PatternCount $stderrLog $pattern) @@ -369,6 +377,7 @@ $env:ACDREAM_UNCAPPED_RENDER = '1' $env:ACDREAM_DEVTOOLS = '0' $env:ACDREAM_UI_PROBE_DUMP = '0' $env:ACDREAM_UI_PROBE_SCRIPT = $routePath +$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $artifactDir $env:ACDREAM_DUMP_MOVE_TRUTH = '1' $env:ACDREAM_NO_AUDIO = $null $env:ACDREAM_WB_DIAG = $null @@ -422,7 +431,6 @@ try { } Add-RelativeGateFailures - Add-LogFailures Write-Marker 'route-complete requesting graceful close' $gracefulExit = Close-ClientGracefully $process @@ -430,6 +438,7 @@ try { if ($process.HasExited) { $exitCode = [int]$process.ExitCode } if (-not $gracefulExit) { $failures.Add('client did not exit through the graceful WM_CLOSE path') } if ($null -ne $exitCode -and $exitCode -ne 0) { $failures.Add("client exit code was $exitCode") } + Add-LogFailures } catch { $failures.Add($_.Exception.Message) diff --git a/tools/run-connected-world-lifecycle-gate.ps1 b/tools/run-connected-world-lifecycle-gate.ps1 index f0c599ef..d03efc08 100644 --- a/tools/run-connected-world-lifecycle-gate.ps1 +++ b/tools/run-connected-world-lifecycle-gate.ps1 @@ -113,6 +113,8 @@ function Add-LogFailures([string]$Label, [string]$Stdout, [string]$Stderr) { 'device removed', 'GPU reset', 'live: disconnected', + '[shutdown]', + 'ObjectDisposedException', 'screenshot-failed', 'graceful logout confirmation timed out', 'graceful logout failed', From d4ecac1da35ea827919d173f471bf0fa7c49830e Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 23:45:45 +0200 Subject: [PATCH 71/98] docs(architecture): close GameWindow slice 5 --- AGENTS.md | 13 ++++--- CLAUDE.md | 13 ++++--- docs/architecture/code-structure.md | 24 +++++++----- .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 11 +++--- docs/plans/2026-05-12-milestones.md | 11 +++--- ...mewindow-slice-5-landblock-presentation.md | 39 ++++++++++++++----- 7 files changed, 70 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0c6f40d3..30fb71a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,052 tests pass / 5 skip. +Release builds; 7,054 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -123,12 +123,13 @@ Selection/interaction Slice 1, live-animation-presentation Slice 2, complete live-session ownership Slice 3, and live-entity App integration Slice 4 are landed. Focused hydration, teardown, inbound-authority, network-update, and motion-runtime owners now sit over canonical `LiveEntityRuntime` without a -second GUID dictionary. Slice 5 checkpoints A–G have moved the complete DAT +second GUID dictionary. Slice 5 is complete: the complete DAT build, render/physics/static publication, retryable retirement, and shared- -origin lifetime barrier out of `GameWindow`; it is now 8,793 raw lines, down -44% from the 15,723-line campaign baseline. Slice 5H lifecycle/connected -closeout is active; later slices finish update/render orchestration and -composition cleanup. +origin lifetime barrier are out of `GameWindow`; it is now 8,811 raw lines / +247 fields / 153 methods, down 44% from the 15,723-line campaign baseline. +The lifecycle/reconnect and synchronized nine-stop soak gates pass. Slice 6 +update-frame orchestration is active; later slices finish render orchestration +and composition cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/CLAUDE.md b/CLAUDE.md index eb7fed10..ce3e9c7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,052 tests pass / 5 skip. +Release builds; 7,054 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -121,12 +121,13 @@ Selection/interaction Slice 1, live-animation-presentation Slice 2, complete live-session ownership Slice 3, and live-entity App integration Slice 4 are landed. Focused hydration, teardown, inbound-authority, network-update, and motion-runtime owners now sit over canonical `LiveEntityRuntime` without a -second GUID dictionary. Slice 5 checkpoints A–G have moved the complete DAT +second GUID dictionary. Slice 5 is complete: the complete DAT build, render/physics/static publication, retryable retirement, and shared- -origin lifetime barrier out of `GameWindow`; it is now 8,793 raw lines, down -44% from the 15,723-line campaign baseline. Slice 5H lifecycle/connected -closeout is active; later slices finish update/render orchestration and -composition cleanup. +origin lifetime barrier are out of `GameWindow`; it is now 8,811 raw lines / +247 fields / 153 methods, down 44% from the 15,723-line campaign baseline. +The lifecycle/reconnect and synchronized nine-stop soak gates pass. Slice 6 +update-frame orchestration is active; later slices finish render orchestration +and composition cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 9878bb36..efb4d015 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -1,7 +1,7 @@ # acdream — code structure & extraction sequence **Status:** Living document. Created 2026-05-16; implementation reconciliation -completed 2026-07-21; Slices 1–4 landed the same day. This is the active +completed 2026-07-21; Slices 1–5 landed the same day. This is the active structural program before new M4 subsystems enter the App layer. **Purpose:** Describe the desired structural state of the App layer, explain the rules we've adopted, and lay out the safe extraction @@ -27,7 +27,7 @@ after Slice 1 14,912 lines / 278 fields / 191 method after Slice 2 14,546 lines / 277 fields / 190 methods after Slice 3 14,310 lines / 274 fields / 190 methods after Slice 4 10,301 lines / 267 fields / 163 methods -after Slice 5G 8,793 lines (field/method closeout in H) +after Slice 5 closeout 8,811 lines / 247 fields / 153 methods ``` `GameWindow` is the single object that: @@ -400,7 +400,7 @@ useful ordering seam, but its ownership status is **partial**. | World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. | | Retained gameplay UI | **Mostly complete feature ownership** | `RetailUiRuntime` and focused panel/controllers own layout and behavior. `GameWindow.OnLoad` still performs substantial service composition, which is allowed until the final composition cleanup. | | Selection/interaction | **Complete** | `WorldSelectionQuery` owns read-only picking/classification/range queries; `SelectionInteractionController` owns selection intent, Use/PickUp transport, exact-incarnation queues, and auto-walk deferral; `ItemInteractionController` owns ItemHolder policy plus the shared retail inventory-request transaction. `GameWindow` retains construction and narrow lifecycle forwarding only. | -| Landblock presentation | **Complete; closeout active** | `LandblockBuildFactory` owns the captured-origin DAT transaction; concrete render/physics/DAT-static publishers and `LandblockPresentationPipeline` own publication and exact retryable retirement. `StreamingOriginRecenterCoordinator` serializes old-window retirement with teleport/session origin lifetimes. `GameWindow` retains construction and one pipeline field only (`c79d0a49`). | +| Landblock presentation | **Complete** | `LandblockBuildFactory` owns the captured-origin DAT transaction; concrete render/physics/DAT-static publishers and `LandblockPresentationPipeline` own publication and exact retryable retirement. `StreamingOriginRecenterCoordinator` serializes old-window retirement with teleport/session origin lifetimes. `GameWindow` retains construction and one pipeline field only (`c79d0a49`, closeout `4a205a3e`). | | Render-frame orchestration | **Not extracted** | `OnRender` plus its portal/PView/alpha/particle helpers still own the draw graph and frame-local scratch state. There is no `RenderFrameOrchestrator` yet. | | Unified `GameEntity` | **Deferred target** | `LiveEntityRuntime` already supplies the critical canonical owner. Full type aggregation is a separate high-risk migration and is not required to make `GameWindow` thin. | @@ -547,15 +547,19 @@ Slice 5 checkpoints A–G are complete. The pipeline owns the serialized worker build, concrete render/physics/static publication, and retryable Near/full retirement. The direct cutover deleted the legacy apply/facade/scratch body and added one retained shared-origin lifetime transaction for teleport, logout, -and fast relogin. `GameWindow.cs` is 8,793 raw lines and no longer owns -landblock build/apply/retirement behavior. Deterministic and connected closeout -is active in H. +and fast relogin. `GameWindow.cs` was 8,793 raw lines at checkpoint G and no +longer owns landblock build/apply/retirement behavior. H then corrected native +shutdown ordering so live-session reset converges before the streamer and +other reset dependencies are disposed. The final measurement is 8,811 raw +lines / 247 fields / 153 methods. The complete Release suite (7,054 pass / 5 +skip), the 311-second capped/reconnect lifecycle gate, and the synchronized +394-second nine-stop resource soak all pass (`4a205a3e`). -#### Slice 5 — extract landblock presentation — CHECKPOINTS A–G COMPLETE +#### Slice 5 — extract landblock presentation — COMPLETE Detailed execution ledger: [`docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md`](../plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md) -(active). +(complete). Create `LandblockPresentationPipeline` around the existing immutable `LandblockBuild` transaction. It owns DAT build context, scenery/EnvCell @@ -565,8 +569,8 @@ what is resident; `GpuWorldState` continues owning spatial buckets. Tests pin loaded/pending/demoted/unloaded symmetry, stale build generations, resource pin/release balance, collision footprints across landblock seams, and -first-login bootstrap replacement. Run the deterministic world-lifecycle gate -and the seven-destination resource soak. +first-login bootstrap replacement. The deterministic world-lifecycle gate and +nine-destination resource soak pass. #### Slice 6 — extract update-frame orchestration diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 38ffd9a9..39fcd22c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -67,7 +67,7 @@ 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 priority 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 forced-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (owner wiring + draw eligibility); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `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 an empty 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, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | +| 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 priority 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 forced-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (owner wiring + draw eligibility); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `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 an empty 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, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `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) | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 417106a9..87c4edf4 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -47,15 +47,16 @@ passes 6,940 tests with five intentional skips. The 310-second connected R6 gate passed seven destinations/revisits, movement/jump/combat exercises, and graceful close. -**Current:** Slice 5 checkpoints A–G have extracted the DAT-backed worker +Slice 5 is complete: checkpoints A–G extracted the DAT-backed worker build plus render, physics, DAT-static, and retryable Near/full presentation ownership into `LandblockPresentationPipeline`. Production has no callback facade back into the deleted GameWindow apply bodies. A retained shared-origin transaction retires the complete old window before teleport recenter or -session replacement, including failure/retry and fast relogin. `GameWindow` is -8,793 raw lines (44% below the campaign baseline) and the Release suite passes -7,052 tests with five intentional skips. Slice 5H deterministic and connected -closeout is active. +session replacement, including failure/retry and fast relogin. Shutdown keeps +the reset graph alive until disposal converges. `GameWindow` is 8,811 raw +lines / 247 fields / 153 methods (44% below the campaign baseline); 7,054 +Release tests, the capped/reconnect lifecycle gate, and the synchronized +nine-stop resource soak pass. Slice 6 update-frame orchestration is active. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 3ac80c58..0b248c75 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -19,14 +19,15 @@ layer, the active structural prerequisite is the behavior-preserving The 2026-07-21 audit baseline was 15,723 lines, 278 fields, and 205 methods. Selection/interaction Slice 1, live-animation-presentation Slice 2, complete live-session ownership Slice 3, and live-entity App integration Slice 4 are -complete. Slice 5 landblock-presentation checkpoints A–G are also complete: +complete. Slice 5 landblock presentation is also complete: the worker DAT transaction, render/physics/static publishers, and retryable Near/full retirement now have focused owners. The direct production cutover also serializes complete old-window retirement with teleport and session -origin lifetimes. `GameWindow` is 8,793 raw lines, down 1,508 from the Slice-5 -baseline and 6,930 (44%) from the campaign baseline. The full Release suite -passes 7,052 tests / 5 skips; Slice 4's seven-destination connected R6 route -passed in 310 seconds. Slice 5H deterministic/connected closeout is active. +origin lifetimes. `GameWindow` is 8,811 raw lines / 247 fields / 153 methods, +down 1,490 lines from the Slice-5 baseline and 6,912 (44%) from the campaign +baseline. The full Release suite passes 7,054 tests / 5 skips; the 311-second +capped/reconnect lifecycle gate and synchronized 394-second nine-destination +resource soak both pass. Slice 6 update-frame orchestration is next. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md index 750ae702..9ef6385b 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md +++ b/docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md @@ -1,6 +1,6 @@ # GameWindow Slice 5 — landblock presentation -**Status:** Active 2026-07-21. +**Status:** Complete 2026-07-21 (`4a205a3e`). **Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 5. **Baseline:** `2b10e91e`; `GameWindow.cs` is 10,301 lines, 267 fields, and 163 methods. Release baseline: 6,940 passed / 5 skipped. The connected R6 @@ -98,9 +98,23 @@ test, and bisectable commit rather than being hidden in a mechanical move. `GameWindow.cs` is 9,011 lines. - [x] G — cut `StreamingController` and `GameWindow` directly to the pipeline; delete old bodies, scratch state, and callback facades. -- [ ] H — three-agent corrected-diff review, full Release suite, deterministic - lifecycle gate, seven-destination resource soak, documentation, and durable +- [x] H — three-agent corrected-diff review, full Release suite, deterministic + lifecycle gate, nine-destination resource soak, documentation, and durable memory closeout. + - Landed in `4a205a3e`: native shutdown now keeps the live-session reset + dependencies alive until deferred/reentrant disposal actually converges; + source and behavioral tests pin that completion barrier. Both connected + harnesses scan the final post-close logs for shutdown/disposed-resource + failures. The resource gate uses an already-warm Caul → Sawato → Caul + plateau instead of comparing a cold first load to a warmed cache, and + every route command blocks on its exact portal-materialization count. + - All three final reviews are clean. Release gate: 7,054 passed / 5 skipped. + The capped/reconnect lifecycle gate passed in 311.4 seconds. The + synchronized nine-stop soak passed in 394.1 seconds; Caul return → plateau + changed working/private memory by only +56.4/+48.4 MiB and update p95 by + 0.8 → 0.7 ms. `GameWindow.cs` closes Slice 5 at 8,811 raw lines, 247 + fields, and 153 methods—1,490 lines below the Slice-5 baseline and 6,912 + lines (44%) below the campaign baseline. Every checked checkpoint is committed as a bisectable architectural unit. Documentation records the exact commit and accepted test count as work lands. @@ -385,15 +399,15 @@ the shared origin may be reused. Reviews closed full-window overlap, radius reconfiguration, sealed-dungeon bootstrap, committed-callback retry, reentrancy, fast-relogin, and ordinary-logout gaps. Three final reviews are clean; the zero-warning Release build and 7,052 tests pass with five skips. -`GameWindow.cs` is 8,793 raw lines, 1,508 below the Slice-5 baseline and 6,930 -(44%) below the campaign baseline. +`GameWindow.cs` was 8,793 raw lines at checkpoint G, 1,508 below the Slice-5 +baseline and 6,930 (44%) below the campaign baseline. ### H — closeout -**Active.** No new landblock ownership moves are planned in H; it proves the -landed graph under deterministic and connected lifecycle churn, reconciles -divergence pointers/documentation, and records the final Slice-5 structural -measurement before Slice 6 begins. +**Complete in `4a205a3e`.** H proved the landed graph under deterministic and +connected lifecycle churn, corrected shutdown-stage ordering so session reset +converges before its streaming dependencies are disposed, and strengthened the +resource oracle to compare already-warm steady-state revisits. - Run focused App/Core/Net tests after each checkpoint. - After each code checkpoint, run three independent read-only reviews: retail/ @@ -401,7 +415,7 @@ measurement before Slice 6 begins. analysis. Fix confirmed findings and repeat review until clean. - Run `dotnet build AcDream.slnx -c Release` and the full Release test suite. - Run deterministic first-login/portal lifecycle automation and the connected - seven-destination resource soak. Verify retirement/preparation queues drain, + nine-destination resource soak. Verify retirement/preparation queues drain, residence matches the region, resource counts remain bounded, and graceful close succeeds. - Update the divergence register pointers in IA-14, AD-2, AD-22, and any other @@ -410,6 +424,11 @@ measurement before Slice 6 begins. - Update architecture, milestones, roadmap, issues if needed, synchronized `AGENTS.md`/`CLAUDE.md`, and durable decomposition/render memory. +The final IA-14/AD-2/AD-22 audit found no new retail divergence. IA-14 and +AD-22 still point at the correct WB/mesh owners; AD-2 now names the extracted +origin-recenter/presentation owners. AD-6, AD-24, AP-31, and TS-52 remain open +as required. + ## 6. Acceptance matrix Automated acceptance includes: From a36a7015c4aa9c5cb6c1e4ca46d18c6d4e3eb32d Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 00:06:55 +0200 Subject: [PATCH 72/98] docs(architecture): plan GameWindow update-frame extraction --- docs/architecture/code-structure.md | 16 +- .../retail-divergence-register.md | 5 +- docs/plans/2026-04-11-roadmap.md | 4 +- docs/plans/2026-05-12-milestones.md | 3 +- ...ndow-slice-6-update-frame-orchestration.md | 442 ++++++++++++++++++ ...07-19-r6-update-object-order-pseudocode.md | 161 +++++-- 6 files changed, 594 insertions(+), 37 deletions(-) create mode 100644 docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index efb4d015..9fc6cd54 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -574,12 +574,18 @@ nine-destination resource soak pass. #### Slice 6 — extract update-frame orchestration +Detailed execution ledger: +[`docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`](../plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md) +(active). + After the stateful bodies above have owners, make the update path a real -orchestrator instead of delegates back into the window. It owns the fixed -retail phase order: input/local object → ordinary/static objects → hooks/ -particles/scripts → inbound network → command interpreter → non-advancing -spatial reconcile → streaming/UI updates. `GameWindow.OnUpdate` becomes a -short time/input handoff. +orchestrator instead of delegates back into the window. It preserves the +accepted production order—not an overclaimed retail host order—covering +teardown/clock/streaming convergence, input, the retail-shaped object → inbound +network → command barrier, conditional spatial reconciles, liveness, local +teleport, player-mode entry, and camera presentation. The exact twelve-phase +graph and registered TS-53 host-order adaptation live in the detailed ledger. +`GameWindow.OnUpdate` becomes a short time/input handoff. Frame-order tests and the existing R6 gate must produce the same lifecycle and movement traces before and after extraction. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 39fcd22c..b2f3dd95 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -218,7 +218,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | | AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` | -## 4. Temporary stopgap (TS) — 34 active rows + 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) — 35 active rows + 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 | |---|---|---|---|---|---| @@ -258,8 +258,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` | | TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` | | TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order | -| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each object's ParticleManager and ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` also advances that static owner's managers using its whole admitted elapsed interval. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`) | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and manager order faithful without pretending the shared tails have per-owner timing. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static default scripts/particles use render elapsed rather than `animate_static_object` elapsed/discard timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum | +| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order | | TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 | +| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slice 6 preserves the accepted host order as an ownership-only extraction. | `src/AcDream.App/Rendering/GameWindow.cs` (`OnUpdate` teleport/camera tails and `_retailUiRuntime.Tick` in `OnRender`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 87c4edf4..f2023076 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -56,7 +56,9 @@ session replacement, including failure/retry and fast relogin. Shutdown keeps the reset graph alive until disposal converges. `GameWindow` is 8,811 raw lines / 247 fields / 153 methods (44% below the campaign baseline); 7,054 Release tests, the capped/reconnect lifecycle gate, and the synchronized -nine-stop resource soak pass. Slice 6 update-frame orchestration is active. +nine-stop resource soak pass. Slice 6 update-frame orchestration is active; +its detailed checkpoint ledger is +[`2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`](2026-07-21-gamewindow-slice-6-update-frame-orchestration.md). This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 0b248c75..ff932553 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -27,7 +27,8 @@ origin lifetimes. `GameWindow` is 8,811 raw lines / 247 fields / 153 methods, down 1,490 lines from the Slice-5 baseline and 6,912 (44%) from the campaign baseline. The full Release suite passes 7,054 tests / 5 skips; the 311-second capped/reconnect lifecycle gate and synchronized 394-second nine-destination -resource soak both pass. Slice 6 update-frame orchestration is next. +resource soak both pass. Slice 6 update-frame orchestration is active under +[`2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`](2026-07-21-gamewindow-slice-6-update-frame-orchestration.md). Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md new file mode 100644 index 00000000..6de9b5d2 --- /dev/null +++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md @@ -0,0 +1,442 @@ +# GameWindow Slice 6 — update-frame orchestration + +**Status:** Active 2026-07-21. +**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 6. +**Baseline:** `d4ecac1d`; `GameWindow.cs` is 8,811 raw lines, 247 fields, and +153 methods. Release baseline: 7,054 passed / 5 skipped. The connected +lifecycle gate and synchronized nine-stop resource soak both pass. +**Behavior rule:** This is an ownership extraction. Preserve the accepted +retail object/network order, asynchronous streaming-readiness adaptation, +portal transit, input semantics, and camera presentation. A defect exposed by +the boundary receives its own deterministic oracle and bisectable fix; it is +never hidden behind a retry, delay, suppression flag, or reordered callback. + +## Progress ledger + +- [ ] A — freeze the complete production phase graph and introduce the typed + orchestration contract plus structural/order guards. +- [ ] B — extract the pre-network live-object presentation phase and the + non-advancing post-network spatial reconciler; remove callbacks into + `GameWindow` from `RetailLiveFrameCoordinator`. +- [ ] C — extract streaming-origin convergence, observer selection, dungeon + collapse, streaming tick, and rescued-entity rebucketing. +- [ ] D — extract dispatcher/raw-mouse/combat input sampling without changing + movement-command or UI-capture semantics. +- [ ] E — extract the complete local-player teleport owner and wire it between + the existing post-network liveness and player-mode auto-entry phases. +- [ ] F — extract the shared player-mode lifecycle plus fly/chase/player + camera presentation and cut production over to one + `UpdateFrameOrchestrator`. +- [ ] G — delete the old `OnUpdate` bodies, callback facades, and obsolete + frame state; run focused corrected-diff reviews after each ownership edge. +- [ ] H — full Release suite, connected lifecycle/reconnect gate, synchronized + resource soak, documentation, durable memory, and line/field/method closeout. + +Every checked checkpoint is committed as one bisectable architectural unit. +Documentation records the exact commit and accepted test count as work lands. + +## 1. Outcome and non-goals + +At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one +host delta to `UpdateFrameOrchestrator`. `GameWindow` composes concrete owners; +it no longer selects a streaming observer, advances animations/effects, +samples raw mouse input, sequences inbound network traffic, advances portal +transit, or updates gameplay cameras. + +The owner graph is explicit rather than a list of anonymous callbacks: + +```text +UpdateFrameOrchestrator + ├── live teardown convergence retry retained tombstones + ├── UpdateFrameClock normalized host time / PES clock + ├── StreamingFrameController residence/readiness pre-pass + ├── GameplayInputFrameController semantic input + mouse/combat intent + ├── RetailLiveFrameCoordinator retail SmartBox object barrier + │ ├── LiveObjectFrameController local/ordinary/static/effect tails + │ ├── LiveSessionController inbound network drain + │ ├── RetailLocalPlayerFrame CommandInterpreter position tail + │ └── LiveSpatialReconciler non-advancing authoritative refresh + ├── LiveEntityLivenessController expiry after accepted inbound state + ├── LocalPlayerTeleportController local transit/reveal/tunnel state + ├── PlayerModeAutoEntry one-shot ready-world entry + ├── PlayerModeController enter/exit/current-player lifetime + └── CameraFrameController fly/chase/player presentation +``` + +Concrete names may be narrowed during checkpoint A's source audit, but the +ownership boundaries and order do not change. The orchestrator receives a +small immutable per-frame input and concrete phase owners. It does not receive +a hundred delegates, one mega `IUpdateFrameServices` context, or a service +locator, and no transitive update collaborator calls a substantial method back +on `GameWindow`. + +This slice does not change simulation rate, object quantum rules, movement +speed, interpolation, streaming radii, reveal readiness, portal visuals, +particle range, combat cadence, key bindings, camera formulas, render order, +or shutdown order. It adds no GL work and does not move retained-UI timers out +of their render seam. Slice 7 owns the draw graph; Slice 8 owns final +composition and Silk callback cleanup. + +## 2. Retail oracle and accepted adaptation + +The object/network barrier is pinned by +[`2026-07-19-r6-update-object-order-pseudocode.md`](../research/2026-07-19-r6-update-object-order-pseudocode.md): + +- `CPhysicsObj::update_object @ 0x00515D10` applies the strict object quantum; +- `CPhysicsObj::UpdatePositionInternal @ 0x00512C30` advances the PartArray, + position/physics candidate, then processes semantic hooks; +- `CPhysicsObj::UpdateObjectInternal @ 0x005156B0` runs Detection → Target → + Movement → `CPartArray::HandleMovement` → Position → Particle → Script; +- `SmartBox::UseTime @ 0x00455410` advances object/physics time before the + inbound queue and then calls `CommandInterpreter::UseTime`; +- `gmSmartBoxUI::UseTime @ 0x004D6E30` owns UI/viewport time outside that + object barrier. + +At the retail host level, `Client::UseTime @ 0x00411C40` calls +`UIElementManager::UseTime` before `SmartBox::UseTime`; the UI time broadcast +reaches `gmSmartBoxUI::UseTime`. Retail also publishes player-camera work from +the physics/player callback rather than from one post-network camera tail. +acdream currently advances teleport/UI-camera presentation after its +object/network/command barrier. Slice 6 preserves that existing order as +registered TS-53; it does not cite `gmSmartBoxUI` as proof that the current +post-network placement is retail-exact. + +acdream preserves two documented presentation adaptations: + +1. only semantic `AnimationDone` executes at retail's in-object hook slot; + every other captured animation hook retains the current deferred path after + final root, part, and equipped-child poses are published, including + particles, audio, lights, translucency, `CallPES`, and default-script + chaining (TS-50); +2. shared particle simulation and PhysicsScript queues tick once per host + update after both worksets rather than once per object's admitted quantum. + This also means the current shared Particle → Script order does not reproduce + the static workset's retail Script → Particle → hooks order (TS-51). + +Asynchronous landblock streaming has no one-call retail equivalent. Its +convergence pre-pass remains before inbound CreateObject dispatch so a newly +accepted projection can find a resident bucket in the same frame. This +pre-pass does not advance an object's retail clock. `WorldRevealCoordinator` +remains the single login/portal readiness owner. + +## 3. Fixed production phase order + +The complete host-update graph is frozen as follows: + +1. retry canonical live-entity teardown tombstones; an `AggregateException` is + logged and later phases continue, while any other exception propagates; +2. normalize the host delta, advance the shared PES clock once, and publish it; +3. advance pending origin retirement/recenter, select the observer, tick + streaming, and rebucket rescued persistent entities; +4. tick `InputDispatcher`, consume one raw mouse sample, and advance combat + attack intent; +5. enter the non-reentrant inbound-event envelope and advance the pre-network + live-object phase: + - local object and pre-network movement/jump output; + - queued selection/use output after movement edges; + - ordinary live objects; + - the distinct static-animation workset; + - equipped children and static hook completion; + - fades, every deferred non-`AnimationDone` hook, and current effect/light + anchors; + - particle visibility and particle simulation; + - PhysicsScript queues; +6. drain inbound session traffic inside one `GpuWorldState.MutationBatch`; +7. run the local player's post-network `CommandInterpreter` position check; +8. perform the ordinary post-network non-advancing root/child/emitter/light + reconcile; +9. advance liveness expiry from the absolute client timer; +10. activate/tick local teleport transit. A Place edge commits the player and + live root, immediately performs its own non-advancing reconcile, then + notifies world reveal and continues tunnel state; +11. evaluate one-shot player-mode auto-entry; +12. update fly or chase/player camera presentation. If the local player was + first created by this frame's inbound pass, publish its non-advancing + projection, immediately reconcile a second time, then update the camera. + +Those three reconcile edges—ordinary post-network, teleport Place, and +inbound-created-player projection—are separate accepted production edges. Any +future consolidation is a behavior change with its own oracle, not part of +this extraction. + +An invalid/non-positive host delta becomes zero for the PES/object/particle/ +tunnel delta but never blocks inbound traffic, absolute-time liveness, +recenter convergence, or camera-state publication. Raw mouse idle uses its +independent monotonic input clock. A missing gameplay camera/input owner never +suppresses outer phases; the streaming controller may skip observer-driven +streaming when it has no valid live or offline observer, while origin +retirement still advances. Devtools keyboard capture pauses player/camera +control including autorun; retained chat capture suppresses held WASD while +autorun continues; mouse capture ends mouse-look but does not suppress +keyboard, combat, network, streaming, teleport, or teardown. + +## 4. Architecture and interfaces + +### 4.1 Per-frame input + +Use an immutable value equivalent to: + +```csharp +public readonly record struct UpdateFrameInput(double HostDeltaSeconds); +``` + +Derived facts are read by their owning controller from stable runtime owners +or from a narrowly typed state source. Do not pass `GameWindow`, expose its +private fields wholesale, or create a service locator. Mutable player/session +owners that legitimately change during login are represented by one focused +state/source interface per domain, not anonymous closures into window methods. + +`UpdateFrameClock` is constructed before any materializer/network owner that +needs the current PES time. It owns the normalized host/PES clock and exposes a +narrow `CurrentScriptTime` source; `_physicsScriptGameTime` does not remain a +`GameWindow` field captured by later owners. Absolute liveness time and raw +mouse idle time remain distinct monotonic sources and are not derived from the +normalized simulation delta. + +### 4.2 Object phase + +`LiveObjectFrameController` owns the body currently in +`AdvanceLiveObjectRuntimeCore`. It composes existing focused owners and keeps +their retail order; it does not absorb their algorithms or identity maps. +`LiveSpatialPresentationReconciler` owns the current no-time refresh body. +`RetailLiveFrameCoordinator` depends on those typed owners plus the existing +session and local-player owners. Its public `Tick` remains the one explicit +object → network → command → reconcile barrier. + +`RetailInboundEventDispatcher` continues wrapping the complete object phase. +Reentrant packets queue until the current object's manager tail completes. +The hot path remains allocation-free after warmup. + +The extraction inventory includes the complete transitive update graph, not +only `RetailLiveFrameCoordinator`: `RetailLocalPlayerFrameController` loses +substantial window captures in favor of a focused local-player slot, movement +input source, outbound owner, and projection owner; +`LiveEntityAnimationPresenter` receives a concrete animation-presentation +context instead of retaining `GameWindow` as +`ILiveAnimationPresentationContext`; scheduler clock/player/projectile sources +are similarly narrow. Existing small value/policy callbacks are acceptable +only when they do not call a substantial body back on the window or create a +second identity/state owner. + +### 4.3 Streaming phase + +`StreamingFrameController` owns observer selection and the sealed-dungeon +gate. It receives the existing `StreamingOriginRecenterCoordinator`, +`StreamingController`, `LiveWorldOriginState`/center state, camera/player +state, physics current cell, `GpuWorldState`, and `LiveEntityRuntime` through +typed seams. It never builds, publishes, or retires a landblock; those remain +Slice 5 owners. + +The controller preserves: + +- no pre-login hardcoded-Holtburg streaming; +- real-player-origin readiness before first streaming; +- destination-origin pinning while in portal space; +- authoritative player landblock during the login hold; +- offline fly-camera observation only outside live in-world mode; +- sealed EnvCell collapse using the cell's own landblock identity; +- teleport-hold suppression of the stale source-cell dungeon gate; +- rescued-entity rebucketing after the accepted streaming tick. + +### 4.4 Input and camera phases + +`GameplayInputFrameController` owns per-frame dispatcher hold delivery, +UI-capture transitions, raw mouse sample/filter/idle handling, combat attack +ticking, and the complete mouse-look/cursor-capture lifetime. It is the single +owner of mouse-look state, saved cursor mode, begin/end/hide/restore, and +movement teardown across press/release, capture change, focus loss, teleport, +session reset, player-mode exit, and shutdown. Action press/release callbacks +remain on `InputDispatcher` and movement stays owned by +`PlayerMovementController`. + +`PlayerModeController` owns enter/exit/toggle construction and the current +player-controller, physics-host, chase-camera, and chase-mode slot. Manual +input, `PlayerModeAutoEntry`, and local teleport activation all enter through +that one owner. Streaming, camera, local-frame, and teleport owners receive +narrow read sources from it; no path calls the substantial current +`EnterPlayerModeNow`/exit bodies back on `GameWindow` or duplicates player-mode +state. + +`CameraFrameController` owns held fly-camera controls, retail chase offsets, +post-network player presentation sampling, and both chase-camera updates. It +does not own physics, target selection, or camera formulas. The local player +created by the inbound pass gets a non-advancing projection before its first +draw exactly as today, followed by the current second spatial reconcile before +the camera samples that projection. + +### 4.5 Teleport phase + +`LocalPlayerTeleportController` owns only the current local-player transit +state machine and presentation edges. It composes +`TeleportTransitCoordinator`, +`TeleportViewPlaneController`, `PortalTunnelPresentation`, +`WorldRevealCoordinator`, streaming priority, and the session LoginComplete +send. Checkpoint E first extracts the currently window-owned local aim, +destination, hold/forced, activation, placement, reset, and session-clear +state into this owner plus a concrete local placement collaborator where +needed. It does not reuse `RemoteTeleportPlacement`, and network/session sinks +do not retain `AimTeleportDestination` or reset callbacks into `GameWindow`. +`LiveEntityLivenessController`, tombstone retry, and `PlayerModeAutoEntry` +remain separate focused phases; teleport does not absorb them. + +### 4.6 Composition, thread, and lifetime + +The clock and focused dynamic sources are constructed before their first +consumer. The final orchestrator is composed exactly once at the end of a +successful `OnLoad`; `OnUpdate` fails fast if that invariant is broken rather +than running a nullable partial graph. Every phase mutates state only on the +existing update/render thread. Streaming worker completion remains behind +`StreamingController.Tick`; the extraction adds no lock, task, or async entry. + +Phase controllers borrow their input, session, streaming, portal, camera, and +render-presentation owners; they do not dispose shared dependencies. Any new +event subscription has explicit symmetric detach. During shutdown the update +collaborators remain alive through the first-stage `LiveSessionController` +reset, then retire before their underlying input/camera/portal/streaming +resources. Existing retryable shutdown ordering remains authoritative. + +## 5. Checkpoints and acceptance + +### A — contract and guards + +- record the exact production phase graph and every mutable dependency; +- correct and pin the retail oracle for physics-hook retention/reentrant + append, inactive object behavior, and static Script → Particle → hooks; pin + the existing TS-50/TS-51 divergences without reordering runtime behavior in + this ownership-only slice; +- add an explicit orchestrator order test covering all twelve phases and the + three conditional reconcile edges; +- pin normalized simulation/PES/particle/tunnel time separately from absolute + liveness and raw-mouse idle clocks for NaN, both infinities, non-positive + values, and finite values above `float.MaxValue`; +- pin missing live/offline camera behavior and the separate devtools keyboard, + retained-chat keyboard, and mouse-capture contracts; +- pin teardown retry success, `AggregateException` log-and-continue, other + exception propagation, and next-frame retry; +- add source guards that prevent `OnUpdate` or the coordinator from regaining + substantial callback bodies, and prevent any transitive update owner from + retaining `GameWindow` or one mega service context; +- verify current production traces before cutover. + +### B — object/network barrier + +- move the pre-network animation/effect body into + `LiveObjectFrameController`; +- move no-time root/child/effect refresh into + `LiveSpatialPresentationReconciler`; +- replace coordinator callbacks with typed collaborators; +- preserve semantic/visual hook separation, owner-incarnation revalidation, + static-after-ordinary order, ordinary Particle-before-Script, the current + shared-tail TS-51 adaptation, and once-per-host tails; +- delete `AdvanceLiveObjectRuntimeCore` and + `ReconcileLiveObjectSpatialPresentation` from `GameWindow`. + +Focused tests cover `RetailObjectActivityGate` and +`RetailObjectQuantumClock`: parented/cell-less/Frozen suspension, inactive +Particle/Script-only time, Hidden+Active adjustment/hooks/manager time, and +separate ordinary epsilon/Min/Max/Huge plus static epsilon/Huge boundaries. +They also cover incomplete physics-hook +retention, same-drain animation-hook append, deferred `CallPES`/default-script/ +translucency routing, physics-hook append deferred until the next drain, +completed physics-hook removal preserving the snapshotted successor, static +hooks, callback GUID reuse, and reentrant inbound packets. Particle then Script +must run exactly once for an empty world, +multiple ordinary/static owners, invalid delta, and reentrant inbound work; +the one published PES clock remains visible from hook capture through the +script tick. + +### C — streaming frame + +- extract readiness/recenter advancement, observer selection, dungeon gate, + streaming tick, and rescued projection rebucketing; +- test login-before-player-spawn, outdoor/dungeon/portal-hold/offline observer + cases, missing live/offline camera sources, and cell-landblock identity; +- prove same-frame streaming completion precedes CreateObject projection into + the resident bucket, unknown pre-login origin enqueues nothing, and recenter + retirement continues while ordinary streaming is gated; +- verify no build/publication/retirement ownership leaks back from Slice 5. + +### D — input frame + +- extract dispatcher tick, UI mouse-capture edge, raw sample/filter/idle path, + combat intent tick, and the one cross-lifetime mouse-look/cursor owner; +- test capture entry while mouse-look is active, zero-idle drift stop, + production action ordering, devtools-vs-chat autorun semantics, mouse-only + capture, and focus/teleport teardown; +- prove every non-input phase still runs under each capture mode; +- retain diagnostic semantic input as the unattended connected gate seam. + +### E — local teleport and post-network world frame + +- extract the currently window-owned local-player teleport state and every + aim/destination/activation/place/reset/session port before moving its tick; +- retain liveness and auto-entry as separate orchestrator calls around pending + transit activation, readiness/hold, tunnel events, materialization, and + reveal completion; +- test loaded and pending destinations, forced-readiness boundary, same-cell + respawn, LoginComplete ordering, delete/reset, and no second reveal owner; +- pin Place → immediate reconcile → reveal and old-GUID liveness candidate → + inbound delete/recreate generation safety; +- preserve the completed portal visuals and world-reveal barrier unchanged. + +### F/G — camera, cutover, and deletion + +- extract player-mode enter/exit/toggle and current-owner state before camera + or teleport cutover; retain `PlayerModeAutoEntry` only as its one-shot + readiness guard; +- extract fly camera, retail chase adjustment, local post-network projection, + combat target tracking, and chase-camera publication; +- pin inbound-created projection → immediate second reconcile → camera, with + exact root/child/emitter/light refresh counts and no duplicate time advance; +- compose `UpdateFrameOrchestrator` only after all dependencies exist; +- replace `OnUpdate` with profiler scope plus one `Tick` call; +- remove obsolete frame fields/facades and prove there is no back-reference to + `GameWindow` from an extracted owner; +- measure line/field/method count. The target is below 8,000 lines, but the + ownership/ordering exit criteria outrank the count. + +### H — release and connected gates + +After all three independent corrected-diff reviews are clean: + +1. run every focused App/Core/Net/UI test project; +2. run `dotnet build AcDream.slnx -c Release`; +3. run the full Release suite; +4. run the connected lifecycle/reconnect gate; +5. run the synchronized nine-stop resource soak with movement, turn, jump, + combat, portal materialization waits, fatal-log scan, and graceful close; +6. compare lifecycle, outbound movement, materialization, entity/animation, + frame-profile, resource, and teardown traces with the Slice 5 baseline; +7. audit the divergence register, architecture, milestones, roadmap, issues, + `AGENTS.md`, `CLAUDE.md`, and durable memory. + +The slice requires no new visual behavior. If connected screenshots or traces +change, return to the responsible checkpoint; do not defer the difference to +Slice 7. + +## 6. Review gate for every checkpoint + +After each implementation checkpoint: + +1. run focused tests and inspect the complete diff; +2. retail-conformance review checks named functions, exact order, Hidden/ + quantum semantics, and adaptation boundaries; +3. architecture review checks dependency direction, absence of a replacement + god object/service locator, same-thread mutation, and symmetric teardown; +4. adversarial review checks independent clocks, callback reentrancy, + owner/GUID reuse, login/portal transitions, capture modes, missing observer + sources, teardown failures, and early-return phase suppression; +5. fix every confirmed root cause and repeat review until clean; +6. build/test Release and commit the checkpoint before starting the next one. + +## 7. Divergence and documentation bookkeeping + +TS-50, TS-51, and the newly documented existing host-order TS-53 remain until +their exact adaptations are ported; this slice must not overclaim retail +parity. IA-14/AD-22 and the lifetime/resource rows are audited against the +final owner graph but changed only if their mechanism actually changes. Any +newly discovered unavoidable adaptation gets a register row in the same +commit. The Slice 6 closeout updates the code-structure table, +roadmap, milestones, session docs, and +`project_gamewindow_decomposition.md` with exact commits, test counts, gates, +and final metrics. diff --git a/docs/research/2026-07-19-r6-update-object-order-pseudocode.md b/docs/research/2026-07-19-r6-update-object-order-pseudocode.md index c4e87243..74a5ab55 100644 --- a/docs/research/2026-07-19-r6-update-object-order-pseudocode.md +++ b/docs/research/2026-07-19-r6-update-object-order-pseudocode.md @@ -8,18 +8,23 @@ Retail oracle: Asheron's Call v11.4186 (September 2013 EoR) This note pins the control-flow order needed by R6. It deliberately separates two kinds of animation-hook work: -- **control hooks** such as `AnimDoneHook`, whose result changes the motion +- the immediate **`AnimDoneHook` control path**, whose result changes the motion queues observed later in the same object update; and -- **pose-dependent presentation hooks** such as particles and sounds, whose - App-layer delivery may wait until the final root/part pose has been - published without changing motion state. +- **all other captured animation hooks**, which acdream currently defers until + the final root/part pose has been published. This includes presentation + hooks such as particles and sounds, but also semantically relevant + `CallPES`, default-script chaining, and translucency hooks. -That split is an acdream presentation boundary. It does not change retail's -logical hook order. +That split is an acdream presentation boundary. Authored FIFO order is +preserved within acdream's deferred non-`AnimationDone` stream, but moving that +stream past retail's manager tail changes manager-relative timing; that is the +registered TS-50 divergence. ## Named-retail sources - `CPhysicsObj::process_hooks` `0x00511550` +- `CPhysics::UseTime` `0x00509950` +- `CPhysicsObj::animate_static_object` `0x00513DF0` - `CPhysicsObj::UpdatePositionInternal` `0x00512C30` - `CPhysicsObj::UpdateObjectInternal` `0x005156B0` - `CPhysicsObj::update_object` `0x00515D10` @@ -58,6 +63,17 @@ CPhysicsObj::update_object -> PositionManager::UseTime -> ParticleManager::UpdateParticles -> ScriptManager::UpdateScripts + +CPhysics::UseTime + -> ordinary object table in the order above + -> static_animating_objects + -> CPhysicsObj::animate_static_object + -> CPartArray::Update + -> UpdatePartsInternal + -> UpdateChildrenInternal + -> ScriptManager::UpdateScripts + -> ParticleManager::UpdateParticles + -> CPhysicsObj::process_hooks ``` ## Pseudocode @@ -66,11 +82,16 @@ CPhysicsObj::update_object ```text if parent != null or cell == null or state contains Frozen: + clear Active transient state return refresh active/view-distance state elapsed = current_physics_time - last_update_time +if elapsed <= FrameEpsilon (0.000199999995 seconds): + last_update_time = current_physics_time + return + if elapsed > HugeQuantum: last_update_time = current_physics_time return @@ -85,8 +106,19 @@ if elapsed > MinQuantum: UpdateObjectInternal(elapsed) ``` -The comparisons are strict (`>`), not `>=`. A sub-minimum remainder stays in -the clock for a later call. +The comparisons are strict (`>`), not `>=`. Elapsed time at or below the +roughly 0.2-millisecond frame epsilon is explicitly rebased/discarded. Above +that epsilon, a remainder at or below `MinQuantum` stays in the clock for a +later call; `MaxQuantum` controls subdivision and `HugeQuantum` controls stale +discard. These are separate gates. + +`UpdateObjectInternal` then branches on the transient Active bit. An inactive +object skips position integration, hook processing, Detection, Target, +Movement, PartArray, and PositionManager, but still advances its +ParticleManager and ScriptManager tails. A Hidden object that remains Active +is different: it enters `UpdatePositionInternal`, suppresses only PartArray +advance and physics integration, and retains PositionManager adjustment, +hook processing, and the complete manager tail. ### `CPhysicsObj::UpdatePositionInternal(dt, candidate)` @@ -120,13 +152,25 @@ manager tail. ### `CPhysicsObj::process_hooks` ```text -execute each queued physics hook in FIFO order -clear physics-hook queue +for each physics hook present in the linked list walk: + completed = hook.Execute(owner) + if completed: + unlink and destroy that hook + else: + retain it for a later object update -execute each queued animation hook in FIFO order -clear animation-hook queue +for i from zero while i < current animation-hook count: + animation_hooks[i].Execute(owner) + // the bound is live: a hook appended during execution can run in this drain +shrink animation-hook storage and set the count to zero ``` +The physics-hook walk snapshots each current node's `next` pointer before +execution. A completed hook is removed; an incomplete hook remains linked. +The animation-hook loop deliberately rereads `m_num` after each callback, so +reentrant append is consumed in the same drain. Neither collection is an +unconditional snapshot-and-clear queue. + For an `AnimDoneHook`: ```text @@ -175,8 +219,52 @@ if script_manager != null: ``` The tail is reached for both visible and Hidden objects when the object quantum -runs. A missing manager is simply skipped. No manager is reordered around -another manager based on local/remote/player/NPC classification. +runs and the object is Active. An inactive object skips this position/manager +body but still reaches Particle then Script. A missing manager is simply +skipped. No manager is reordered around another manager based on +local/remote/player/NPC classification. + +### `CPhysicsObj::animate_static_object` + +```text +if cell == null: + return + +elapsed = current_time - update_time +if elapsed <= FrameEpsilon (0.000199999995 seconds): + update_time = current_time + return +if elapsed > HugeQuantum: + update_time = current_time + return + +if part_array != null: + if state contains HasDefaultAnim: + part_array.Update(elapsed) + rotate root by the current angular vector + UpdatePartsInternal() + UpdateChildrenInternal() + + if state contains HasDefaultScript and script_manager != null: + script_manager.UpdateScripts() + + if particle_manager != null: + particle_manager.UpdateParticles() + + process_hooks() + +update_time = current_time +``` + +The static workset has no ordinary-object `MinQuantum` remainder gate or +`MaxQuantum` subdivision loop: it uses only the frame epsilon and +`HugeQuantum`, then passes the complete admitted elapsed time to PartArray. +It therefore has a distinct Script → Particle → hooks tail, not ordinary +objects' Particle → Script tail. acdream currently shares one +ParticleSystem and one PhysicsScriptRunner across every owner and advances +them once after the complete ordinary/static worksets. That is the registered +TS-51 adaptation and must not be silently reordered by an ownership-only +extraction. ## acdream conformance boundary @@ -186,9 +274,11 @@ unsupported `DetectionManager` is represented by an absent callback rather than an invented behavior. Animation-hook capture performs `AnimationDone` control effects immediately, -at the `process_hooks` boundary. Pose-dependent routing remains deferred until -the current frame's root/part poses are published. Deferred routing must never -invoke `AnimationDone` a second time and must never own +at the `process_hooks` boundary. Every other captured animation hook currently +remains deferred until the final root/part poses are published, including +particle/audio/light/translucency hooks, `CallPES`, and default-script +chaining. Deferred routing must never invoke `AnimationDone` a second time and +must never own `MotionTableManager.UseTime`; that belongs only to the PartArray manager-tail slot. @@ -199,17 +289,32 @@ slot. 2. Target runs before Movement; Movement runs before PartArray; PartArray runs before PositionManager. 3. A missing manager does not shift or duplicate another callback. -4. Hidden objects skip sequence advance but retain the same manager tail. -5. A visual hook sees a pose published after capture, while an `AnimDoneHook` - changes motion state at capture time. -6. Deferred visual-hook drain never completes an animation twice and never - performs a zero-tick motion sweep. -7. A sub-MinQuantum frame performs no object-tail call; accumulated time is - consumed by a later quantum. +4. Parented, cell-less, and Frozen objects return before clock admission. +5. Inactive objects skip position/hooks/manager time but retain Particle then + Script; Hidden + Active objects skip sequence/physics but retain adjustment, + hooks, and the manager tail. +6. A non-`AnimationDone` hook sees a pose published after capture, while an + `AnimDoneHook` changes motion state at capture time. +7. Deferred hook drain never completes an animation twice and never performs + a zero-tick motion sweep; representative `CallPES`, default-script, and + translucency hooks retain their current deferred path. +8. An incomplete physics hook remains linked, and an animation hook appended + by another animation hook executes in the same retail drain. +9. Static objects retain their distinct Script → Particle → hooks order. +10. Ordinary epsilon/Min/Max/Huge boundaries and static epsilon/Huge + boundaries preserve their separate strict retail comparisons. ## Cross-reference notes -ACE's physics/motion ports remain interpretation aids for manager ownership -and state naming; the retail binary above is authoritative for order. The -existing `RemoteChaseEndToEndHarnessTests` hand-codes acdream's pre-R6 order -and is therefore a migration target, not an oracle. +ACE's `PhysicsObj.cs` corroborates ordinary Particle → Script and static +Script → Particle → hooks order. It does **not** corroborate the retail clock +or hook-lifetime gates: ACE uses its server `TickRate`/Min/Max policy and +clears physics hooks unconditionally, unlike retail's epsilon/Huge behavior +and completion-based hook retention. ACViewer exposes the same client-shaped +methods and manager order but shares ancestry with ACE, so that agreement is +corroboration rather than a second oracle. holtburger's native protocol crates +have no client physics-loop equivalent (its vendored ACViewer copy is not +independent). The retail binary above is authoritative for every boundary. +The existing +`RemoteChaseEndToEndHarnessTests` hand-codes acdream's pre-R6 order and is +therefore a migration target, not an oracle. From 99a3e819c4499ea1127399fa4bf220e211e79847 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 00:15:27 +0200 Subject: [PATCH 73/98] refactor(runtime): define the update-frame contract --- ...ndow-slice-6-update-frame-orchestration.md | 9 +- .../LiveEntityNetworkUpdateController.cs | 7 +- .../DatLiveEntityProjectionMaterializer.cs | 7 +- src/AcDream.App/Rendering/GameWindow.cs | 19 +- .../Update/UpdateFrameOrchestrator.cs | 178 +++++++ .../World/RetailLiveFrameCoordinator.cs | 8 +- .../World/RecallTeleportAnimationTests.cs | 9 +- .../World/UpdateFrameOrchestratorTests.cs | 435 ++++++++++++++++++ 8 files changed, 646 insertions(+), 26 deletions(-) create mode 100644 src/AcDream.App/Update/UpdateFrameOrchestrator.cs create mode 100644 tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md index 6de9b5d2..335153ac 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md @@ -13,7 +13,7 @@ never hidden behind a retry, delay, suppression flag, or reordered callback. ## Progress ledger -- [ ] A — freeze the complete production phase graph and introduce the typed +- [x] A — freeze the complete production phase graph and introduce the typed orchestration contract plus structural/order guards. - [ ] B — extract the pre-network live-object presentation phase and the non-advancing post-network spatial reconciler; remove callbacks into @@ -35,6 +35,13 @@ never hidden behind a retry, delay, suppression flag, or reordered callback. Every checked checkpoint is committed as one bisectable architectural unit. Documentation records the exact commit and accepted test count as work lands. +Checkpoint A introduced the internal `AcDream.App.Update` contract, one typed +`UpdateFrameClock`/`IPhysicsScriptTimeSource`, the twelve-phase outer-order +oracle, exact teardown and invalid-delta tests, and guards against a transitive +`GameWindow` clock capture. Production now publishes PES time once per update; +18 focused Release tests and the full 2,709-test App project pass, and all +three corrected-diff reviews are clean. + ## 1. Outcome and non-goals At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index a73c69eb..aaebdac4 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -4,6 +4,7 @@ using AcDream.App.Interaction; using AcDream.App.Physics; using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; +using AcDream.App.Update; using AcDream.App.World; using AcDream.Content; using AcDream.Core.Net; @@ -52,7 +53,7 @@ internal sealed class LiveEntityNetworkUpdateController private readonly LocalPlayerOutboundController _localPlayerOutbound; private readonly Func _playerHostSource; private readonly Func _playerGuid; - private readonly Func _gameTime; + private readonly IPhysicsScriptTimeSource _gameTime; private readonly Func _session; private readonly LiveEntityInboundAuthorityGate _authorityGate; private readonly Action _aimTeleportDestination; @@ -62,7 +63,7 @@ internal sealed class LiveEntityNetworkUpdateController private PlayerMovementController? _playerController => _playerControllerSource(); private EntityPhysicsHost? _playerHost => _playerHostSource(); private uint _playerServerGuid => _playerGuid(); - private double _physicsScriptGameTime => _gameTime(); + private double _physicsScriptGameTime => _gameTime.CurrentScriptTime; private IReadOnlyDictionary _entitiesByServerGuid => _liveEntities.MaterializedWorldEntities; private IReadOnlyDictionary _visibleEntitiesByServerGuid => @@ -97,7 +98,7 @@ internal sealed class LiveEntityNetworkUpdateController LocalPlayerOutboundController localPlayerOutbound, Func playerHostSource, Func playerGuid, - Func gameTime, + IPhysicsScriptTimeSource gameTime, Func session, Action publishTimestamps, Action aimTeleportDestination, diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs index 79660df4..34bbed82 100644 --- a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -2,6 +2,7 @@ using System.Numerics; using AcDream.App.Physics; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; +using AcDream.App.Update; using AcDream.App.World; using AcDream.Content; using AcDream.Core.Meshing; @@ -44,7 +45,7 @@ internal sealed class DatLiveEntityProjectionMaterializer private readonly LiveEntityAnimationPresenter _animationPresenter; private readonly RetailStaticAnimatingObjectScheduler _staticAnimations; private readonly LiveWorldOriginState _origin; - private readonly Func _gameTime; + private readonly IPhysicsScriptTimeSource _gameTime; private int _received; private int _hydrated; @@ -76,7 +77,7 @@ internal sealed class DatLiveEntityProjectionMaterializer LiveEntityAnimationPresenter animationPresenter, RetailStaticAnimatingObjectScheduler staticAnimations, LiveWorldOriginState origin, - Func gameTime) + IPhysicsScriptTimeSource gameTime) { _options = options ?? throw new ArgumentNullException(nameof(options)); _dats = dats ?? throw new ArgumentNullException(nameof(dats)); @@ -791,7 +792,7 @@ internal sealed class DatLiveEntityProjectionMaterializer _projectiles.TryBind( expectedRecord, setup, - _gameTime(), + _gameTime.CurrentScriptTime, _origin.CenterX, _origin.CenterY); diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e4a0f07e..c06fa66e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -158,6 +158,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator; + private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new(); private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; // Step 7 projectile presentation. The controller owns no identity map; // each runtime component is stored on the canonical LiveEntityRecord. @@ -295,7 +296,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner; private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader; private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects; - private double _physicsScriptGameTime; private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new(); // Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but @@ -2569,7 +2569,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _animationPresenter, _staticAnimationScheduler!, _liveWorldOrigin, - () => _physicsScriptGameTime); + _updateFrameClock); var originCoordinator = new AcDream.App.World.LiveEntityWorldOriginCoordinator( _liveWorldOrigin, @@ -2642,7 +2642,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _localPlayerOutbound, () => _playerHost, () => _playerServerGuid, - () => _physicsScriptGameTime, + _updateFrameClock, () => LiveSession, PublishLocalPhysicsTimestamps, AimTeleportDestination, @@ -3800,16 +3800,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Console.Error.WriteLine($"[live-entity-teardown] {error}"); } - double frameSeconds = - AcDream.App.World.RetailLiveFrameCoordinator.NormalizeDeltaSeconds(dt); - float frameDelta = (float)frameSeconds; + AcDream.App.Update.UpdateFrameTiming frameTiming = _updateFrameClock.Advance( + new AcDream.App.Update.UpdateFrameInput(dt)); + double frameSeconds = frameTiming.SimulationDeltaSeconds; + float frameDelta = frameTiming.SimulationDeltaSecondsSingle; // Retail ScriptManager::AddScriptInternal (0x0051B310) stamps // Timer::cur_time at the packet/default-script call site. Publish this // update frame's clock before streaming and network dispatch, then // reuse the same timestamp for animation hooks and the later drain. - _physicsScriptGameTime += frameSeconds; - _scriptRunner?.PublishTime(_physicsScriptGameTime); + _scriptRunner?.PublishTime(frameTiming.ScriptTime); // Phase A.1: advance the streaming controller FIRST so the initial // landblocks are loaded into GpuWorldState before live-session @@ -5739,7 +5739,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Use directly from Silk's key callback inverted that order and left // the client walking to a corpse whose server callback was cancelled. _selectionInteractions?.DrainOutbound(); - _scriptRunner?.PublishTime(_physicsScriptGameTime); System.Numerics.Vector3? playerPosition = _entitiesByServerGuid.TryGetValue( _playerServerGuid, @@ -5792,7 +5791,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // ParticleManager before ScriptManager. A particle created by a PES // hook therefore begins simulation on the following object frame. _particleSystem?.Tick(dt); - _scriptRunner?.Tick(_physicsScriptGameTime); + _scriptRunner?.Tick(_updateFrameClock.CurrentScriptTime); } /// diff --git a/src/AcDream.App/Update/UpdateFrameOrchestrator.cs b/src/AcDream.App/Update/UpdateFrameOrchestrator.cs new file mode 100644 index 00000000..57272024 --- /dev/null +++ b/src/AcDream.App/Update/UpdateFrameOrchestrator.cs @@ -0,0 +1,178 @@ +namespace AcDream.App.Update; + +/// +/// The host-supplied input for one update callback. +/// +internal readonly record struct UpdateFrameInput(double HostDeltaSeconds); + +/// +/// The normalized simulation time shared by every time-advancing phase in one +/// host update. +/// +internal readonly record struct UpdateFrameTiming( + double SimulationDeltaSeconds, + float SimulationDeltaSecondsSingle, + double ScriptTime); + +/// +/// Owns the normalized host delta and the monotonic PhysicsScript clock. +/// +/// +/// Retail ScriptManager::AddScriptInternal @ 0x0051B310 stamps +/// PhysicsScript work from Timer::cur_time at the call site. acdream +/// uses one update-frame timestamp for packet/default scripts, animation +/// hooks, and the later script drain. Absolute liveness time and raw-mouse +/// idle time intentionally do not enter this clock. +/// +internal sealed class UpdateFrameClock : IPhysicsScriptTimeSource +{ + public double CurrentScriptTime { get; private set; } + + public UpdateFrameTiming Advance(UpdateFrameInput input) + { + double deltaSeconds = NormalizeDeltaSeconds(input.HostDeltaSeconds); + CurrentScriptTime += deltaSeconds; + return new UpdateFrameTiming( + deltaSeconds, + (float)deltaSeconds, + CurrentScriptTime); + } + + public static double NormalizeDeltaSeconds(double deltaSeconds) => + double.IsFinite(deltaSeconds) + && deltaSeconds > 0.0 + && deltaSeconds <= float.MaxValue + ? deltaSeconds + : 0.0; +} + +internal interface IPhysicsScriptTimeSource +{ + double CurrentScriptTime { get; } +} + +internal interface IUpdateFrameTeardownPhase +{ + void RetryPendingTeardowns(); +} + +internal interface IUpdateFrameFailureSink +{ + void ReportTeardownFailure(AggregateException error); +} + +internal interface IUpdateFrameScriptClockPublisher +{ + void PublishTime(double scriptTime); +} + +internal interface IStreamingFramePhase +{ + void Tick(); +} + +internal interface IGameplayInputFramePhase +{ + void Tick(UpdateFrameTiming timing); +} + +internal interface IRetailLiveFramePhase +{ + void Tick(float deltaSeconds); +} + +internal interface ILiveEntityLivenessFramePhase +{ + void Tick(); +} + +internal interface ILocalPlayerTeleportFramePhase +{ + void Tick(float deltaSeconds); +} + +internal interface IPlayerModeAutoEntryFramePhase +{ + void TryEnter(); +} + +internal interface ICameraFramePhase +{ + void Tick(UpdateFrameTiming timing); +} + +/// +/// Owns the accepted acdream host-update phase graph. +/// +/// +/// The nested object/network/command/reconcile order remains owned by +/// . This host order is the accepted TS-53 +/// adaptation and is not claimed to be the exact retail Client::UseTime order. +/// +internal sealed class UpdateFrameOrchestrator +{ + private readonly IUpdateFrameTeardownPhase _teardown; + private readonly IUpdateFrameFailureSink _failureSink; + private readonly UpdateFrameClock _clock; + private readonly IUpdateFrameScriptClockPublisher _scriptClockPublisher; + private readonly IStreamingFramePhase _streaming; + private readonly IGameplayInputFramePhase _input; + private readonly IRetailLiveFramePhase _liveFrame; + private readonly ILiveEntityLivenessFramePhase _liveness; + private readonly ILocalPlayerTeleportFramePhase _teleport; + private readonly IPlayerModeAutoEntryFramePhase _playerModeAutoEntry; + private readonly ICameraFramePhase _camera; + + public UpdateFrameOrchestrator( + IUpdateFrameTeardownPhase teardown, + IUpdateFrameFailureSink failureSink, + UpdateFrameClock clock, + IUpdateFrameScriptClockPublisher scriptClockPublisher, + IStreamingFramePhase streaming, + IGameplayInputFramePhase input, + IRetailLiveFramePhase liveFrame, + ILiveEntityLivenessFramePhase liveness, + ILocalPlayerTeleportFramePhase teleport, + IPlayerModeAutoEntryFramePhase playerModeAutoEntry, + ICameraFramePhase camera) + { + _teardown = teardown ?? throw new ArgumentNullException(nameof(teardown)); + _failureSink = failureSink ?? throw new ArgumentNullException(nameof(failureSink)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _scriptClockPublisher = scriptClockPublisher + ?? throw new ArgumentNullException(nameof(scriptClockPublisher)); + _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming)); + _input = input ?? throw new ArgumentNullException(nameof(input)); + _liveFrame = liveFrame ?? throw new ArgumentNullException(nameof(liveFrame)); + _liveness = liveness ?? throw new ArgumentNullException(nameof(liveness)); + _teleport = teleport ?? throw new ArgumentNullException(nameof(teleport)); + _playerModeAutoEntry = playerModeAutoEntry + ?? throw new ArgumentNullException(nameof(playerModeAutoEntry)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + } + + public void Tick(UpdateFrameInput input) + { + try + { + _teardown.RetryPendingTeardowns(); + } + catch (AggregateException error) + { + // The canonical teardown tombstone retains unfinished work for the + // next frame. Other exception types are programming failures and + // deliberately propagate. + _failureSink.ReportTeardownFailure(error); + } + + UpdateFrameTiming timing = _clock.Advance(input); + _scriptClockPublisher.PublishTime(timing.ScriptTime); + _streaming.Tick(); + _input.Tick(timing); + _liveFrame.Tick(timing.SimulationDeltaSecondsSingle); + _liveness.Tick(); + _teleport.Tick(timing.SimulationDeltaSecondsSingle); + _playerModeAutoEntry.TryEnter(); + _camera.Tick(timing); + } +} diff --git a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs index b625e18b..7ac15993 100644 --- a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs +++ b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs @@ -1,3 +1,5 @@ +using AcDream.App.Update; + namespace AcDream.App.World; /// @@ -45,9 +47,5 @@ public sealed class RetailLiveFrameCoordinator } public static double NormalizeDeltaSeconds(double deltaSeconds) => - double.IsFinite(deltaSeconds) - && deltaSeconds > 0.0 - && deltaSeconds <= float.MaxValue - ? deltaSeconds - : 0.0; + UpdateFrameClock.NormalizeDeltaSeconds(deltaSeconds); } diff --git a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs index 7942d1ac..d1810824 100644 --- a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs +++ b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.Rendering; +using AcDream.App.Update; using AcDream.App.World; using AcDream.Content.Vfx; using AcDream.Core.Physics; @@ -51,10 +52,10 @@ public sealed class RecallTeleportAnimationTests Assert.Equal([0f, 0f, 0f], deltas); Assert.Equal(3, networkDispatches); - Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.NaN)); - Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.NegativeInfinity)); - Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.PositiveInfinity)); - Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.MaxValue)); + Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.NaN)); + Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.NegativeInfinity)); + Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.PositiveInfinity)); + Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.MaxValue)); } [Fact] diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs new file mode 100644 index 00000000..e22cf03c --- /dev/null +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -0,0 +1,435 @@ +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.App.Update; +using AcDream.App.World; + +namespace AcDream.App.Tests.World; + +public sealed class UpdateFrameOrchestratorTests +{ + [Fact] + public void Tick_PreservesTheCompleteAcceptedPhaseGraph() + { + var calls = new List(); + UpdateFrameOrchestrator frame = Create(calls); + + frame.Tick(new UpdateFrameInput(1.0 / 60.0)); + + Assert.Equal( + [ + "teardown", + "clock", + "streaming", + "input", + "objects", + "network", + "commands", + "ordinary-reconcile", + "liveness", + "teleport", + "auto-entry", + "camera", + ], + calls); + } + + [Fact] + public void ConditionalReconciles_RemainAtTheirOwningEdges() + { + // Checkpoint A freezes only the outer ownership contract. Checkpoints + // E and F must replace these probes with production teleport/camera + // owner tests before the conditional edges can be claimed guarded. + var calls = new List(); + UpdateFrameOrchestrator frame = Create( + calls, + teleportPlace: true, + inboundCreatedPlayer: true); + + frame.Tick(new UpdateFrameInput(1.0 / 60.0)); + + Assert.Equal( + [ + "teardown", + "clock", + "streaming", + "input", + "objects", + "network", + "commands", + "ordinary-reconcile", + "liveness", + "teleport-place", + "teleport-reconcile", + "teleport-reveal", + "auto-entry", + "inbound-player-projection", + "inbound-player-reconcile", + "camera", + ], + calls); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.NegativeInfinity)] + [InlineData(double.PositiveInfinity)] + [InlineData(-1.0)] + [InlineData(0.0)] + [InlineData(double.MaxValue)] + public void InvalidSimulationDelta_BecomesZeroWithoutSuppressingOuterPhases( + double hostDelta) + { + var calls = new List(); + var observed = new FrameObservations(); + UpdateFrameOrchestrator frame = Create(calls, observed: observed); + + frame.Tick(new UpdateFrameInput(hostDelta)); + + Assert.Equal( + [ + "teardown", "clock", "streaming", "input", "objects", + "network", "commands", "ordinary-reconcile", "liveness", + "teleport", "auto-entry", "camera", + ], + calls); + Assert.Equal([0.0], observed.PublishedTimes); + Assert.Equal([0.0], observed.Input.Select(value => value.SimulationDeltaSeconds)); + Assert.Equal([0f], observed.LiveDeltas); + Assert.Equal([0f], observed.TeleportDeltas); + Assert.Equal([0.0], observed.Camera.Select(value => value.SimulationDeltaSeconds)); + Assert.All( + observed.Input.Concat(observed.Camera), + timing => Assert.Equal(0.0, timing.ScriptTime)); + } + + [Fact] + public void Clock_AdvancesOnceAndPublishesTheSameTimeToEveryConsumer() + { + var calls = new List(); + var observed = new FrameObservations(); + UpdateFrameOrchestrator frame = Create(calls, observed: observed); + + frame.Tick(new UpdateFrameInput(0.25)); + frame.Tick(new UpdateFrameInput(double.NaN)); + frame.Tick(new UpdateFrameInput(0.5)); + + Assert.Equal([0.25, 0.25, 0.75], observed.PublishedTimes); + Assert.Equal( + [0.25, 0.25, 0.75], + observed.Input.Select(value => value.ScriptTime)); + Assert.Equal( + [0.25, 0.25, 0.75], + observed.Camera.Select(value => value.ScriptTime)); + Assert.Equal([0.25f, 0f, 0.5f], observed.LiveDeltas); + Assert.Equal([0.25f, 0f, 0.5f], observed.TeleportDeltas); + Assert.Equal( + [0.25, 0.0, 0.5], + observed.Input.Select(value => value.SimulationDeltaSeconds)); + Assert.Equal( + [0.25, 0.0, 0.5], + observed.Camera.Select(value => value.SimulationDeltaSeconds)); + } + + [Fact] + public void Clock_RejectsFiniteDeltaAboveTheSinglePrecisionConsumerRange() + { + double tooLarge = (double)float.MaxValue * 2.0; + + Assert.True(double.IsFinite(tooLarge)); + Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(tooLarge)); + } + + [Fact] + public void AggregateTeardownFailure_IsReportedAndRetriedNextFrame() + { + var calls = new List(); + var teardown = new RecordingTeardown(calls) + { + Failure = new AggregateException(new InvalidOperationException("transient")), + }; + var failures = new RecordingFailureSink(); + UpdateFrameOrchestrator frame = Create( + calls, + teardown: teardown, + failureSink: failures); + + frame.Tick(new UpdateFrameInput(0.1)); + teardown.Failure = null; + frame.Tick(new UpdateFrameInput(0.1)); + + Assert.Equal(2, teardown.Attempts); + Assert.Single(failures.Errors); + string[] oneFrame = + [ + "teardown", "clock", "streaming", "input", "objects", "network", + "commands", "ordinary-reconcile", "liveness", "teleport", + "auto-entry", "camera", + ]; + Assert.Equal(oneFrame.Concat(oneFrame), calls); + } + + [Fact] + public void NonAggregateTeardownFailure_PropagatesBeforeTimeOrLaterPhases() + { + var calls = new List(); + var teardown = new RecordingTeardown(calls) + { + Failure = new InvalidOperationException("fatal"), + }; + UpdateFrameOrchestrator frame = Create(calls, teardown: teardown); + + InvalidOperationException error = Assert.Throws( + () => frame.Tick(new UpdateFrameInput(0.1))); + + Assert.Equal("fatal", error.Message); + Assert.Equal(["teardown"], calls); + } + + [Fact] + public void OrchestrationTypes_UseOnlyTheExplicitTypedOwnerGraph() + { + Type[] expectedFieldTypes = + [ + typeof(IUpdateFrameTeardownPhase), + typeof(IUpdateFrameFailureSink), + typeof(UpdateFrameClock), + typeof(IUpdateFrameScriptClockPublisher), + typeof(IStreamingFramePhase), + typeof(IGameplayInputFramePhase), + typeof(IRetailLiveFramePhase), + typeof(ILiveEntityLivenessFramePhase), + typeof(ILocalPlayerTeleportFramePhase), + typeof(IPlayerModeAutoEntryFramePhase), + typeof(ICameraFramePhase), + ]; + + FieldInfo[] fields = typeof(UpdateFrameOrchestrator).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.Equal( + expectedFieldTypes.OrderBy(type => type.FullName), + fields.Select(field => field.FieldType).OrderBy(type => type.FullName)); + Assert.All(fields, field => + { + Assert.NotEqual(typeof(GameWindow), field.FieldType); + Assert.False(typeof(Delegate).IsAssignableFrom(field.FieldType)); + }); + + Type[] phaseInterfaces = expectedFieldTypes.Where(type => type.IsInterface).ToArray(); + Assert.DoesNotContain( + phaseInterfaces, + phaseInterface => phaseInterface.IsAssignableFrom(typeof(GameWindow))); + + Type[] productionPhaseOwners = typeof(GameWindow).Assembly.GetTypes() + .Where(type => !type.IsAbstract && !type.IsInterface) + .Where(type => phaseInterfaces.Any(contract => contract.IsAssignableFrom(type))) + .ToArray(); + foreach (Type owner in productionPhaseOwners) + { + FieldInfo[] ownerFields = owner.GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(ownerFields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain( + ownerFields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + Assert.DoesNotContain( + typeof(GameWindow).GetFields(BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_physicsScriptGameTime"); + } + + [Fact] + public void PhysicsScriptClockConsumers_RetainTheTypedSourceNotGameWindowClosures() + { + Type[] consumers = + [ + typeof(DatLiveEntityProjectionMaterializer), + typeof(AcDream.App.Physics.LiveEntityNetworkUpdateController), + ]; + + foreach (Type consumer in consumers) + { + FieldInfo clock = Assert.Single( + consumer.GetFields(BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_gameTime"); + Assert.Equal(typeof(IPhysicsScriptTimeSource), clock.FieldType); + Assert.DoesNotContain( + consumer.GetFields(BindingFlags.Instance | BindingFlags.NonPublic), + field => field.FieldType == typeof(GameWindow)); + } + + Assert.False(typeof(IPhysicsScriptTimeSource).IsAssignableFrom(typeof(GameWindow))); + } + + [Fact] + public void ProductionFrame_PublishesPhysicsScriptTimeExactlyOnce() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.Equal(1, source.Split("PublishTime(", StringSplitOptions.None).Length - 1); + } + + private static UpdateFrameOrchestrator Create( + List calls, + RecordingTeardown? teardown = null, + RecordingFailureSink? failureSink = null, + FrameObservations? observed = null, + bool teleportPlace = false, + bool inboundCreatedPlayer = false) + { + teardown ??= new RecordingTeardown(calls); + failureSink ??= new RecordingFailureSink(); + return new UpdateFrameOrchestrator( + teardown, + failureSink, + new UpdateFrameClock(), + new RecordingClockPublisher(calls, observed), + new RecordingStreaming(calls), + new RecordingInput(calls, observed), + new RecordingLiveFrame(calls, observed), + new RecordingLiveness(calls), + new RecordingTeleport(calls, observed, teleportPlace), + new RecordingAutoEntry(calls), + new RecordingCamera(calls, observed, inboundCreatedPlayer)); + } + + private sealed class RecordingTeardown(List calls) + : IUpdateFrameTeardownPhase + { + public Exception? Failure { get; set; } + public int Attempts { get; private set; } + + public void RetryPendingTeardowns() + { + calls.Add("teardown"); + Attempts++; + if (Failure is not null) + throw Failure; + } + } + + private sealed class RecordingFailureSink : IUpdateFrameFailureSink + { + public List Errors { get; } = []; + public void ReportTeardownFailure(AggregateException error) => Errors.Add(error); + } + + private sealed class RecordingClockPublisher( + List calls, + FrameObservations? observed) : IUpdateFrameScriptClockPublisher + { + public void PublishTime(double scriptTime) + { + calls.Add("clock"); + observed?.PublishedTimes.Add(scriptTime); + } + } + + private sealed class RecordingStreaming(List calls) : IStreamingFramePhase + { + public void Tick() => calls.Add("streaming"); + } + + private sealed class RecordingInput( + List calls, + FrameObservations? observed) : IGameplayInputFramePhase + { + public void Tick(UpdateFrameTiming timing) + { + calls.Add("input"); + observed?.Input.Add(timing); + } + } + + private sealed class RecordingLiveFrame( + List calls, + FrameObservations? observed) : IRetailLiveFramePhase + { + public void Tick(float deltaSeconds) + { + observed?.LiveDeltas.Add(deltaSeconds); + calls.Add("objects"); + calls.Add("network"); + calls.Add("commands"); + calls.Add("ordinary-reconcile"); + } + } + + private sealed class RecordingLiveness(List calls) + : ILiveEntityLivenessFramePhase + { + public void Tick() => calls.Add("liveness"); + } + + private sealed class RecordingTeleport( + List calls, + FrameObservations? observed, + bool place) + : ILocalPlayerTeleportFramePhase + { + public void Tick(float deltaSeconds) + { + observed?.TeleportDeltas.Add(deltaSeconds); + if (!place) + { + calls.Add("teleport"); + return; + } + + calls.Add("teleport-place"); + calls.Add("teleport-reconcile"); + calls.Add("teleport-reveal"); + } + } + + private sealed class RecordingAutoEntry(List calls) + : IPlayerModeAutoEntryFramePhase + { + public void TryEnter() => calls.Add("auto-entry"); + } + + private sealed class RecordingCamera( + List calls, + FrameObservations? observed, + bool inboundCreatedPlayer) : ICameraFramePhase + { + public void Tick(UpdateFrameTiming timing) + { + if (inboundCreatedPlayer) + { + calls.Add("inbound-player-projection"); + calls.Add("inbound-player-reconcile"); + } + + calls.Add("camera"); + observed?.Camera.Add(timing); + } + } + + private sealed class FrameObservations + { + public List PublishedTimes { get; } = []; + public List Input { get; } = []; + public List LiveDeltas { get; } = []; + public List TeleportDeltas { get; } = []; + public List Camera { get; } = []; + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} From 4e4aac2c5a2a6e45f854353746e84f9ac4183a31 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 00:42:26 +0200 Subject: [PATCH 74/98] refactor(runtime): extract the live object frame --- ...ndow-slice-6-update-frame-orchestration.md | 17 +- .../Input/LocalPlayerRuntimeState.cs | 30 ++ .../Input/RetailLocalPlayerFrameController.cs | 4 +- src/AcDream.App/Net/LiveSessionController.cs | 3 +- .../Physics/ProjectileController.cs | 58 +++- src/AcDream.App/Rendering/GameWindow.cs | 292 ++++++------------ .../Rendering/IAnimationHookCaptureSink.cs | 36 +++ .../LiveAnimationPresentationContext.cs | 46 +++ .../Rendering/LiveEntityAnimationPresenter.cs | 24 +- .../Rendering/LiveEntityAnimationScheduler.cs | 57 ++-- .../Rendering/LiveStaticAnimationResidency.cs | 34 ++ .../Rendering/StaticLiveRootCommitter.cs | 31 +- .../Update/LiveObjectFrameController.cs | 246 +++++++++++++++ .../World/LiveEntityRuntimeSlot.cs | 24 ++ .../World/LiveEntityRuntimeViews.cs | 28 +- .../World/RetailLiveFrameCoordinator.cs | 46 +-- .../FramePhaseTestDoubles.cs | 27 ++ .../RetailLocalPlayerFrameControllerTests.cs | 26 +- .../Physics/ProjectileControllerTests.cs | 29 +- .../LiveEntityAnimationPresenterTests.cs | 20 +- .../LiveEntityAnimationSchedulerTests.cs | 41 ++- .../GameWindowLiveEntityCompositionTests.cs | 94 ++++++ ...tityProjectionWithdrawalControllerTests.cs | 5 +- .../World/LiveEntityRuntimeTests.cs | 92 ++++-- .../World/LiveObjectFrameControllerTests.cs | 84 +++++ .../World/RecallTeleportAnimationTests.cs | 104 ++++++- .../World/UpdateFrameOrchestratorTests.cs | 90 ++++++ 27 files changed, 1217 insertions(+), 371 deletions(-) create mode 100644 src/AcDream.App/Input/LocalPlayerRuntimeState.cs create mode 100644 src/AcDream.App/Rendering/IAnimationHookCaptureSink.cs create mode 100644 src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs create mode 100644 src/AcDream.App/Rendering/LiveStaticAnimationResidency.cs create mode 100644 src/AcDream.App/Update/LiveObjectFrameController.cs create mode 100644 src/AcDream.App/World/LiveEntityRuntimeSlot.cs create mode 100644 tests/AcDream.App.Tests/FramePhaseTestDoubles.cs create mode 100644 tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md index 335153ac..00790c44 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md @@ -15,7 +15,7 @@ never hidden behind a retry, delay, suppression flag, or reordered callback. - [x] A — freeze the complete production phase graph and introduce the typed orchestration contract plus structural/order guards. -- [ ] B — extract the pre-network live-object presentation phase and the +- [x] B — extract the pre-network live-object presentation phase and the non-advancing post-network spatial reconciler; remove callbacks into `GameWindow` from `RetailLiveFrameCoordinator`. - [ ] C — extract streaming-origin convergence, observer selection, dungeon @@ -42,6 +42,14 @@ oracle, exact teardown and invalid-delta tests, and guards against a transitive 18 focused Release tests and the full 2,709-test App project pass, and all three corrected-diff reviews are clean. +Checkpoint B moved the complete local/ordinary/static/effect body into +`LiveObjectFrameController`, made `RetailLiveFrameCoordinator`'s +object → inbound batch → command → spatial-reconcile barrier fully typed, +and removed animation/runtime/projectile back-references to `GameWindow`. +`GameWindow.cs` is now 8,716 raw lines. The corrected diff passes 239 focused +Release tests, the 2,732-test App suite, and the full 7,090-test Release suite +(5 fixture/environment skips); all three independent reviews are clean. + ## 1. Outcome and non-goals At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one @@ -352,6 +360,13 @@ multiple ordinary/static owners, invalid delta, and reentrant inbound work; the one published PES clock remains visible from hook capture through the script tick. +Checkpoint B removes the coordinator, animation presenter/scheduler, runtime +view, static-root, and projectile callbacks into `GameWindow`. The callback +composition still retained by `RetailLocalPlayerFrameController` is explicitly +deferred: checkpoint D owns its input/capture sources and checkpoint F owns its +mutable player-mode/controller/session sources. A structural test permits that +one named legacy owner only; D/F must remove the exception before final cutover. + ### C — streaming frame - extract readiness/recenter advancement, observer selection, dungeon gate, diff --git a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs new file mode 100644 index 00000000..02a1b2ad --- /dev/null +++ b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs @@ -0,0 +1,30 @@ +using AcDream.Core.Physics; + +namespace AcDream.App.Input; + +internal interface ILocalPlayerIdentitySource +{ + uint ServerGuid { get; } +} + +/// +/// Session-scoped identity slot shared by focused update owners. +/// +internal sealed class LocalPlayerIdentityState : ILocalPlayerIdentitySource +{ + public uint ServerGuid { get; set; } +} + +internal interface ILocalPlayerControllerSource +{ + PlayerMovementController? Controller { get; } +} + +/// +/// The one mutable local movement-controller slot. Player-mode lifecycle owns +/// assignment; update and presentation owners receive the read-only seam. +/// +internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource +{ + public PlayerMovementController? Controller { get; set; } +} diff --git a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs index 67be00bc..51bb8fd7 100644 --- a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs +++ b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs @@ -1,3 +1,5 @@ +using AcDream.App.Update; + namespace AcDream.App.Input; /// @@ -10,7 +12,7 @@ namespace AcDream.App.Input; /// state. The cached input result is presentation-only after the barrier: its /// one-shot commands are never replayed against a later authoritative cell. /// -public sealed class RetailLocalPlayerFrameController +internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFramePhase { private readonly Func _canPresentPlayer; private readonly Func _getController; diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs index eecab525..02664eaa 100644 --- a/src/AcDream.App/Net/LiveSessionController.cs +++ b/src/AcDream.App/Net/LiveSessionController.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Sockets; using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.App.Update; using AcDream.UI.Abstractions; namespace AcDream.App.Net; @@ -157,7 +158,7 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations /// publication, exact-generation Tick, graceful replacement, and convergent /// teardown. Retail wire behavior remains inside WorldSession. /// -public sealed class LiveSessionController : IDisposable +internal sealed class LiveSessionController : IDisposable, ILiveSessionFramePhase { private sealed class SessionScope( WorldSession session, diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs index 7e4ad350..6758d8c9 100644 --- a/src/AcDream.App/Physics/ProjectileController.cs +++ b/src/AcDream.App/Physics/ProjectileController.cs @@ -1,5 +1,7 @@ using System.Numerics; +using AcDream.App.Rendering; using AcDream.App.World; +using AcDream.Content; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -9,6 +11,29 @@ using RemoteMotion = AcDream.App.Physics.RemoteMotion; namespace AcDream.App.Physics; +internal interface IProjectileSetupResolver +{ + Setup? Resolve(uint setupId); +} + +internal sealed class DatProjectileSetupResolver : IProjectileSetupResolver +{ + private readonly IDatReaderWriter _dats; + private readonly object _datLock; + + public DatProjectileSetupResolver(IDatReaderWriter dats, object datLock) + { + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); + } + + public Setup? Resolve(uint setupId) + { + lock (_datLock) + return _dats.Get(setupId); + } +} + /// /// Update-thread presentation owner for live retail missiles. Logical identity, /// generation, accepted network state, and teardown remain owned by @@ -56,26 +81,26 @@ internal sealed class ProjectileController private readonly LiveEntityRuntime _liveEntities; private readonly ProjectilePhysicsStepper _stepper; private readonly ShadowObjectRegistry _shadows; - private readonly Func _setupResolver; - private readonly Action _publishRootPose; - private readonly Func<(int X, int Y)> _liveCenterProvider; + private readonly IProjectileSetupResolver? _setupResolver; + private readonly IEntityRootPosePublisher? _rootPoses; + private readonly LiveWorldOriginState? _origin; private readonly List _spatialProjectileSnapshot = new(); private double _lastFiniteGameTime; internal ProjectileController( LiveEntityRuntime liveEntities, PhysicsEngine physicsEngine, - Func? setupResolver = null, - Action? publishRootPose = null, - Func<(int X, int Y)>? liveCenterProvider = null) + IProjectileSetupResolver? setupResolver = null, + IEntityRootPosePublisher? rootPoses = null, + LiveWorldOriginState? origin = null) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); ArgumentNullException.ThrowIfNull(physicsEngine); _stepper = new ProjectilePhysicsStepper(physicsEngine); _shadows = physicsEngine.ShadowObjects; - _setupResolver = setupResolver ?? (_ => null); - _publishRootPose = publishRootPose ?? (_ => { }); - _liveCenterProvider = liveCenterProvider ?? (() => (0, 0)); + _setupResolver = setupResolver; + _rootPoses = rootPoses; + _origin = origin; _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; } @@ -322,7 +347,7 @@ internal sealed class ProjectileController { SuspendOutsideWorld(runtime, entity.Id); } - _publishRootPose(entity); + _rootPoses?.UpdateRoot(entity); return true; } @@ -472,7 +497,7 @@ internal sealed class ProjectileController // A newer State packet can make an already materialized object a // missile. TryBind adopts an existing MovementManager body so the // live record still represents retail's one CPhysicsObj. - Setup? setup = _setupResolver(entity.SourceGfxObjOrSetupId); + Setup? setup = _setupResolver?.Resolve(entity.SourceGfxObjOrSetupId); if (setup is null) { DiagnosticSink?.Invoke( @@ -617,7 +642,7 @@ internal sealed class ProjectileController { SuspendOutsideWorld(runtime, entity.Id); } - _publishRootPose(entity); + _rootPoses?.UpdateRoot(entity); return true; } @@ -712,7 +737,7 @@ internal sealed class ProjectileController record.FullCellId, liveCenterX, liveCenterY); - _publishRootPose(entity); + _rootPoses?.UpdateRoot(entity); } if (IsHidden(record)) @@ -909,7 +934,7 @@ internal sealed class ProjectileController current.FullCellId, liveCenterX, liveCenterY); - _publishRootPose(entity); + _rootPoses?.UpdateRoot(entity); if (!IsCurrentQuantumIdentity(step)) return false; } @@ -966,7 +991,8 @@ internal sealed class ProjectileController // not lazily on the next simulated quantum: a zero-velocity or // newly ordinary retained projectile may have no later projectile // step from which to repair its shadow membership. - (int liveCenterX, int liveCenterY) = _liveCenterProvider(); + int liveCenterX = _origin?.CenterX ?? 0; + int liveCenterY = _origin?.CenterY ?? 0; ShadowPositionSynchronizer.Sync( _shadows, entity.Id, @@ -975,7 +1001,7 @@ internal sealed class ProjectileController record.FullCellId, liveCenterX, liveCenterY); - _publishRootPose(entity); + _rootPoses?.UpdateRoot(entity); return; } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c06fa66e..2f7b502f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -10,7 +10,7 @@ using Silk.NET.Windowing; namespace AcDream.App.Rendering; -public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext +public sealed class GameWindow : IDisposable { private static double ClientTimerNow() => System.Diagnostics.Stopwatch.GetTimestamp() @@ -152,12 +152,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _remoteInboundMotion; private readonly AcDream.App.World.RetailInboundEventDispatcher _inboundEntityEvents = new(); - private readonly LiveEntityAnimationScheduler _liveAnimationScheduler; - private readonly LiveEntityAnimationPresenter _animationPresenter; + private LiveEntityAnimationScheduler _liveAnimationScheduler = null!; + private LiveEntityAnimationPresenter _animationPresenter = null!; private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; - private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator; + private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!; + private AcDream.App.Update.LiveSpatialPresentationReconciler + _liveSpatialReconciler = null!; private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new(); private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; // Step 7 projectile presentation. The controller owns no identity map; @@ -247,6 +249,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// appear in this map. /// private readonly LiveEntityAnimationRuntimeView _animatedEntities; + private readonly AcDream.App.World.LiveEntityRuntimeSlot _liveEntityRuntimeSlot = new(); // MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys. // WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically @@ -520,11 +523,21 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private int _weatherDebugStep = 0; // Phase B.2: player movement mode. - private AcDream.App.Input.PlayerMovementController? _playerController; + private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new(); + private AcDream.App.Input.PlayerMovementController? _playerController + { + get => _playerControllerSlot.Controller; + set => _playerControllerSlot.Controller = value; + } private AcDream.App.Rendering.ChaseCamera? _chaseCamera; private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera; private bool _playerMode; - private uint _playerServerGuid; + private readonly AcDream.App.Input.LocalPlayerIdentityState _localPlayerIdentity = new(); + private uint _playerServerGuid + { + get => _localPlayerIdentity.ServerGuid; + set => _localPlayerIdentity.ServerGuid = value; + } // Retail Default_CharacterOption (acclient.h:3434). PlayerDescription // replaces this before any in-world drag can normally occur. private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 @@ -745,7 +758,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _uiRegistry = uiRegistry; - _animatedEntities = new LiveEntityAnimationRuntimeView(() => _liveEntities); + _animatedEntities = new LiveEntityAnimationRuntimeView( + _liveEntityRuntimeSlot); SpellBook = new AcDream.Core.Spells.Spellbook(); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); // #184 Slice 2a: the extracted per-remote DR tick. Its stateful @@ -761,25 +775,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext movement, cellId, update), (host, targetGuid) => _liveEntityMotionBindings.StickToObjectFromWire(host, targetGuid)); - var ordinaryPhysicsUpdater = - new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater( - _physicsEngine, - _liveEntityMotionBindings.GetSetupCylinder); - _liveAnimationScheduler = new LiveEntityAnimationScheduler( - () => _liveEntities, - () => _playerServerGuid, - _remotePhysicsUpdater, - ordinaryPhysicsUpdater, - () => _projectileController, - entity => _effectPoses.UpdateRoot(entity), - CaptureAnimationHooks); - _animationPresenter = new LiveEntityAnimationPresenter( - () => _liveEntities, - () => _staticAnimationScheduler, - _effectPoses, - this, - _animationDiagnostics, - options.HidePartIndex); _localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController( resolveEntity: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) @@ -824,16 +819,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext objectClockDisposition: () => _liveEntities?.GetRootObjectClockDisposition(_playerServerGuid) ?? AcDream.Core.Physics.RetailObjectClockDisposition.Advance); - _liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( - AdvanceLiveObjectRuntime, - () => - { - using AcDream.App.Streaming.GpuWorldState.MutationBatch spatialBatch = - _worldState.BeginMutationBatch(); - _liveSessionController?.Tick(); - }, - _localPlayerFrame.RunPostNetworkCommandPhase, - ReconcileLiveObjectSpatialPresentation); } public void Run() @@ -2080,34 +2065,20 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale. AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null; var staticLiveRootCommitter = new StaticLiveRootCommitter( - () => _liveEntities, + _liveEntityRuntimeSlot, _physicsEngine.ShadowObjects, - () => (_liveCenterX, _liveCenterY), - entity => _effectPoses.UpdateRoot(entity)); + _liveWorldOrigin, + _effectPoses); + var staticAnimationResidency = new LiveStaticAnimationResidency( + _liveEntityRuntimeSlot); _staticAnimationScheduler = new RetailStaticAnimatingObjectScheduler( capturedAnimLoader!, - CaptureAnimationHooks, - (entity, poses, availability) => - _effectPoses.Publish(entity, poses, availability), - entity => entity.ServerGuid == 0 - || (_liveEntities?.TryGetRecord( - entity.ServerGuid, - out LiveEntityRecord resident) == true - && resident.ProjectionKind is LiveEntityProjectionKind.World - && ReferenceEquals(resident.WorldEntity, entity) - && resident.IsSpatiallyProjected - && resident.IsSpatiallyVisible - && resident.FullCellId != 0), + _animationHookFrames!.Capture, + _effectPoses.Publish, + staticAnimationResidency.IsResident, (entity, body) => _ = staticLiveRootCommitter.Commit(entity, body), - entity => entity.ServerGuid == 0 - ? 0UL - : _liveEntities?.TryGetRecord( - entity.ServerGuid, - out LiveEntityRecord resident) == true - && ReferenceEquals(resident.WorldEntity, entity) - ? resident.ProjectionMutationVersion - : ulong.MaxValue); + staticAnimationResidency.ProjectionVersion); AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e) { try @@ -2183,6 +2154,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext entityScriptActivator.OnCreate, entityScriptActivator.OnRemove)), liveEntityComponentLifecycle); + _liveEntityRuntimeSlot.Bind(_liveEntities); liveEntityMotionRuntime = new AcDream.App.Physics.LiveEntityMotionRuntimeController( _liveEntities, @@ -2204,17 +2176,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _projectileController = new AcDream.App.Physics.ProjectileController( _liveEntities, _physicsEngine, - setupId => - { - // State can classify an already-live object as Missile - // outside the spawn handler's lock. DatCollection shares - // one mutable reader cursor with the streaming worker, so - // this resolver must serialize both initial and late bind. - lock (_datLock) - return _dats?.Get(setupId); - }, - entity => _effectPoses.UpdateRoot(entity), - () => (_liveCenterX, _liveCenterY)) + new AcDream.App.Physics.DatProjectileSetupResolver(_dats!, _datLock), + new EntityRootPosePublisher(_effectPoses), + _liveWorldOrigin) { DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"), }; @@ -2233,6 +2197,29 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _lightingSink!, setupId => _dats!.Get(setupId)); + var ordinaryPhysicsUpdater = + new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater( + _physicsEngine, + _liveEntityMotionBindings.GetSetupCylinder); + _liveAnimationScheduler = new LiveEntityAnimationScheduler( + _liveEntities, + _localPlayerIdentity, + _remotePhysicsUpdater, + ordinaryPhysicsUpdater, + _projectileController, + new EntityRootPosePublisher(_effectPoses), + new AnimationHookCaptureSink(_animationHookFrames!)); + _animationPresenter = new LiveEntityAnimationPresenter( + _liveEntities, + _staticAnimationScheduler, + _effectPoses, + new LiveAnimationPresentationContext( + _liveEntities, + _localPlayerIdentity, + _playerControllerSlot), + _animationDiagnostics, + _options.HidePartIndex); + parentAcceptance = new AcDream.App.World.DeferredLiveEntityParentAcceptance(); _equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController( _dats!, @@ -2627,7 +2614,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _remoteTeleportController!, _animatedEntities, new LiveEntityRemoteMotionRuntimeView( - () => _liveEntities), + _liveEntityRuntimeSlot), _remoteMovementObservations, _remotePhysicsUpdater, _remoteInboundMotion, @@ -2667,6 +2654,44 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // gated behind ACDREAM_LIVE=1 so the default run path is unchanged. _liveWorldOrigin.SetPlaceholder(centerX, centerY); _liveSessionController = new AcDream.App.Net.LiveSessionController(); + var liveEffectFrame = new AcDream.App.Update.LiveEffectFrameController( + _translucencyFades, + _animationHookFrames!, + _entityEffects!, + _particleSink!, + _liveEntityLights!, + _particleVisibility, + _particleSystem!, + _scriptRunner!, + _updateFrameClock, + new AcDream.App.Update.SettingsParticleRangeSource( + _settingsVm, + _persistedDisplay.ParticleRange)); + var liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController( + _inboundEntityEvents, + _localPlayerFrame, + _selectionInteractions, + _liveEntities, + _localPlayerIdentity, + _liveWorldOrigin, + _liveAnimationScheduler, + _staticAnimationScheduler, + _animationPresenter, + _animatedEntities, + _equippedChildRenderer!, + liveEffectFrame); + _liveSpatialReconciler = + new AcDream.App.Update.LiveSpatialPresentationReconciler( + _entityEffects!, + _equippedChildRenderer!, + _particleSink!, + _liveEntityLights!); + _liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( + liveObjectFrame, + _worldState, + _liveSessionController, + _localPlayerFrame, + _liveSpatialReconciler); AcDream.App.Net.LiveSessionStartResult liveStart = _liveSessionController.Start( _options, @@ -3520,7 +3545,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // camera path re-extends the boom. Updating directly from the stale // source-world viewer made the destination bend across the reveal. _retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw); - ReconcileLiveObjectSpatialPresentation(); + _liveSpatialReconciler.Reconcile(); AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( "PLACED", resolved.CellId, $"forced={forced}"); @@ -4203,7 +4228,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // The player was materialized by this frame's inbound pass. // Its non-advancing initial projection must publish child and // effect anchors before the first draw too. - ReconcileLiveObjectSpatialPresentation(); + _liveSpatialReconciler.Reconcile(); } // Update chase camera(s). The CameraController exposes whichever @@ -5715,102 +5740,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext LastFrameProfile: _frameProfiler.LastReport); } - /// - /// Advances the live object's retail UseTime phase before inbound packet - /// dispatch. Final root/part poses and their animation hooks are published - /// together so rendering consumes one coherent update snapshot. - /// - private void AdvanceLiveObjectRuntime(float dt) => - _inboundEntityEvents.Run( - this, - dt, - static (window, deltaTime) => - window.AdvanceLiveObjectRuntimeCore(deltaTime)); - - private void AdvanceLiveObjectRuntimeCore(float dt) - { - // The local body participates in the same retail CPhysics phase as - // remote objects. Its outbound movement snapshot is completed here, - // before SmartBox dispatch can apply F751/ForcePosition. - _localPlayerFrame.AdvanceBeforeNetwork(dt); - // Retail input ordering: movement edges reach MoveToState before a - // later Use/PickUp action from the same input frame. ACE cancels an - // active server MoveTo chain when it receives MoveToState, so sending - // Use directly from Silk's key callback inverted that order and left - // the client walking to a corpse whose server callback was cancelled. - _selectionInteractions?.DrainOutbound(); - System.Numerics.Vector3? playerPosition = - _entitiesByServerGuid.TryGetValue( - _playerServerGuid, - out var ordinaryObjectViewer) - ? ordinaryObjectViewer.Position - : null; - IReadOnlyDictionary animationSchedules = - _liveAnimationScheduler.Tick( - dt, - playerPosition, - _localPlayerFrame.HiddenPartPoseDirty, - _liveCenterX, - _liveCenterY, - _animationPresenter.PrepareAnimation); - // CPhysics::UseTime (0x00509950) processes the ordinary object table - // first, then its distinct static_animating_objects workset. - _staticAnimationScheduler?.Tick(dt); - if (_animatedEntities.Count > 0) - _animationPresenter.Present(animationSchedules); - _equippedChildRenderer?.Tick(); - - // CPhysicsObj::animate_static_object (0x00513DF0) reaches - // process_hooks only after UpdatePartsInternal and - // UpdateChildrenInternal. Ordinary objects complete AnimationDone in - // their earlier UpdatePositionInternal slot; the static workset has - // this distinct tail. - _staticAnimationScheduler?.ProcessHooks(); - - // Advance already-active FP hooks before routing hooks emitted by - // this PartArray update. A newly created translucency hook begins at - // its authored Start value this frame; it does not consume dt twice. - _translucencyFades.AdvanceAll(dt); - _animationHookFrames?.Drain(); - _entityEffects?.RefreshLiveOwnerPoses(); - _particleSink?.RefreshAttachedEmitters(); - _liveEntityLights?.Refresh(); - - if (_particleSystem is not null) - { - var particleRange = _settingsVm?.DisplayDraft.ParticleRange - ?? _persistedDisplay.ParticleRange; - float rangeMultiplier = particleRange - == AcDream.UI.Abstractions.Panels.Settings.ParticleRange.Extended - ? AcDream.App.Rendering.Vfx.ParticleVisibilityController.ExtendedRangeMultiplier - : 1f; - _particleVisibility.Apply(_particleSystem, rangeMultiplier); - } - - // Retail CPhysicsObj::UpdateObjectInternal (0x005156B0) advances - // ParticleManager before ScriptManager. A particle created by a PES - // hook therefore begins simulation on the following object frame. - _particleSystem?.Tick(dt); - _scriptRunner?.Tick(_updateFrameClock.CurrentScriptTime); - } - - /// - /// Re-composes spatial derivatives after authoritative packet handlers - /// move roots. This pass advances no time, animation, scripts, particles, - /// or fades; it only makes children and live anchors agree with the root - /// that the same frame will draw. - /// - private void ReconcileLiveObjectSpatialPresentation() - { - // Authoritative handlers mutate WorldEntity roots directly. Publish - // those roots before children read their parent's pose; child Tick - // then publishes each newly composed child pose for attached effects. - _entityEffects?.RefreshLiveOwnerPoses(); - _equippedChildRenderer?.Tick(); - _particleSink?.RefreshAttachedEmitters(); - _liveEntityLights?.Refresh(); - } - /// /// Phase 6.4: advance every animated entity's frame counter by /// * Framerate, wrapping around the cycle's @@ -5855,33 +5784,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext && _animatedEntities.TryGetValue(entity.Id, out var ae) && ae.Sequencer is { } sequencer) { - CaptureAnimationHooks(ae.Entity.Id, sequencer); + _animationHookFrames?.Capture(ae.Entity.Id, sequencer); } } - private void CaptureAnimationHooks( - uint ownerLocalId, - AcDream.Core.Physics.AnimationSequencer sequencer) => - _animationHookFrames?.Capture(ownerLocalId, sequencer); - - uint ILiveAnimationPresentationContext.LocalPlayerGuid => _playerServerGuid; - - AcDream.Core.Physics.MotionInterpreter? - ILiveAnimationPresentationContext.ResolveMotionInterpreter( - LiveEntityRecord record) - { - if (_liveEntities?.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) != true - || !ReferenceEquals(current, record)) - { - return null; - } - if (record.ServerGuid == _playerServerGuid) - return _playerController?.Motion; - return record.RemoteMotionRuntime is RemoteMotion remote - ? remote.Motion - : null; - } - // IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass // narrowing that dropped settled-open doors / faded-out walls back onto the // stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the diff --git a/src/AcDream.App/Rendering/IAnimationHookCaptureSink.cs b/src/AcDream.App/Rendering/IAnimationHookCaptureSink.cs new file mode 100644 index 00000000..81134d2c --- /dev/null +++ b/src/AcDream.App/Rendering/IAnimationHookCaptureSink.cs @@ -0,0 +1,36 @@ +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +internal interface IAnimationHookCaptureSink +{ + void Capture(uint ownerLocalId, AnimationSequencer sequencer); +} + +internal sealed class AnimationHookCaptureSink : IAnimationHookCaptureSink +{ + private readonly AnimationHookFrameQueue _queue; + + public AnimationHookCaptureSink(AnimationHookFrameQueue queue) => + _queue = queue ?? throw new ArgumentNullException(nameof(queue)); + + public void Capture(uint ownerLocalId, AnimationSequencer sequencer) => + _queue.Capture(ownerLocalId, sequencer); +} + +internal interface IEntityRootPosePublisher +{ + void UpdateRoot(WorldEntity entity); +} + +internal sealed class EntityRootPosePublisher : IEntityRootPosePublisher +{ + private readonly EntityEffectPoseRegistry _poses; + + public EntityRootPosePublisher(EntityEffectPoseRegistry poses) => + _poses = poses ?? throw new ArgumentNullException(nameof(poses)); + + public void UpdateRoot(WorldEntity entity) => _poses.UpdateRoot(entity); +} diff --git a/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs b/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs new file mode 100644 index 00000000..ab75fc0d --- /dev/null +++ b/src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs @@ -0,0 +1,46 @@ +using AcDream.App.Input; +using AcDream.App.Physics; +using AcDream.App.World; +using AcDream.Core.Physics; + +namespace AcDream.App.Rendering; + +/// +/// Resolves the current motion owner without retaining the application window. +/// +internal sealed class LiveAnimationPresentationContext + : ILiveAnimationPresentationContext +{ + private readonly LiveEntityRuntime _runtime; + private readonly ILocalPlayerIdentitySource _localPlayer; + private readonly ILocalPlayerControllerSource _playerController; + + public LiveAnimationPresentationContext( + LiveEntityRuntime runtime, + ILocalPlayerIdentitySource localPlayer, + ILocalPlayerControllerSource playerController) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer)); + _playerController = playerController + ?? throw new ArgumentNullException(nameof(playerController)); + } + + public uint LocalPlayerGuid => _localPlayer.ServerGuid; + + public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record) + { + ArgumentNullException.ThrowIfNull(record); + if (!_runtime.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) + || !ReferenceEquals(current, record)) + { + return null; + } + + if (record.ServerGuid == _localPlayer.ServerGuid) + return _playerController.Controller?.Motion; + return record.RemoteMotionRuntime is RemoteMotion remote + ? remote.Motion + : null; + } +} diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs index 3bc51b3a..45768801 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs @@ -12,8 +12,8 @@ namespace AcDream.App.Rendering; /// internal sealed class LiveEntityAnimationPresenter { - private readonly Func _liveEntities; - private readonly Func _staticFrames; + private readonly LiveEntityRuntime _liveEntities; + private readonly ILiveStaticPartFrameSource _staticFrames; private readonly EntityEffectPoseRegistry _effectPoses; private readonly ILiveAnimationPresentationContext _context; private readonly AnimationPresentationDiagnostics _diagnostics; @@ -22,8 +22,8 @@ internal sealed class LiveEntityAnimationPresenter private bool _isPresenting; public LiveEntityAnimationPresenter( - Func liveEntities, - Func staticFrames, + LiveEntityRuntime liveEntities, + ILiveStaticPartFrameSource staticFrames, EntityEffectPoseRegistry effectPoses, ILiveAnimationPresentationContext context, AnimationPresentationDiagnostics diagnostics, @@ -43,9 +43,8 @@ internal sealed class LiveEntityAnimationPresenter { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(animation); - LiveEntityRuntime? runtime = _liveEntities(); - if (runtime is null - || !runtime.IsCurrentSpatialAnimation(record, animation) + LiveEntityRuntime runtime = _liveEntities; + if (!runtime.IsCurrentSpatialAnimation(record, animation) || !ReferenceEquals(record.WorldEntity, animation.Entity) || animation.Sequencer is not { MotionDoneTarget: null } sequencer) { @@ -55,9 +54,8 @@ internal sealed class LiveEntityAnimationPresenter WorldEntity capturedEntity = animation.Entity; sequencer.MotionDoneTarget = (motion, success) => { - LiveEntityRuntime? current = _liveEntities(); - if (current is null - || !current.IsCurrentSpatialAnimation(record, animation) + LiveEntityRuntime current = _liveEntities; + if (!current.IsCurrentSpatialAnimation(record, animation) || !ReferenceEquals(record.WorldEntity, capturedEntity) || !ReferenceEquals(animation.Entity, capturedEntity)) { @@ -79,8 +77,8 @@ internal sealed class LiveEntityAnimationPresenter IReadOnlyDictionary schedules) { ArgumentNullException.ThrowIfNull(schedules); - LiveEntityRuntime? runtime = _liveEntities(); - if (runtime is null || _isPresenting) + LiveEntityRuntime runtime = _liveEntities; + if (_isPresenting) return; // EffectPoseChanged is synchronous. Presentation is one consuming @@ -112,7 +110,7 @@ internal sealed class LiveEntityAnimationPresenter ulong projectionVersion = record.ProjectionMutationVersion; ulong presentationRevision = animation.PresentationRevision; - if (_staticFrames()?.TryTakeLivePartFrames( + if (_staticFrames.TryTakeLivePartFrames( record, entity, animation, diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs index b7b275cf..73a7ea1c 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs @@ -1,4 +1,6 @@ using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Rendering.Vfx; using AcDream.App.Physics; using AcDream.App.World; using AcDream.Core.Physics; @@ -17,41 +19,38 @@ namespace AcDream.App.Rendering; /// internal sealed class LiveEntityAnimationScheduler { - private readonly Func _liveEntities; - private readonly Func _localPlayerGuid; + private readonly LiveEntityRuntime _liveEntities; + private readonly ILocalPlayerIdentitySource _localPlayer; private readonly RemotePhysicsUpdater _remotePhysics; private readonly LiveEntityOrdinaryPhysicsUpdater _ordinaryPhysics; - private readonly Func _projectiles; - private readonly Action _publishRootPose; - private readonly Action _captureAnimationHooks; + private readonly ProjectileController _projectiles; + private readonly IEntityRootPosePublisher _rootPoses; + private readonly IAnimationHookCaptureSink _animationHooks; private readonly List _rootSnapshot = new(); private readonly Dictionary _schedules = new(); private readonly Frame _rootFrameScratch = new(); private readonly MotionDeltaFrame _rootDeltaScratch = new(); public LiveEntityAnimationScheduler( - Func liveEntities, - Func localPlayerGuid, + LiveEntityRuntime liveEntities, + ILocalPlayerIdentitySource localPlayer, RemotePhysicsUpdater remotePhysics, LiveEntityOrdinaryPhysicsUpdater ordinaryPhysics, - Func projectiles, - Action publishRootPose, - Action captureAnimationHooks) + ProjectileController projectiles, + IEntityRootPosePublisher rootPoses, + IAnimationHookCaptureSink animationHooks) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); - _localPlayerGuid = localPlayerGuid - ?? throw new ArgumentNullException(nameof(localPlayerGuid)); + _localPlayer = localPlayer + ?? throw new ArgumentNullException(nameof(localPlayer)); _remotePhysics = remotePhysics ?? throw new ArgumentNullException(nameof(remotePhysics)); _ordinaryPhysics = ordinaryPhysics ?? throw new ArgumentNullException(nameof(ordinaryPhysics)); - _projectiles = projectiles - ?? throw new ArgumentNullException(nameof(projectiles)); - _publishRootPose = publishRootPose - ?? throw new ArgumentNullException(nameof(publishRootPose)); - _captureAnimationHooks = captureAnimationHooks - ?? throw new ArgumentNullException(nameof(captureAnimationHooks)); + _projectiles = projectiles ?? throw new ArgumentNullException(nameof(projectiles)); + _rootPoses = rootPoses ?? throw new ArgumentNullException(nameof(rootPoses)); + _animationHooks = animationHooks ?? throw new ArgumentNullException(nameof(animationHooks)); } /// @@ -69,9 +68,7 @@ internal sealed class LiveEntityAnimationScheduler Action? prepareAnimation = null) { _schedules.Clear(); - LiveEntityRuntime? runtime = _liveEntities(); - if (runtime is null) - return _schedules; + LiveEntityRuntime runtime = _liveEntities; runtime.CopySpatialRootObjectRecordsTo(_rootSnapshot); foreach (LiveEntityRecord record in _rootSnapshot) @@ -180,7 +177,7 @@ internal sealed class LiveEntityAnimationScheduler // PlayerMovementController owns this exact record clock and advances // its PartArray before local collision. Consume that prepared pose; // never tick the same clock or sequence a second time here. - if (serverGuid == _localPlayerGuid()) + if (serverGuid == _localPlayer.ServerGuid) { IReadOnlyList? prepared = animation?.PreparedSequenceFrames; @@ -208,7 +205,7 @@ internal sealed class LiveEntityAnimationScheduler // that are smaller than an admitted object quantum. Publish that // current root independently of PartArray recomposition so local // attached effects and lights never trail the rendered character. - _publishRootPose(entity); + _rootPoses.UpdateRoot(entity); if (!IsCurrent( runtime, record, @@ -249,7 +246,7 @@ internal sealed class LiveEntityAnimationScheduler IReadOnlyList? frames = null; float legacyElapsed = 0f; bool completed = true; - ProjectileController? projectileController = _projectiles(); + ProjectileController projectileController = _projectiles; bool projectileHandlesMovement = projectile is not null && projectileController?.HandlesMovement(serverGuid) == true; float objectScale = animation?.Scale @@ -275,7 +272,7 @@ internal sealed class LiveEntityAnimationScheduler entity, quantum, sequencer?.Manager, - _captureAnimationHooks, + _animationHooks.Capture, sequencer, runtime, record, @@ -289,7 +286,7 @@ internal sealed class LiveEntityAnimationScheduler { if (sequencer is not null) { - _captureAnimationHooks(entity.Id, sequencer); + _animationHooks.Capture(entity.Id, sequencer); if (!IsCurrent( runtime, record, @@ -355,7 +352,7 @@ internal sealed class LiveEntityAnimationScheduler rootDelta, liveCenterX, liveCenterY, - _captureAnimationHooks, + _animationHooks.Capture, runtime, record, objectClockEpoch)) @@ -381,7 +378,7 @@ internal sealed class LiveEntityAnimationScheduler liveCenterY, objectClockEpoch, sequencer, - _captureAnimationHooks)) + _animationHooks.Capture)) { completed = false; break; @@ -405,7 +402,7 @@ internal sealed class LiveEntityAnimationScheduler out projectileStep) == true; if (!ordinaryBodyHandlesMovement && sequencer is not null) - _captureAnimationHooks(entity.Id, sequencer); + _animationHooks.Capture(entity.Id, sequencer); if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) { completed = false; @@ -443,7 +440,7 @@ internal sealed class LiveEntityAnimationScheduler return default; } - _publishRootPose(entity); + _rootPoses.UpdateRoot(entity); if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) return default; diff --git a/src/AcDream.App/Rendering/LiveStaticAnimationResidency.cs b/src/AcDream.App/Rendering/LiveStaticAnimationResidency.cs new file mode 100644 index 00000000..3738f97c --- /dev/null +++ b/src/AcDream.App/Rendering/LiveStaticAnimationResidency.cs @@ -0,0 +1,34 @@ +using AcDream.App.World; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Resolves static-workset residency against the canonical live incarnation. +/// DAT-only statics have no server identity and remain resident for their +/// enclosing landblock resource lifetime. +/// +internal sealed class LiveStaticAnimationResidency +{ + private readonly ILiveEntityRuntimeSource _runtime; + + public LiveStaticAnimationResidency(ILiveEntityRuntimeSource runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + + public bool IsResident(WorldEntity entity) => + entity.ServerGuid == 0 + || (_runtime.Current?.TryGetRecord(entity.ServerGuid, out LiveEntityRecord resident) == true + && resident.ProjectionKind is LiveEntityProjectionKind.World + && ReferenceEquals(resident.WorldEntity, entity) + && resident.IsSpatiallyProjected + && resident.IsSpatiallyVisible + && resident.FullCellId != 0); + + public ulong ProjectionVersion(WorldEntity entity) => + entity.ServerGuid == 0 + ? 0UL + : _runtime.Current?.TryGetRecord(entity.ServerGuid, out LiveEntityRecord resident) == true + && ReferenceEquals(resident.WorldEntity, entity) + ? resident.ProjectionMutationVersion + : ulong.MaxValue; +} diff --git a/src/AcDream.App/Rendering/StaticLiveRootCommitter.cs b/src/AcDream.App/Rendering/StaticLiveRootCommitter.cs index a41696cc..de9d9e3b 100644 --- a/src/AcDream.App/Rendering/StaticLiveRootCommitter.cs +++ b/src/AcDream.App/Rendering/StaticLiveRootCommitter.cs @@ -1,4 +1,5 @@ using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; using AcDream.App.World; using AcDream.Core.Physics; using AcDream.Core.World; @@ -12,30 +13,28 @@ namespace AcDream.App.Rendering; /// internal sealed class StaticLiveRootCommitter { - private readonly Func _runtime; + private readonly ILiveEntityRuntimeSource _runtime; private readonly ShadowObjectRegistry _shadows; - private readonly Func<(int X, int Y)> _liveCenter; - private readonly Action _updateEffectRoot; + private readonly LiveWorldOriginState _origin; + private readonly EntityEffectPoseRegistry _effectPoses; public StaticLiveRootCommitter( - Func runtime, + ILiveEntityRuntimeSource runtime, ShadowObjectRegistry shadows, - Func<(int X, int Y)> liveCenter, - Action updateEffectRoot) + LiveWorldOriginState origin, + EntityEffectPoseRegistry effectPoses) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); - _liveCenter = liveCenter - ?? throw new ArgumentNullException(nameof(liveCenter)); - _updateEffectRoot = updateEffectRoot - ?? throw new ArgumentNullException(nameof(updateEffectRoot)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _effectPoses = effectPoses ?? throw new ArgumentNullException(nameof(effectPoses)); } public bool Commit(WorldEntity entity, PhysicsBody body) { ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(body); - LiveEntityRuntime? runtime = _runtime(); + LiveEntityRuntime? runtime = _runtime.Current; if (runtime is null || !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord record) || !ReferenceEquals(record.WorldEntity, entity) @@ -46,15 +45,14 @@ internal sealed class StaticLiveRootCommitter // The retained pose owner follows the CPhysicsObj root even while its // mesh is hidden. Hidden is presentation state, not logical teardown. - _updateEffectRoot(entity); + _effectPoses.UpdateRoot(entity); // EffectPoseChanged observers run synchronously and may delete this // incarnation (or replace the GUID) before returning. Do not let the // stale root restore collision shadows for an owner which no longer // exists. This is the same callback-boundary lifetime rule used by the // live schedulers and movement controllers. - if (!ReferenceEquals(_runtime(), runtime) - || !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord current) + if (!runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord current) || !ReferenceEquals(current, record) || !ReferenceEquals(current.WorldEntity, entity) || !ReferenceEquals(current.PhysicsBody, body)) @@ -76,15 +74,14 @@ internal sealed class StaticLiveRootCommitter uint cellId = body.CellPosition.ObjCellId != 0 ? body.CellPosition.ObjCellId : record.FullCellId; - (int centerX, int centerY) = _liveCenter(); ShadowPositionSynchronizer.Sync( _shadows, entity.Id, body.Position, body.Orientation, cellId, - centerX, - centerY); + _origin.CenterX, + _origin.CenterY); return true; } } diff --git a/src/AcDream.App/Update/LiveObjectFrameController.cs b/src/AcDream.App/Update/LiveObjectFrameController.cs new file mode 100644 index 00000000..21b3739c --- /dev/null +++ b/src/AcDream.App/Update/LiveObjectFrameController.cs @@ -0,0 +1,246 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Interaction; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Update; + +internal interface ILiveObjectFramePhase +{ + void Tick(float deltaSeconds); +} + +internal interface ILiveSessionFramePhase +{ + void Tick(); +} + +internal interface IPostNetworkCommandFramePhase +{ + void RunPostNetworkCommandPhase(); +} + +internal interface ILiveSpatialReconcilePhase +{ + void Reconcile(); +} + +internal interface IParticleRangeSource +{ + float RangeMultiplier { get; } +} + +internal sealed class SettingsParticleRangeSource : IParticleRangeSource +{ + private readonly SettingsVM? _settings; + private readonly ParticleRange _fallback; + + public SettingsParticleRangeSource(SettingsVM? settings, ParticleRange fallback) + { + _settings = settings; + _fallback = fallback; + } + + public float RangeMultiplier => + (_settings?.DisplayDraft.ParticleRange ?? _fallback) == ParticleRange.Extended + ? ParticleVisibilityController.ExtendedRangeMultiplier + : 1f; +} + +/// +/// Owns the shared effect-manager tail that follows ordinary and static +/// object worksets in acdream's registered TS-50/TS-51 adaptation. +/// +internal sealed class LiveEffectFrameController +{ + private readonly TranslucencyFadeManager _translucencyFades; + private readonly AnimationHookFrameQueue _animationHooks; + private readonly EntityEffectController _entityEffects; + private readonly ParticleHookSink _particleSink; + private readonly LiveEntityLightController _lights; + private readonly ParticleVisibilityController _particleVisibility; + private readonly ParticleSystem _particles; + private readonly PhysicsScriptRunner _scripts; + private readonly IPhysicsScriptTimeSource _scriptTime; + private readonly IParticleRangeSource _particleRange; + + public LiveEffectFrameController( + TranslucencyFadeManager translucencyFades, + AnimationHookFrameQueue animationHooks, + EntityEffectController entityEffects, + ParticleHookSink particleSink, + LiveEntityLightController lights, + ParticleVisibilityController particleVisibility, + ParticleSystem particles, + PhysicsScriptRunner scripts, + IPhysicsScriptTimeSource scriptTime, + IParticleRangeSource particleRange) + { + _translucencyFades = translucencyFades + ?? throw new ArgumentNullException(nameof(translucencyFades)); + _animationHooks = animationHooks ?? throw new ArgumentNullException(nameof(animationHooks)); + _entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects)); + _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink)); + _lights = lights ?? throw new ArgumentNullException(nameof(lights)); + _particleVisibility = particleVisibility + ?? throw new ArgumentNullException(nameof(particleVisibility)); + _particles = particles ?? throw new ArgumentNullException(nameof(particles)); + _scripts = scripts ?? throw new ArgumentNullException(nameof(scripts)); + _scriptTime = scriptTime ?? throw new ArgumentNullException(nameof(scriptTime)); + _particleRange = particleRange ?? throw new ArgumentNullException(nameof(particleRange)); + } + + public void Tick(float deltaSeconds) + { + // Retail's ordinary-object UpdatePositionInternal @ 0x00512C30 calls + // CPhysicsObj::process_hooks @ 0x00511550 before its manager tail. + // acdream currently keeps non-AnimationDone hooks at this deferred + // shared boundary under TS-50; this extraction must not silently + // claim that adaptation is gone. + _translucencyFades.AdvanceAll(deltaSeconds); + _animationHooks.Drain(); + _entityEffects.RefreshLiveOwnerPoses(); + _particleSink.RefreshAttachedEmitters(); + _lights.Refresh(); + _particleVisibility.Apply(_particles, _particleRange.RangeMultiplier); + + // Retail CPhysicsObj::UpdateObjectInternal @ 0x005156B0 advances the + // ordinary-object Particle manager before Script. acdream's shared + // once-per-host tail and its static-order difference remain TS-51. + _particles.Tick(deltaSeconds); + _scripts.Tick(_scriptTime.CurrentScriptTime); + } +} + +/// +/// Owns the complete pre-network local/ordinary/static live-object phase. +/// +internal sealed class LiveObjectFrameController : ILiveObjectFramePhase +{ + private readonly RetailInboundEventDispatcher _inboundEvents; + private readonly RetailLocalPlayerFrameController _localPlayerFrame; + private readonly SelectionInteractionController? _selectionInteractions; + private readonly LiveEntityRuntime _liveEntities; + private readonly ILocalPlayerIdentitySource _localPlayer; + private readonly LiveWorldOriginState _origin; + private readonly LiveEntityAnimationScheduler _animations; + private readonly RetailStaticAnimatingObjectScheduler _staticAnimations; + private readonly LiveEntityAnimationPresenter _animationPresenter; + private readonly LiveEntityAnimationRuntimeView + _animatedEntities; + private readonly EquippedChildRenderController _equippedChildren; + private readonly LiveEffectFrameController _effects; + + public LiveObjectFrameController( + RetailInboundEventDispatcher inboundEvents, + RetailLocalPlayerFrameController localPlayerFrame, + SelectionInteractionController? selectionInteractions, + LiveEntityRuntime liveEntities, + ILocalPlayerIdentitySource localPlayer, + LiveWorldOriginState origin, + LiveEntityAnimationScheduler animations, + RetailStaticAnimatingObjectScheduler staticAnimations, + LiveEntityAnimationPresenter animationPresenter, + LiveEntityAnimationRuntimeView animatedEntities, + EquippedChildRenderController equippedChildren, + LiveEffectFrameController effects) + { + _inboundEvents = inboundEvents ?? throw new ArgumentNullException(nameof(inboundEvents)); + _localPlayerFrame = localPlayerFrame + ?? throw new ArgumentNullException(nameof(localPlayerFrame)); + _selectionInteractions = selectionInteractions; + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _animations = animations ?? throw new ArgumentNullException(nameof(animations)); + _staticAnimations = staticAnimations + ?? throw new ArgumentNullException(nameof(staticAnimations)); + _animationPresenter = animationPresenter + ?? throw new ArgumentNullException(nameof(animationPresenter)); + _animatedEntities = animatedEntities + ?? throw new ArgumentNullException(nameof(animatedEntities)); + _equippedChildren = equippedChildren + ?? throw new ArgumentNullException(nameof(equippedChildren)); + _effects = effects ?? throw new ArgumentNullException(nameof(effects)); + } + + public void Tick(float deltaSeconds) => + _inboundEvents.Run( + this, + deltaSeconds, + static (controller, elapsed) => controller.TickCore(elapsed)); + + private void TickCore(float deltaSeconds) + { + _localPlayerFrame.AdvanceBeforeNetwork(deltaSeconds); + _selectionInteractions?.DrainOutbound(); + + Vector3? playerPosition = + _liveEntities.MaterializedWorldEntities.TryGetValue( + _localPlayer.ServerGuid, + out WorldEntity? player) + ? player.Position + : null; + IReadOnlyDictionary schedules = + _animations.Tick( + deltaSeconds, + playerPosition, + _localPlayerFrame.HiddenPartPoseDirty, + _origin.CenterX, + _origin.CenterY, + _animationPresenter.PrepareAnimation); + + // CPhysics::UseTime @ 0x00509950 walks ordinary objects before the + // distinct static_animating_objects workset. + _staticAnimations.Tick(deltaSeconds); + if (_animatedEntities.Count > 0) + _animationPresenter.Present(schedules); + _equippedChildren.Tick(); + // Retail CPhysicsObj::animate_static_object @ 0x00513DF0 runs + // Script -> Particle -> process_hooks. acdream intentionally retains + // the TS-51 static-order difference here: ProcessHooks precedes the + // shared once-per-host Particle -> Script tail below. + _staticAnimations.ProcessHooks(); + _effects.Tick(deltaSeconds); + } +} + +/// +/// Re-composes spatial derivatives after authoritative root mutations without +/// advancing animation, physics, effects, particles, scripts, or fades. +/// +internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcilePhase +{ + private readonly EntityEffectController _entityEffects; + private readonly EquippedChildRenderController _equippedChildren; + private readonly ParticleHookSink _particleSink; + private readonly LiveEntityLightController _lights; + + public LiveSpatialPresentationReconciler( + EntityEffectController entityEffects, + EquippedChildRenderController equippedChildren, + ParticleHookSink particleSink, + LiveEntityLightController lights) + { + _entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects)); + _equippedChildren = equippedChildren + ?? throw new ArgumentNullException(nameof(equippedChildren)); + _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink)); + _lights = lights ?? throw new ArgumentNullException(nameof(lights)); + } + + public void Reconcile() + { + _entityEffects.RefreshLiveOwnerPoses(); + _equippedChildren.Tick(); + _particleSink.RefreshAttachedEmitters(); + _lights.Refresh(); + } +} diff --git a/src/AcDream.App/World/LiveEntityRuntimeSlot.cs b/src/AcDream.App/World/LiveEntityRuntimeSlot.cs new file mode 100644 index 00000000..e9c677d3 --- /dev/null +++ b/src/AcDream.App/World/LiveEntityRuntimeSlot.cs @@ -0,0 +1,24 @@ +namespace AcDream.App.World; + +internal interface ILiveEntityRuntimeSource +{ + LiveEntityRuntime? Current { get; } +} + +/// +/// One-time composition bridge for the resource-activation/runtime cycle. +/// It owns no identity or entity state; all authority remains in the bound +/// . +/// +internal sealed class LiveEntityRuntimeSlot : ILiveEntityRuntimeSource +{ + public LiveEntityRuntime? Current { get; private set; } + + public void Bind(LiveEntityRuntime runtime) + { + ArgumentNullException.ThrowIfNull(runtime); + if (Current is not null) + throw new InvalidOperationException("The live entity runtime is already bound."); + Current = runtime; + } +} diff --git a/src/AcDream.App/World/LiveEntityRuntimeViews.cs b/src/AcDream.App/World/LiveEntityRuntimeViews.cs index 5d6620e0..0ce8cef0 100644 --- a/src/AcDream.App/World/LiveEntityRuntimeViews.cs +++ b/src/AcDream.App/World/LiveEntityRuntimeViews.cs @@ -9,21 +9,21 @@ public sealed class LiveEntityAnimationRuntimeView : IEnumerable> where TAnimation : class, ILiveEntityAnimationRuntime { - private readonly Func _runtime; + private readonly ILiveEntityRuntimeSource _runtime; private readonly List> _iterationSnapshot = new(); - public LiveEntityAnimationRuntimeView(Func runtime) => + internal LiveEntityAnimationRuntimeView(ILiveEntityRuntimeSource runtime) => _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - public int Count => _runtime()?.SpatialAnimationRuntimes.Count ?? 0; + public int Count => _runtime.Current?.SpatialAnimationRuntimes.Count ?? 0; public IEnumerable Keys => - _runtime()?.SpatialAnimationRuntimes.Keys ?? Array.Empty(); + _runtime.Current?.SpatialAnimationRuntimes.Keys ?? Array.Empty(); public TAnimation this[uint localEntityId] { set { - LiveEntityRuntime runtime = _runtime() + LiveEntityRuntime runtime = _runtime.Current ?? throw new InvalidOperationException("Live entity runtime is not initialized."); if (!runtime.TryGetServerGuid(localEntityId, out uint guid)) throw new InvalidOperationException($"No live entity owns local id 0x{localEntityId:X8}."); @@ -33,7 +33,7 @@ public sealed class LiveEntityAnimationRuntimeView public bool TryGetValue(uint localEntityId, out TAnimation animation) { - if (_runtime()?.TryGetAnimationRuntime(localEntityId, out var found) == true + if (_runtime.Current?.TryGetAnimationRuntime(localEntityId, out var found) == true && found is TAnimation typed) { animation = typed; @@ -46,7 +46,7 @@ public sealed class LiveEntityAnimationRuntimeView public bool Remove(uint localEntityId) { - LiveEntityRuntime? runtime = _runtime(); + LiveEntityRuntime? runtime = _runtime.Current; return runtime is not null && runtime.TryGetServerGuid(localEntityId, out uint guid) && runtime.ClearAnimationRuntime(guid); @@ -57,7 +57,7 @@ public sealed class LiveEntityAnimationRuntimeView public void CopySpatialIdsTo(HashSet destination) { ArgumentNullException.ThrowIfNull(destination); - LiveEntityRuntime? runtime = _runtime(); + LiveEntityRuntime? runtime = _runtime.Current; if (runtime is null) destination.Clear(); else @@ -66,7 +66,7 @@ public sealed class LiveEntityAnimationRuntimeView public Enumerator GetEnumerator() { - LiveEntityRuntime? runtime = _runtime(); + LiveEntityRuntime? runtime = _runtime.Current; if (runtime is null) _iterationSnapshot.Clear(); else @@ -135,21 +135,21 @@ public sealed class LiveEntityAnimationRuntimeView public sealed class LiveEntityRemoteMotionRuntimeView where TRemoteMotion : class, ILiveEntityRemoteMotionRuntime { - private readonly Func _runtime; + private readonly ILiveEntityRuntimeSource _runtime; - public LiveEntityRemoteMotionRuntimeView(Func runtime) => + internal LiveEntityRemoteMotionRuntimeView(ILiveEntityRuntimeSource runtime) => _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); public TRemoteMotion this[uint serverGuid] { - set => (_runtime() + set => (_runtime.Current ?? throw new InvalidOperationException("Live entity runtime is not initialized.")) .SetRemoteMotionRuntime(serverGuid, value); } public bool TryGetValue(uint serverGuid, out TRemoteMotion motion) { - if (_runtime()?.TryGetRemoteMotionRuntime(serverGuid, out var found) == true + if (_runtime.Current?.TryGetRemoteMotionRuntime(serverGuid, out var found) == true && found is TRemoteMotion typed) { motion = typed; @@ -161,5 +161,5 @@ public sealed class LiveEntityRemoteMotionRuntimeView } public bool Remove(uint serverGuid) => - _runtime()?.ClearRemoteMotionRuntime(serverGuid) == true; + _runtime.Current?.ClearRemoteMotionRuntime(serverGuid) == true; } diff --git a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs index 7ac15993..6e9cbb80 100644 --- a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs +++ b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs @@ -1,4 +1,7 @@ using AcDream.App.Update; +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.App.Streaming; namespace AcDream.App.World; @@ -14,36 +17,37 @@ namespace AcDream.App.World; /// from freezing an action due to complete this frame while ensuring the /// periodic AutonomousPosition check observes accepted inbound state. /// -public sealed class RetailLiveFrameCoordinator +internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase { - private readonly Action _advanceObjectRuntime; - private readonly Action _dispatchInboundNetwork; - private readonly Action _runPostNetworkCommands; - private readonly Action _reconcileSpatialPresentation; + private readonly ILiveObjectFramePhase _objects; + private readonly GpuWorldState _worldState; + private readonly ILiveSessionFramePhase _session; + private readonly IPostNetworkCommandFramePhase _localPlayer; + private readonly ILiveSpatialReconcilePhase _spatialReconciler; public RetailLiveFrameCoordinator( - Action advanceObjectRuntime, - Action dispatchInboundNetwork, - Action runPostNetworkCommands, - Action reconcileSpatialPresentation) + ILiveObjectFramePhase objects, + GpuWorldState worldState, + ILiveSessionFramePhase session, + IPostNetworkCommandFramePhase localPlayer, + ILiveSpatialReconcilePhase spatialReconciler) { - _advanceObjectRuntime = advanceObjectRuntime - ?? throw new ArgumentNullException(nameof(advanceObjectRuntime)); - _dispatchInboundNetwork = dispatchInboundNetwork - ?? throw new ArgumentNullException(nameof(dispatchInboundNetwork)); - _runPostNetworkCommands = runPostNetworkCommands - ?? throw new ArgumentNullException(nameof(runPostNetworkCommands)); - _reconcileSpatialPresentation = reconcileSpatialPresentation - ?? throw new ArgumentNullException(nameof(reconcileSpatialPresentation)); + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer)); + _spatialReconciler = spatialReconciler + ?? throw new ArgumentNullException(nameof(spatialReconciler)); } public void Tick(float deltaSeconds) { float frameDelta = (float)NormalizeDeltaSeconds(deltaSeconds); - _advanceObjectRuntime(frameDelta); - _dispatchInboundNetwork(); - _runPostNetworkCommands(); - _reconcileSpatialPresentation(); + _objects.Tick(frameDelta); + using (_worldState.BeginMutationBatch()) + _session.Tick(); + _localPlayer.RunPostNetworkCommandPhase(); + _spatialReconciler.Reconcile(); } public static double NormalizeDeltaSeconds(double deltaSeconds) => diff --git a/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs b/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs new file mode 100644 index 00000000..50812877 --- /dev/null +++ b/tests/AcDream.App.Tests/FramePhaseTestDoubles.cs @@ -0,0 +1,27 @@ +using AcDream.App.Update; + +namespace AcDream.App.Tests; + +internal sealed class TestLiveObjectFramePhase(Action tick) + : ILiveObjectFramePhase +{ + public void Tick(float deltaSeconds) => tick(deltaSeconds); +} + +internal sealed class TestLiveSessionFramePhase(Action tick) + : ILiveSessionFramePhase +{ + public void Tick() => tick(); +} + +internal sealed class TestPostNetworkCommandFramePhase(Action run) + : IPostNetworkCommandFramePhase +{ + public void RunPostNetworkCommandPhase() => run(); +} + +internal sealed class TestLiveSpatialReconcilePhase(Action reconcile) + : ILiveSpatialReconcilePhase +{ + public void Reconcile() => reconcile(); +} diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs index 00964469..058c4694 100644 --- a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.Input; +using AcDream.App.Streaming; using AcDream.App.World; using AcDream.Core.Physics; @@ -33,22 +34,23 @@ public sealed class RetailLocalPlayerFrameControllerTests postNetwork.Add(owner.State); }); var live = new RetailLiveFrameCoordinator( - dt => + new TestLiveObjectFramePhase(dt => { calls.Add("objects"); local.AdvanceBeforeNetwork(dt); - }, - () => + }), + new GpuWorldState(), + new TestLiveSessionFramePhase(() => { calls.Add("f751"); controller.State = PlayerState.PortalSpace; - }, - () => + }), + new TestPostNetworkCommandFramePhase(() => { calls.Add("commands"); local.RunPostNetworkCommandPhase(); - }, - () => calls.Add("spatial")); + }), + new TestLiveSpatialReconcilePhase(() => calls.Add("spatial"))); // CPhysicsObj::update_object admits manager time only after the strict // minimum quantum. Use one admitted object tick; the test's concern is @@ -118,10 +120,12 @@ public sealed class RetailLocalPlayerFrameControllerTests sendPreNetwork: (_, _, _) => { }, sendPostNetwork: (_, _) => { }); var live = new RetailLiveFrameCoordinator( - local.AdvanceBeforeNetwork, - () => projectedRoot = new Vector3(999f, 999f, 999f), - local.RunPostNetworkCommandPhase, - () => { }); + new TestLiveObjectFramePhase(local.AdvanceBeforeNetwork), + new GpuWorldState(), + new TestLiveSessionFramePhase( + () => projectedRoot = new Vector3(999f, 999f, 999f)), + local, + new TestLiveSpatialReconcilePhase(() => { })); live.Tick(1f / 60f); float timeAfterOneTick = controller.SimTimeSeconds; diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index f0fd81a4..7d6644df 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -1430,6 +1430,24 @@ public sealed class ProjectileControllerTests private sealed class Fixture { + private sealed class SetupResolver : IProjectileSetupResolver + { + private readonly Func _resolve; + + internal SetupResolver(Func resolve) => _resolve = resolve; + + public Setup? Resolve(uint setupId) => _resolve(); + } + + private sealed class RootPosePublisher : IEntityRootPosePublisher + { + private readonly Action _publish; + + internal RootPosePublisher(Action publish) => _publish = publish; + + public void UpdateRoot(WorldEntity entity) => _publish(entity); + } + internal Fixture( bool loadInitialLandblock = true, PhysicsEngine? physicsEngine = null, @@ -1439,12 +1457,15 @@ public sealed class ProjectileControllerTests Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); Live = new LiveEntityRuntime(Spatial, Resources); Engine = physicsEngine ?? new PhysicsEngine(); + Origin.Recenter(1, 1); Controller = new ProjectileController( Live, Engine, - _ => ResolvedSetup, - publishRootPose ?? (entity => Poses.UpdateRoot(entity)), - () => LiveCenter); + new SetupResolver(() => ResolvedSetup), + new RootPosePublisher( + publishRootPose ?? new Action( + entity => { _ = Poses.UpdateRoot(entity); })), + Origin); } internal GpuWorldState Spatial { get; } = new(); @@ -1452,8 +1473,8 @@ public sealed class ProjectileControllerTests internal LiveEntityRuntime Live { get; } internal PhysicsEngine Engine { get; } internal EntityEffectPoseRegistry Poses { get; } = new(); + internal LiveWorldOriginState Origin { get; } = new(); internal Setup ResolvedSetup { get; set; } = ProjectileSetup(); - internal (int X, int Y) LiveCenter { get; set; } = (1, 1); internal ProjectileController Controller { get; } internal LiveEntityRecord Spawn( diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs index 334d7dc3..0450e6d3 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs @@ -364,13 +364,29 @@ public sealed class LiveEntityAnimationPresenterTests LiveEntityRuntime live, EntityEffectPoseRegistry poses, Context context) => new( - () => live, - () => null, + live, + new EmptyStaticPartFrameSource(), poses, context, AnimationPresentationDiagnostics.Disabled, hidePartIndex: -1); + private sealed class EmptyStaticPartFrameSource : ILiveStaticPartFrameSource + { + public bool TryTakeLivePartFrames( + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation, + ulong objectClockEpoch, + ulong projectionMutationVersion, + ulong presentationRevision, + out IReadOnlyList partFrames) + { + partFrames = Array.Empty(); + return false; + } + } + private static IReadOnlyDictionary Schedules( Fixture fixture, IReadOnlyList frames) => diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs index c81d5a2b..ca8d646b 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs @@ -1,12 +1,15 @@ 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.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; +using AcDream.Core.Vfx; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; @@ -629,14 +632,42 @@ public sealed class LiveEntityAnimationSchedulerTests var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater( physics, (_, _) => (0.48f, 1.835f)); + var identity = new LocalPlayerIdentityState { ServerGuid = localPlayerGuid }; + var poses = new EntityEffectPoseRegistry(); + foreach (WorldEntity entity in live.MaterializedWorldEntities.Values) + poses.PublishMeshRefs(entity); + IAnimationHookCaptureSink animationHooks = captureHooks is null + ? new AnimationHookCaptureSink(new AnimationHookFrameQueue( + new AnimationHookRouter(), + poses)) + : new TestAnimationHookCaptureSink(captureHooks); + projectiles ??= new ProjectileController(live, physics); return new LiveEntityAnimationScheduler( - () => live, - () => localPlayerGuid, + live, + identity, remotePhysics, ordinaryPhysics, - () => projectiles, - publishRootPose ?? (_ => { }), - captureHooks ?? ((_, _) => { })); + projectiles, + new TestRootPosePublisher(poses, publishRootPose), + animationHooks); + } + + private sealed class TestAnimationHookCaptureSink( + Action capture) : IAnimationHookCaptureSink + { + public void Capture(uint ownerLocalId, AnimationSequencer sequencer) => + capture(ownerLocalId, sequencer); + } + + private sealed class TestRootPosePublisher( + EntityEffectPoseRegistry poses, + Action? published) : IEntityRootPosePublisher + { + public void UpdateRoot(WorldEntity entity) + { + poses.UpdateRoot(entity); + published?.Invoke(entity); + } } private static (LiveEntityRuntime Live, LiveEntityRecord Record, diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs index ab53bcac..52460ba3 100644 --- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -47,6 +47,10 @@ public sealed class GameWindowLiveEntityCompositionTests [InlineData("DispatchRemoteInboundMotion")] [InlineData("CreateRemoteMotion")] [InlineData("WillAdvanceRemoteMotion")] + [InlineData("AdvanceLiveObjectRuntime")] + [InlineData("AdvanceLiveObjectRuntimeCore")] + [InlineData("ReconcileLiveObjectSpatialPresentation")] + [InlineData("CaptureAnimationHooks")] public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName) { Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation)); @@ -73,6 +77,13 @@ public sealed class GameWindowLiveEntityCompositionTests [InlineData(typeof(LiveEntityMotionRuntimeController))] [InlineData(typeof(LiveEntityInboundAuthorityGate))] [InlineData(typeof(DeferredLiveEntityMotionRuntimeBindings))] + [InlineData(typeof(AcDream.App.Update.LiveObjectFrameController))] + [InlineData(typeof(AcDream.App.Update.LiveEffectFrameController))] + [InlineData(typeof(AcDream.App.Update.LiveSpatialPresentationReconciler))] + [InlineData(typeof(LiveEntityAnimationPresenter))] + [InlineData(typeof(LiveAnimationPresentationContext))] + [InlineData(typeof(StaticLiveRootCommitter))] + [InlineData(typeof(RetailLiveFrameCoordinator))] public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType) { foreach (FieldInfo field in helperType.GetFields( @@ -83,6 +94,89 @@ public sealed class GameWindowLiveEntityCompositionTests string typeName = field.FieldType.FullName ?? field.FieldType.Name; Assert.DoesNotContain("Silk.NET", typeName, StringComparison.Ordinal); Assert.DoesNotContain("OpenGL", typeName, StringComparison.OrdinalIgnoreCase); + Assert.NotEqual(typeof(GameWindow), field.FieldType); } } + + [Fact] + public void ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks() + { + Type[] owners = + [ + typeof(AcDream.App.Update.LiveObjectFrameController), + typeof(AcDream.App.Update.LiveEffectFrameController), + typeof(AcDream.App.Update.LiveSpatialPresentationReconciler), + typeof(LiveEntityAnimationScheduler), + typeof(LiveEntityAnimationPresenter), + typeof(LiveAnimationPresentationContext), + typeof(StaticLiveRootCommitter), + typeof(RetailLiveFrameCoordinator), + ]; + + foreach (Type owner in owners) + { + Assert.DoesNotContain( + owner.GetFields(BindingFlags.Instance | BindingFlags.NonPublic), + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + Assert.False(typeof(ILiveAnimationPresentationContext).IsAssignableFrom( + typeof(GameWindow))); + } + + [Fact] + public void RuntimeViewsAndProjectileController_RetainTypedSourcesNotWindowClosures() + { + FieldInfo animationRuntime = Assert.Single( + typeof(LiveEntityAnimationRuntimeView) + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_runtime"); + FieldInfo remoteRuntime = Assert.Single( + typeof(LiveEntityRemoteMotionRuntimeView) + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_runtime"); + Assert.Equal(typeof(ILiveEntityRuntimeSource), animationRuntime.FieldType); + Assert.Equal(typeof(ILiveEntityRuntimeSource), remoteRuntime.FieldType); + + FieldInfo[] projectileFields = typeof(ProjectileController).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.Equal( + typeof(IProjectileSetupResolver), + Assert.Single(projectileFields, field => field.Name == "_setupResolver").FieldType); + Assert.Equal( + typeof(IEntityRootPosePublisher), + Assert.Single(projectileFields, field => field.Name == "_rootPoses").FieldType); + Assert.Equal( + typeof(LiveWorldOriginState), + Assert.Single(projectileFields, field => field.Name == "_origin").FieldType); + Assert.DoesNotContain( + projectileFields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType) + && !field.Name.Contains("DiagnosticSink", StringComparison.Ordinal)); + + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs")); + Assert.DoesNotContain( + "new LiveEntityAnimationRuntimeView(() =>", + source, + StringComparison.Ordinal); + Assert.DoesNotContain( + "new LiveEntityRemoteMotionRuntimeView(() =>", + source, + StringComparison.Ordinal); + Assert.Contains("new AcDream.App.Physics.DatProjectileSetupResolver", source); + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } } diff --git a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs index 20195c3d..4e5080ac 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs @@ -202,10 +202,7 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests lifecycle); Projectiles = new ProjectileController( Live, - Physics, - _ => null, - _ => { }, - () => (0, 0)); + Physics); Controller = new LiveEntityProjectionWithdrawalController( Live, Projectiles, diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index dfa9a0d4..f0dd7c8d 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -17,6 +17,20 @@ namespace AcDream.App.Tests.World; public sealed class LiveEntityRuntimeTests { + [Fact] + public void RuntimeSlot_BindsExactlyOnceWithoutOwningEntityState() + { + var spatial = new GpuWorldState(); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + var slot = new LiveEntityRuntimeSlot(); + + Assert.Null(slot.Current); + slot.Bind(runtime); + + Assert.Same(runtime, slot.Current); + Assert.Throws(() => slot.Bind(runtime)); + } + private sealed class RecordingResources : ILiveEntityResourceLifecycle { public int RegisterCount { get; private set; } @@ -245,11 +259,18 @@ public sealed class LiveEntityRuntimeTests landblockId: 0x01010000u, seedCellId: 0x01010001u); int poseUpdates = 0; + var runtimeSlot = new LiveEntityRuntimeSlot(); + runtimeSlot.Bind(runtime); + var origin = new LiveWorldOriginState(); + origin.Recenter(1, 1); + var poses = new EntityEffectPoseRegistry(); + poses.PublishMeshRefs(entity); + poses.EffectPoseChanged += _ => poseUpdates++; var committer = new StaticLiveRootCommitter( - () => runtime, + runtimeSlot, shadows, - () => (1, 1), - _ => poseUpdates++); + origin, + poses); body.SetFrameInCurrentCell( body.Position, @@ -282,13 +303,14 @@ public sealed class LiveEntityRuntimeTests [Fact] public void StaticRootCommit_BeforeLiveRuntimeCompositionIsSafeNoOp() { - LiveEntityRuntime? runtime = null; int poseUpdates = 0; + var poses = new EntityEffectPoseRegistry(); + poses.EffectPoseChanged += _ => poseUpdates++; var committer = new StaticLiveRootCommitter( - () => runtime, + new LiveEntityRuntimeSlot(), new ShadowObjectRegistry(), - () => (0, 0), - _ => poseUpdates++); + new LiveWorldOriginState(), + poses); Assert.False(committer.Commit( Entity(0x7F000001u, 0x70000001u), @@ -347,26 +369,33 @@ public sealed class LiveEntityRuntimeTests worldOffsetY: 0f, landblockId: 0x01010000u, seedCellId: 0x01010001u); + var runtimeSlot = new LiveEntityRuntimeSlot(); + runtimeSlot.Bind(runtime); + var origin = new LiveWorldOriginState(); + origin.Recenter(1, 1); + var poses = new EntityEffectPoseRegistry(); + poses.PublishMeshRefs(oldEntity); + poses.EffectPoseChanged += _ => + { + Assert.True(shadows.Suspend(oldEntity.Id)); + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + runtime.RegisterLiveEntity(Spawn( + guid, + instance: 2, + positionSequence: 1, + cell: 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + }; var committer = new StaticLiveRootCommitter( - () => runtime, + runtimeSlot, shadows, - () => (1, 1), - _ => - { - Assert.True(shadows.Suspend(oldEntity.Id)); - Assert.True(runtime.UnregisterLiveEntity( - new DeleteObject.Parsed(guid, InstanceSequence: 1), - isLocalPlayer: false)); - runtime.RegisterLiveEntity(Spawn( - guid, - instance: 2, - positionSequence: 1, - cell: 0x01010001u)); - runtime.MaterializeLiveEntity( - guid, - 0x01010001u, - id => Entity(id, guid)); - }); + origin, + poses); oldBody.SetFrameInCurrentCell( oldBody.Position, @@ -453,7 +482,7 @@ public sealed class LiveEntityRuntimeTests id => Entity(id, guid))!; var animation = new AnimationRuntime(entity); runtime.SetAnimationRuntime(guid, animation); - var view = new LiveEntityAnimationRuntimeView(() => runtime); + var view = new LiveEntityAnimationRuntimeView(BoundSlot(runtime)); Assert.Equal(1, view.Count); Assert.Equal(entity.Id, Assert.Single(view.Keys)); @@ -489,7 +518,7 @@ public sealed class LiveEntityRuntimeTests id => Entity(id, guid))!; runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity)); } - var view = new LiveEntityAnimationRuntimeView(() => runtime); + var view = new LiveEntityAnimationRuntimeView(BoundSlot(runtime)); var visited = new List(); foreach (KeyValuePair pair in view) @@ -519,7 +548,7 @@ public sealed class LiveEntityRuntimeTests id => Entity(id, guid))!; runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity)); var view = new LiveEntityAnimationRuntimeView( - () => runtime); + BoundSlot(runtime)); var ids = new HashSet(); view.CopySpatialIdsTo(ids); @@ -2803,6 +2832,13 @@ public sealed class LiveEntityRuntimeTests private static LoadedLandblock EmptyLandblock(uint canonicalId) => new(canonicalId, new LandBlock(), Array.Empty()); + private static LiveEntityRuntimeSlot BoundSlot(LiveEntityRuntime runtime) + { + var slot = new LiveEntityRuntimeSlot(); + slot.Bind(runtime); + return slot; + } + private static void ResolveParent(LiveEntityRuntime runtime, uint childGuid) => runtime.ParentAttachments.Resolve( childGuid, diff --git a/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs b/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs new file mode 100644 index 00000000..fdb9982c --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs @@ -0,0 +1,84 @@ +using AcDream.App.Rendering.Vfx; +using AcDream.App.Update; +using AcDream.UI.Abstractions.Input; +using AcDream.UI.Abstractions.Panels.Settings; +using Silk.NET.Input; + +namespace AcDream.App.Tests.World; + +public sealed class LiveObjectFrameControllerTests +{ + [Theory] + [InlineData(ParticleRange.Retail, 1f)] + [InlineData( + ParticleRange.Extended, + ParticleVisibilityController.ExtendedRangeMultiplier)] + public void ParticleRange_NullSettingsUsesConfiguredFallback( + ParticleRange fallback, + float expected) + { + var source = new SettingsParticleRangeSource(null, fallback); + + Assert.Equal(expected, source.RangeMultiplier); + } + + [Fact] + public void ParticleRange_LiveSettingsDraftOverridesFallbackAndUpdatesImmediately() + { + SettingsVM settings = CreateSettings(); + var source = new SettingsParticleRangeSource(settings, ParticleRange.Retail); + + settings.SetDisplay(settings.DisplayDraft with { ParticleRange = ParticleRange.Retail }); + Assert.Equal(1f, source.RangeMultiplier); + + settings.SetDisplay(settings.DisplayDraft with { ParticleRange = ParticleRange.Extended }); + Assert.Equal( + ParticleVisibilityController.ExtendedRangeMultiplier, + source.RangeMultiplier); + } + + private static SettingsVM CreateSettings() + { + var dispatcher = new InputDispatcher( + new NullKeyboardSource(), + new NullMouseSource(), + new KeyBindings()); + return new SettingsVM( + new KeyBindings(), + dispatcher, + static _ => { }, + DisplaySettings.Default, + static _ => { }, + AudioSettings.Default, + static _ => { }, + GameplaySettings.Default, + static _ => { }, + ChatSettings.Default, + static _ => { }, + CharacterSettings.Default, + static _ => { }); + } + + private sealed class NullKeyboardSource : IKeyboardSource + { +#pragma warning disable CS0067 + public event Action? KeyDown; + public event Action? KeyUp; +#pragma warning restore CS0067 + public bool IsHeld(Key key) => false; + public ModifierMask CurrentModifiers => ModifierMask.None; + } + + private sealed class NullMouseSource : IMouseSource + { +#pragma warning disable CS0067 + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool IsHeld(MouseButton button) => false; + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => false; + } +} diff --git a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs index d1810824..a57ae719 100644 --- a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs +++ b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs @@ -1,9 +1,11 @@ using System.Numerics; using AcDream.App.Rendering; +using AcDream.App.Streaming; using AcDream.App.Update; using AcDream.App.World; using AcDream.Content.Vfx; using AcDream.Core.Physics; +using AcDream.Core.World; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Options; @@ -25,10 +27,11 @@ public sealed class RecallTeleportAnimationTests { var calls = new List(); var frame = new RetailLiveFrameCoordinator( - _ => calls.Add("objects"), - () => calls.Add("network"), - () => calls.Add("commands"), - () => calls.Add("spatial")); + new TestLiveObjectFramePhase(_ => calls.Add("objects")), + new GpuWorldState(), + new TestLiveSessionFramePhase(() => calls.Add("network")), + new TestPostNetworkCommandFramePhase(() => calls.Add("commands")), + new TestLiveSpatialReconcilePhase(() => calls.Add("spatial"))); frame.Tick(1f / 60f); @@ -39,25 +42,96 @@ public sealed class RecallTeleportAnimationTests public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch() { var deltas = new List(); - int networkDispatches = 0; + var calls = new List(); var frame = new RetailLiveFrameCoordinator( - deltas.Add, - () => networkDispatches++, - () => { }, - () => { }); + new TestLiveObjectFramePhase(delta => + { + deltas.Add(delta); + calls.Add("objects"); + }), + new GpuWorldState(), + new TestLiveSessionFramePhase(() => calls.Add("network")), + new TestPostNetworkCommandFramePhase(() => calls.Add("commands")), + new TestLiveSpatialReconcilePhase(() => calls.Add("spatial"))); frame.Tick(float.NaN); frame.Tick(float.PositiveInfinity); frame.Tick(-1f); Assert.Equal([0f, 0f, 0f], deltas); - Assert.Equal(3, networkDispatches); + Assert.Equal( + Enumerable.Repeat(new[] { "objects", "network", "commands", "spatial" }, 3) + .SelectMany(static frameCalls => frameCalls), + calls); Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.NaN)); Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.NegativeInfinity)); Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.PositiveInfinity)); Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.MaxValue)); } + [Fact] + public void LiveFrame_CommitsTheInboundMutationBatchBeforeCommandsAndReconcile() + { + const uint landblock = 0x0101FFFFu; + var calls = new List(); + var world = new GpuWorldState(); + long before = world.VisibilityCommitCount; + var frame = new RetailLiveFrameCoordinator( + new TestLiveObjectFramePhase(_ => calls.Add("objects")), + world, + new TestLiveSessionFramePhase(() => + { + calls.Add("network"); + world.PlaceLiveEntityProjection(landblock, Entity(1)); + Assert.Equal(before, world.VisibilityCommitCount); + }), + new TestPostNetworkCommandFramePhase(() => + { + calls.Add("commands"); + Assert.Equal(before + 1, world.VisibilityCommitCount); + }), + new TestLiveSpatialReconcilePhase(() => + { + calls.Add("spatial"); + Assert.Equal(before + 1, world.VisibilityCommitCount); + })); + + frame.Tick(1f / 60f); + + Assert.Equal(["objects", "network", "commands", "spatial"], calls); + Assert.Equal(before + 1, world.VisibilityCommitCount); + Assert.Equal(1, world.PendingLiveEntityCount); + } + + [Fact] + public void LiveFrame_SessionFailureDisposesMutationBatchAndSuppressesLaterPhases() + { + const uint landblock = 0x0101FFFFu; + var calls = new List(); + var world = new GpuWorldState(); + long before = world.VisibilityCommitCount; + var frame = new RetailLiveFrameCoordinator( + new TestLiveObjectFramePhase(_ => calls.Add("objects")), + world, + new TestLiveSessionFramePhase(() => + { + calls.Add("network"); + world.PlaceLiveEntityProjection(landblock, Entity(2)); + Assert.Equal(before, world.VisibilityCommitCount); + throw new InvalidOperationException("inbound failure"); + }), + new TestPostNetworkCommandFramePhase(() => calls.Add("commands")), + new TestLiveSpatialReconcilePhase(() => calls.Add("spatial"))); + + InvalidOperationException error = Assert.Throws( + () => frame.Tick(1f / 60f)); + + Assert.Equal("inbound failure", error.Message); + Assert.Equal(["objects", "network"], calls); + Assert.Equal(before + 1, world.VisibilityCommitCount); + Assert.Equal(1, world.PendingLiveEntityCount); + } + [Fact] public void InstalledRecall_HiddenEnterWorldPublishesFinishedCyclicPoseWithoutAdvancingTime() { @@ -108,4 +182,14 @@ public sealed class RecallTeleportAnimationTests Quaternion.Normalize(recallTail[index].Orientation), Quaternion.Normalize(finishedPose[index].Orientation))) < 0.999999f); } + + private static WorldEntity Entity(uint id) => new() + { + Id = id, + ServerGuid = 0x70000000u + id, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; } diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index e22cf03c..64e35436 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -228,6 +228,16 @@ public sealed class UpdateFrameOrchestratorTests FieldInfo[] ownerFields = owner.GetFields( BindingFlags.Instance | BindingFlags.NonPublic); Assert.DoesNotContain(ownerFields, field => field.FieldType == typeof(GameWindow)); + if (owner == typeof(AcDream.App.Input.RetailLocalPlayerFrameController)) + { + // Checkpoint B deliberately leaves this single legacy callback + // composition for D/F, which own input capture and the mutable + // player-mode/controller slot. No other phase owner may add one. + Assert.Contains( + ownerFields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + continue; + } Assert.DoesNotContain( ownerFields, field => typeof(Delegate).IsAssignableFrom(field.FieldType)); @@ -274,6 +284,71 @@ public sealed class UpdateFrameOrchestratorTests Assert.Equal(1, source.Split("PublishTime(", StringSplitOptions.None).Length - 1); } + [Fact] + public void ExtractedLiveObjectSource_PinsRetailAndRegisteredAdaptationOrder() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Update", + "LiveObjectFrameController.cs")); + + AssertAppearsInOrder( + source, + "_localPlayerFrame.AdvanceBeforeNetwork", + "_selectionInteractions?.DrainOutbound", + "_animations.Tick", + "_staticAnimations.Tick", + "_animationPresenter.Present", + "_equippedChildren.Tick", + "_staticAnimations.ProcessHooks", + "_effects.Tick"); + AssertAppearsInOrder( + source, + "_translucencyFades.AdvanceAll", + "_animationHooks.Drain", + "_entityEffects.RefreshLiveOwnerPoses", + "_particleSink.RefreshAttachedEmitters", + "_lights.Refresh", + "_particleVisibility.Apply", + "_particles.Tick", + "_scripts.Tick"); + AssertAppearsInOrder( + source, + "public void Reconcile()", + "_entityEffects.RefreshLiveOwnerPoses", + "_equippedChildren.Tick", + "_particleSink.RefreshAttachedEmitters", + "_lights.Refresh"); + + Assert.Equal(1, CountOccurrences(source, "_staticAnimations.Tick(")); + Assert.Equal(1, CountOccurrences(source, "_staticAnimations.ProcessHooks(")); + Assert.Equal(1, CountOccurrences(source, "_effects.Tick(")); + Assert.Equal(1, CountOccurrences(source, "_particles.Tick(")); + Assert.Equal(1, CountOccurrences(source, "_scripts.Tick(")); + } + + [Fact] + public void GameWindow_ComposesTheLiveFrameOwnersWithoutOwningTheirBodies() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.Contains("new AcDream.App.Update.LiveObjectFrameController(", source); + Assert.Contains("new AcDream.App.Update.LiveSpatialPresentationReconciler(", source); + Assert.Contains("new AcDream.App.World.RetailLiveFrameCoordinator(", source); + Assert.DoesNotContain("AdvanceLiveObjectRuntime", source, StringComparison.Ordinal); + Assert.DoesNotContain("ReconcileLiveObjectSpatialPresentation", source, + StringComparison.Ordinal); + Assert.DoesNotContain("ILiveAnimationPresentationContext", source, + StringComparison.Ordinal); + } + private static UpdateFrameOrchestrator Create( List calls, RecordingTeardown? teardown = null, @@ -432,4 +507,19 @@ public sealed class UpdateFrameOrchestratorTests throw new DirectoryNotFoundException("Could not find AcDream.slnx."); } + + private static void AssertAppearsInOrder(string source, params string[] markers) + { + int previous = -1; + foreach (string marker in markers) + { + int current = source.IndexOf(marker, previous + 1, StringComparison.Ordinal); + Assert.True(current >= 0, $"Missing source marker: {marker}"); + Assert.True(current > previous, $"Out-of-order source marker: {marker}"); + previous = current; + } + } + + private static int CountOccurrences(string source, string marker) => + source.Split(marker, StringSplitOptions.None).Length - 1; } From 0bc9fda9de03801853bfa19c8508bba4aac149e9 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 01:01:04 +0200 Subject: [PATCH 75/98] refactor(streaming): extract the update frame --- ...ndow-slice-6-update-frame-orchestration.md | 13 +- .../Input/LocalPlayerRuntimeState.cs | 22 + src/AcDream.App/Net/ILiveInWorldSource.cs | 6 + src/AcDream.App/Net/LiveSessionController.cs | 5 +- .../Physics/ILocalPlayerLandblockSource.cs | 6 + .../LiveEntityNetworkUpdateController.cs | 6 +- src/AcDream.App/Rendering/GameWindow.cs | 211 +------ .../Streaming/StreamingController.cs | 2 +- .../Streaming/StreamingFrameController.cs | 227 +++++++ .../StreamingOriginRecenterCoordinator.cs | 2 +- .../StreamingFrameControllerTests.cs | 556 ++++++++++++++++++ .../GameWindowLiveEntityCompositionTests.cs | 16 + .../World/UpdateFrameOrchestratorTests.cs | 24 + 13 files changed, 912 insertions(+), 184 deletions(-) create mode 100644 src/AcDream.App/Net/ILiveInWorldSource.cs create mode 100644 src/AcDream.App/Physics/ILocalPlayerLandblockSource.cs create mode 100644 src/AcDream.App/Streaming/StreamingFrameController.cs create mode 100644 tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md index 00790c44..d10905cf 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md @@ -18,7 +18,7 @@ never hidden behind a retry, delay, suppression flag, or reordered callback. - [x] B — extract the pre-network live-object presentation phase and the non-advancing post-network spatial reconciler; remove callbacks into `GameWindow` from `RetailLiveFrameCoordinator`. -- [ ] C — extract streaming-origin convergence, observer selection, dungeon +- [x] C — extract streaming-origin convergence, observer selection, dungeon collapse, streaming tick, and rescued-entity rebucketing. - [ ] D — extract dispatcher/raw-mouse/combat input sampling without changing movement-command or UI-capture semantics. @@ -50,6 +50,17 @@ and removed animation/runtime/projectile back-references to `GameWindow`. Release tests, the 2,732-test App suite, and the full 7,090-test Release suite (5 fixture/environment skips); all three independent reviews are clean. +Checkpoint C moved retained origin convergence, readiness, observer selection, +sealed-dungeon collapse, streaming publication, and persistent live-projection +rescue into `StreamingFrameController`. Production keeps streaming publication +ahead of input and the live object/network barrier, so a same-frame +CreateObject can enter the newly resident bucket. The focused 146-test Release +selection, 2,754-test App suite, and complete 7,112-test Release suite (5 +fixture/environment skips) pass. Login-before-position, portal hold, outdoor +and sealed/SeenOutside EnvCells, pending recenter, exact rescue rebucketing, +GUID deletion, and session reset are pinned; all three corrected-diff reviews +are clean. `GameWindow.cs` is now 8,569 raw lines. + ## 1. Outcome and non-goals At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one diff --git a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs index 02a1b2ad..57c4236c 100644 --- a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs +++ b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs @@ -28,3 +28,25 @@ internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource { public PlayerMovementController? Controller { get; set; } } + +internal interface ILocalPlayerModeSource +{ + bool IsPlayerMode { get; } + bool ChaseModeEverEntered { get; } +} + +/// +/// The one mutable local presentation-mode slot. Checkpoint F transfers write +/// ownership to PlayerModeController; streaming and camera owners only read it. +/// +internal sealed class LocalPlayerModeState : ILocalPlayerModeSource +{ + public bool IsPlayerMode { get; set; } + public bool ChaseModeEverEntered { get; set; } + + public void ResetSession() + { + IsPlayerMode = false; + ChaseModeEverEntered = false; + } +} diff --git a/src/AcDream.App/Net/ILiveInWorldSource.cs b/src/AcDream.App/Net/ILiveInWorldSource.cs new file mode 100644 index 00000000..48121eb7 --- /dev/null +++ b/src/AcDream.App/Net/ILiveInWorldSource.cs @@ -0,0 +1,6 @@ +namespace AcDream.App.Net; + +internal interface ILiveInWorldSource +{ + bool IsInWorld { get; } +} diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs index 02664eaa..1f956f21 100644 --- a/src/AcDream.App/Net/LiveSessionController.cs +++ b/src/AcDream.App/Net/LiveSessionController.cs @@ -158,7 +158,10 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations /// publication, exact-generation Tick, graceful replacement, and convergent /// teardown. Retail wire behavior remains inside WorldSession. /// -internal sealed class LiveSessionController : IDisposable, ILiveSessionFramePhase +internal sealed class LiveSessionController + : IDisposable, + ILiveSessionFramePhase, + ILiveInWorldSource { private sealed class SessionScope( WorldSession session, diff --git a/src/AcDream.App/Physics/ILocalPlayerLandblockSource.cs b/src/AcDream.App/Physics/ILocalPlayerLandblockSource.cs new file mode 100644 index 00000000..36026f3f --- /dev/null +++ b/src/AcDream.App/Physics/ILocalPlayerLandblockSource.cs @@ -0,0 +1,6 @@ +namespace AcDream.App.Physics; + +internal interface ILocalPlayerLandblockSource +{ + uint? LastKnownLandblockId { get; } +} diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index aaebdac4..96f9c50b 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -25,7 +25,8 @@ namespace AcDream.App.Physics; /// internal sealed class LiveEntityNetworkUpdateController : ILiveEntityNetworkUpdateSink, - ILiveEntitySameGenerationUpdateSink + ILiveEntitySameGenerationUpdateSink, + ILocalPlayerLandblockSource { private readonly LiveEntityRuntime _liveEntities; private readonly ClientObjectTable _objects; @@ -72,6 +73,9 @@ internal sealed class LiveEntityNetworkUpdateController internal uint? LastLivePlayerLandblockId => _authorityGate.LastLivePlayerLandblockId; + uint? ILocalPlayerLandblockSource.LastKnownLandblockId => + LastLivePlayerLandblockId; + public LiveEntityNetworkUpdateController( LiveEntityRuntime liveEntities, ClientObjectTable objects, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2f7b502f..b02100d8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -123,6 +123,7 @@ public sealed class GameWindow : IDisposable _landblockPresentationPipeline; private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Streaming.StreamingController? _streamingController; + private AcDream.App.Update.IStreamingFramePhase _streamingFrame = null!; private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9) @@ -531,7 +532,12 @@ public sealed class GameWindow : IDisposable } private AcDream.App.Rendering.ChaseCamera? _chaseCamera; private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera; - private bool _playerMode; + private readonly AcDream.App.Input.LocalPlayerModeState _localPlayerMode = new(); + private bool _playerMode + { + get => _localPlayerMode.IsPlayerMode; + set => _localPlayerMode.IsPlayerMode = value; + } private readonly AcDream.App.Input.LocalPlayerIdentityState _localPlayerIdentity = new(); private uint _playerServerGuid { @@ -661,18 +667,6 @@ public sealed class GameWindow : IDisposable private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new(); private int _liveCenterX => _liveWorldOrigin.CenterX; private int _liveCenterY => _liveWorldOrigin.CenterY; - // #192 (2026-07-09): true once the player's first canonical Position has - // been accepted — i.e. _liveCenterX/Y reflects a real, server-confirmed - // position, not the Holtburg startup placeholder. Renderability is not a - // prerequisite: a partial CreateObject may carry Position before Setup. - // Gates - // streaming via StreamingReadinessGate.ShouldStream so no landblock is ever - // built around a still-guessed center in live mode. See - // AcDream.Core.World.StreamingReadinessGate for the full mechanism this - // closes (stale-centered geometry built during the InWorld-but-not-yet- - // located window, applied late once its build finished). - private bool _liveCenterKnown => _liveWorldOrigin.IsKnown; - // K-fix1 (2026-04-26): cached at startup so per-frame branches are // single-flag reads instead of env-var lookups. True iff // ACDREAM_LIVE=1 was set when the window came up. @@ -699,7 +693,11 @@ public sealed class GameWindow : IDisposable // Latches true the first time chase mode becomes active. Used by // IsLiveModeWaitingForLogin to suppress the pre-login render gate // for subsequent fly-mode toggles. - private bool _chaseModeEverEntered; + private bool _chaseModeEverEntered + { + get => _localPlayerMode.ChaseModeEverEntered; + set => _localPlayerMode.ChaseModeEverEntered = value; + } /// /// Phase 6.6/6.7: server-guid → local WorldEntity lookup so @@ -2654,6 +2652,22 @@ public sealed class GameWindow : IDisposable // gated behind ACDREAM_LIVE=1 so the default run path is unchanged. _liveWorldOrigin.SetPlaceholder(centerX, centerY); _liveSessionController = new AcDream.App.Net.LiveSessionController(); + _streamingFrame = new AcDream.App.Streaming.StreamingFrameController( + _options.LiveMode, + _localPlayerMode, + _playerControllerSlot, + _liveSessionController, + _liveWorldOrigin, + _liveEntityNetworkUpdates!, + new AcDream.App.Streaming.FlyCameraStreamingObserverSource( + _cameraController!), + new AcDream.App.Streaming.PhysicsStreamingDungeonCellSource( + _physicsEngine), + _streamingOriginRecenter!, + _streamingController!, + new AcDream.App.Streaming.LiveProjectionRescueRebucketter( + _worldState, + _liveEntities)); var liveEffectFrame = new AcDream.App.Update.LiveEffectFrameController( _translucencyFades, _animationHookFrames!, @@ -2803,7 +2817,7 @@ public sealed class GameWindow : IDisposable } finally { - _playerMode = false; + _localPlayerMode.ResetSession(); _playerController = null; _playerHost = null; _chaseCamera = null; @@ -2811,7 +2825,6 @@ public sealed class GameWindow : IDisposable _playerMouseDeltaX = 0f; _rmbHeld = false; _autoRunActive = false; - _chaseModeEverEntered = false; _lastMovementTruthOutbound = null; _localPlayerShadow.Clear(); _spawnClaimRangeMemo = null; @@ -3836,169 +3849,9 @@ public sealed class GameWindow : IDisposable // reuse the same timestamp for animation hooks and the later drain. _scriptRunner?.PublishTime(frameTiming.ScriptTime); - // Phase A.1: advance the streaming controller FIRST so the initial - // landblocks are loaded into GpuWorldState before live-session - // CreateObject events drain. The earlier order (live tick first, - // streaming tick second) caused the initial CreateObject flood from - // login to land before any landblock was loaded; live projection placement - // is a no-op for unloaded landblocks, so all 40+ NPCs/weenies were - // silently dropped on the first frame and never rendered. - // - // K-fix1 (2026-04-26): skip streaming entirely while live mode is - // configured but the chase camera hasn't engaged yet — otherwise - // the orbit camera at startup centers on the hardcoded - // 0xA9B4 (Holtburg) and Holtburg landblocks render briefly - // until the player spawn arrives + auto-entry switches to chase. - // - // #106 gate-3 (2026-06-09): narrowed to PRE-LOGIN only. The original - // form also blocked the in-world window between EnterWorld and - // auto-entry, which deadlocked the spawn-ground entry hold: - // auto-entry waits for terrain under the spawn, terrain streaming - // waited for chase mode. Once the session is InWorld the player's - // landblock is known (the fly-camera else-if below was written for - // exactly this window) — stream it. Pre-login, streaming stays - // blocked, which is the hardcoded-Holtburg flash K-fix1 targeted. - // The world-geometry RENDER gate (IsLiveModeWaitingForLogin at the - // draw site) is unchanged — the pre-entry screen still shows sky - // only. - // Recenter cancellation/session-reset requests must keep converging - // even when ordinary streaming is temporarily gated off. This call is - // a no-op outside a pending teleport or session-boundary origin - // retirement transaction. - _streamingOriginRecenter?.Advance(); - - bool liveInWorld = _liveSessionController?.IsInWorld == true; - // #192: liveInWorld alone used to be sufficient here — but InWorld fires - // right after the login handshake, before the player's own spawn - // CreateObject (and hence the real center) has arrived. Streaming that - // ran in that gap baked landblock offsets from the still-placeholder - // _liveCenterX/Y (Holtburg), producing stale-positioned geometry applied - // late once its build finished. Require _liveCenterKnown too — see - // AcDream.Core.World.StreamingReadinessGate for the full mechanism. - if (_streamingController is not null && _cameraController is not null - && AcDream.Core.World.StreamingReadinessGate.ShouldStream( - LiveModeEnabled, _chaseModeEverEntered, liveInWorld, _liveCenterKnown)) - { - int observerCx = _liveCenterX; - int observerCy = _liveCenterY; - - if (_playerMode && _playerController is not null - && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace) - { - // Teleport hold (#135): the local player position is frozen at the - // PRE-teleport spot, expressed in the OLD center frame. The - // origin coordinator commits _liveCenterX/Y to the destination - // only after old-window presentation retirement converges; the - // streaming controller remains gated until that edge. Follow - // the active shared origin directly — the stale position-derived offset - // (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off - // the dungeon and trip ExitDungeonExpand, re-streaming the very - // neighbor window the pre-collapse just suppressed. Correct for an - // outdoor teleport too: pre-load the destination during the hold. - // - // NOTE: these assignments equal the observerCx/Cy defaults initialized - // above — the LOAD-BEARING effect of this branch is INHIBITING the - // position-derived offset in the else-if below while the player position - // is frozen, not the (redundant) assignment. Kept explicit for clarity. - observerCx = _liveCenterX; - observerCy = _liveCenterY; - } - else if (_playerMode && _playerController is not null) - { - // Player mode: follow the physics-resolved player position. - // The player walks via the local physics engine; the server - // doesn't echo back our autonomous moves, so _lastLivePlayer* - // stays at the login position. Compute the landblock from the - // controller's current world-space position instead. - var pp = _playerController.Position; - observerCx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); - observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); - } - else if (liveInWorld) - { - // Live, not yet in player mode: the login auto-entry hold, or a live - // fly-camera spectator. Follow the PLAYER's server-known landblock; if it - // hasn't arrived yet, KEEP the _liveCenterX/_liveCenterY default — which is - // the spawn/teleport recenter (the dungeon landblock at a dungeon login). - // - // #135 regression fix (2026-06-14): this MUST NOT fall through to the - // fly-camera projection below. During a dungeon-login hold the streaming is - // pre-collapsed onto the spawn landblock; a camera-derived observer far from - // it trips ExitDungeonExpand and unloads the dungeon before it can hydrate — - // the player is never placed and login hangs with no dungeon. Previously - // The last-player-landblock hint was once set by ANY entity, so a dungeon-local NPC - // kept this branch on the dungeon; once it was filtered to the player guid - // (line ~4507), a not-yet-arrived player UP dropped to the camera branch. - // The fly camera is the OFFLINE observer only. - if (_liveEntityNetworkUpdates?.LastLivePlayerLandblockId is { } lid) - { - observerCx = (int)((lid >> 24) & 0xFFu); - observerCy = (int)((lid >> 16) & 0xFFu); - } - // else: keep the _liveCenterX/_liveCenterY default (the spawn recenter). - } - else - { - // Offline: project the fly camera's world-space position back into - // landblock coordinates. OrbitCamera doesn't expose Position via - // ICamera, but FlyCamera.Position is always updated (even when the - // orbit camera is Active), so this is safe in both modes. - // Each landblock is 192 world units wide. - var camPos = _cameraController.Fly.Position; - observerCx = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f); - observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f); - } - - // Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell - // (indoor cell that doesn't see outside — the same predicate that kills - // the sun/sky, playerInsideCell below), collapse streaming to the single - // dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25 - // window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building - // interiors (cottage/inn) have SeenOutside cells, so they are NOT gated - // and keep their surrounding terrain. - // True only for a sealed indoor cell. Read the physics CurrCell's own - // SeenOutside (ObjCell.SeenOutside, set from the EnvCell dat flags) rather - // than the render registry: the registry lookup only succeeds AFTER the - // landblock FINALIZES (~tens of seconds for a 205-cell dungeon), which - // delayed the collapse and let the full 25×25 neighbor window churn in - // first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the - // moment the player is placed, so the collapse now engages at the snap. - // AP-36 / #145: during a teleport HOLD the player is unplaced and CurrCell is - // the frozen SOURCE cell. Suppress the source-cell gate so a teleport OUT of a - // dungeon follows the destination (the PortalSpace observer pin above) instead - // of staying pinned to the source dungeon — see DungeonStreamingGate. - bool isTeleportHold = - _playerController is { State: AcDream.App.Input.PlayerState.PortalSpace }; - var currCell = _physicsEngine.DataCache?.CellGraph.CurrCell; - bool currSealedDungeon = - currCell is AcDream.Core.World.Cells.EnvCell pcEnv && !pcEnv.SeenOutside; - var gate = AcDream.App.Streaming.DungeonStreamingGate.Compute( - isTeleportHold, currSealedDungeon, currCell?.Id ?? 0u); - bool insideDungeon = gate.InsideDungeon; - if (gate.ObserverLandblockKey is { } cellLb) - { - // Pin the collapse to the cell's OWN landblock (cell id high 16 bits), - // NOT the position-derived observer landblock. A dungeon's EnvCells sit - // at arbitrary world coords (the "ocean" placement) with negative local - // offsets, so floor(pp.Y/192) lands one landblock off — which collapses - // onto the WRONG landblock and unloads the real dungeon, nulling CurrCell - // and breaking the render (the Bug-A coordinate class). The cell id is the - // authoritative landblock. - observerCx = (int)((cellLb >> 8) & 0xFFu); - observerCy = (int)(cellLb & 0xFFu); - } - _streamingController.Tick(observerCx, observerCy, insideDungeon); - - // Re-inject persistent entities rescued from unloaded landblocks - // into the current center landblock (the one the observer is in). - var rescued = _worldState.DrainRescued(); - if (rescued.Count > 0) - { - uint centerLb = (uint)((observerCx << 24) | (observerCy << 16) | 0xFFFF); - foreach (var entity in rescued) - _liveEntities?.RebucketLiveEntity(entity.ServerGuid, centerLb); - } - } + // Streaming publishes before inbound CreateObject dispatch so newly + // accepted live projections can enter a resident bucket this frame. + _streamingFrame.Tick(); // Input callbacks feed the current object tick. Packets already read // by Core.Net remain queued until the retail object/physics phase has diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 0581647d..c746f644 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -16,7 +16,7 @@ namespace AcDream.App.Streaming; /// Threading: not thread-safe. All calls must happen on the render thread. /// /// -public sealed class StreamingController +public sealed class StreamingController : IStreamingFrameBackend { private sealed class OriginRecenterRetirement { diff --git a/src/AcDream.App/Streaming/StreamingFrameController.cs b/src/AcDream.App/Streaming/StreamingFrameController.cs new file mode 100644 index 00000000..d32525b2 --- /dev/null +++ b/src/AcDream.App/Streaming/StreamingFrameController.cs @@ -0,0 +1,227 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Update; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; +using AcDream.Core.World.Cells; + +namespace AcDream.App.Streaming; + +internal interface IStreamingOriginConvergence +{ + bool Advance(); +} + +internal interface IStreamingFrameBackend +{ + void Tick(int observerCx, int observerCy, bool insideDungeon = false); +} + +internal interface IOfflineStreamingObserverSource +{ + Vector3 Position { get; } +} + +internal sealed class FlyCameraStreamingObserverSource + : IOfflineStreamingObserverSource +{ + private readonly CameraController _camera; + + public FlyCameraStreamingObserverSource(CameraController camera) => + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + + public Vector3 Position => _camera.Fly.Position; +} + +internal readonly record struct StreamingDungeonCellSnapshot( + uint CellId, + bool IsSealedDungeon); + +internal interface IStreamingDungeonCellSource +{ + StreamingDungeonCellSnapshot Capture(); +} + +internal sealed class PhysicsStreamingDungeonCellSource + : IStreamingDungeonCellSource +{ + private readonly PhysicsEngine _physics; + + public PhysicsStreamingDungeonCellSource(PhysicsEngine physics) => + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + + public StreamingDungeonCellSnapshot Capture() + { + ObjCell? cell = _physics.DataCache?.CellGraph.CurrCell; + return new StreamingDungeonCellSnapshot( + cell?.Id ?? 0u, + cell is EnvCell envCell && !envCell.SeenOutside); + } +} + +internal interface ILiveProjectionRescueRebucketter +{ + void RebucketAll(int observerCx, int observerCy); +} + +internal sealed class LiveProjectionRescueRebucketter + : ILiveProjectionRescueRebucketter +{ + private readonly GpuWorldState _worldState; + private readonly LiveEntityRuntime _liveEntities; + + public LiveProjectionRescueRebucketter( + GpuWorldState worldState, + LiveEntityRuntime liveEntities) + { + _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + } + + public void RebucketAll(int observerCx, int observerCy) + { + List rescued = _worldState.DrainRescued(); + if (rescued.Count == 0) + return; + + uint centerLandblock = + ((uint)observerCx << 24) | ((uint)observerCy << 16) | 0xFFFFu; + foreach (WorldEntity entity in rescued) + _liveEntities.RebucketLiveEntity(entity.ServerGuid, centerLandblock); + } +} + +/// +/// Owns the complete pre-input streaming phase of one host update: retained +/// origin convergence, readiness, observer selection, sealed-dungeon collapse, +/// completion publication, and persistent-projection rescue. +/// +internal sealed class StreamingFrameController : IStreamingFramePhase +{ + private const float LandblockLength = 192f; + + private readonly bool _liveModeEnabled; + private readonly ILocalPlayerModeSource _playerMode; + private readonly ILocalPlayerControllerSource _playerController; + private readonly ILiveInWorldSource _session; + private readonly LiveWorldOriginState _origin; + private readonly ILocalPlayerLandblockSource _playerLandblock; + private readonly IOfflineStreamingObserverSource _offlineObserver; + private readonly IStreamingDungeonCellSource _dungeonCell; + private readonly IStreamingOriginConvergence _originConvergence; + private readonly IStreamingFrameBackend _streaming; + private readonly ILiveProjectionRescueRebucketter _rescues; + + public StreamingFrameController( + bool liveModeEnabled, + ILocalPlayerModeSource playerMode, + ILocalPlayerControllerSource playerController, + ILiveInWorldSource session, + LiveWorldOriginState origin, + ILocalPlayerLandblockSource playerLandblock, + IOfflineStreamingObserverSource offlineObserver, + IStreamingDungeonCellSource dungeonCell, + IStreamingOriginConvergence originConvergence, + IStreamingFrameBackend streaming, + ILiveProjectionRescueRebucketter rescues) + { + _liveModeEnabled = liveModeEnabled; + _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); + _playerController = playerController + ?? throw new ArgumentNullException(nameof(playerController)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _playerLandblock = playerLandblock + ?? throw new ArgumentNullException(nameof(playerLandblock)); + _offlineObserver = offlineObserver + ?? throw new ArgumentNullException(nameof(offlineObserver)); + _dungeonCell = dungeonCell ?? throw new ArgumentNullException(nameof(dungeonCell)); + _originConvergence = originConvergence + ?? throw new ArgumentNullException(nameof(originConvergence)); + _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming)); + _rescues = rescues ?? throw new ArgumentNullException(nameof(rescues)); + } + + public void Tick() + { + // A pending teleport/session origin retirement must converge even while + // ordinary streaming is gated. This remains the first streaming edge. + _originConvergence.Advance(); + + bool liveInWorld = _session.IsInWorld; + // #192: InWorld precedes the player's first authoritative Position. + // Starting an async build against the placeholder origin bakes stale + // offsets that can publish after the real origin arrives, so live + // startup requires both edges. A prior chase lifetime remains open for + // intentional later fly-mode observation, exactly as before extraction. + if (!StreamingReadinessGate.ShouldStream( + _liveModeEnabled, + _playerMode.ChaseModeEverEntered, + liveInWorld, + _origin.IsKnown)) + { + return; + } + + (int observerCx, int observerCy) = SelectObserver(liveInWorld); + PlayerMovementController? controller = _playerController.Controller; + bool isTeleportHold = controller is { State: PlayerState.PortalSpace }; + StreamingDungeonCellSnapshot currentCell = _dungeonCell.Capture(); + DungeonGateResult gate = DungeonStreamingGate.Compute( + isTeleportHold, + currentCell.IsSealedDungeon, + currentCell.CellId); + if (gate.ObserverLandblockKey is { } cellLandblock) + { + observerCx = (int)((cellLandblock >> 8) & 0xFFu); + observerCy = (int)(cellLandblock & 0xFFu); + } + + _streaming.Tick(observerCx, observerCy, gate.InsideDungeon); + _rescues.RebucketAll(observerCx, observerCy); + } + + private (int X, int Y) SelectObserver(bool liveInWorld) + { + int observerCx = _origin.CenterX; + int observerCy = _origin.CenterY; + PlayerMovementController? controller = _playerController.Controller; + + if (_playerMode.IsPlayerMode + && controller is { State: PlayerState.PortalSpace }) + { + // The player root remains frozen in the source coordinate frame. + // The accepted shared origin already names the destination. + return (observerCx, observerCy); + } + + if (_playerMode.IsPlayerMode && controller is not null) + { + Vector3 position = controller.Position; + return ( + observerCx + (int)Math.Floor(position.X / LandblockLength), + observerCy + (int)Math.Floor(position.Y / LandblockLength)); + } + + if (liveInWorld) + { + if (_playerLandblock.LastKnownLandblockId is { } landblockId) + { + return ( + (int)((landblockId >> 24) & 0xFFu), + (int)((landblockId >> 16) & 0xFFu)); + } + + return (observerCx, observerCy); + } + + Vector3 cameraPosition = _offlineObserver.Position; + return ( + observerCx + (int)Math.Floor(cameraPosition.X / LandblockLength), + observerCy + (int)Math.Floor(cameraPosition.Y / LandblockLength)); + } +} diff --git a/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs b/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs index 73fd4c40..c43fa647 100644 --- a/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs +++ b/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs @@ -9,7 +9,7 @@ namespace AcDream.App.Streaming; /// while a session boundary releases the controller without recentering. The /// transaction remains pending across frames when an owner teardown needs retry. /// -internal sealed class StreamingOriginRecenterCoordinator +internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConvergence { private readonly record struct Request( int DestinationX, diff --git a/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs new file mode 100644 index 00000000..a56c88f8 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs @@ -0,0 +1,556 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Net; +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.Terrain; +using AcDream.Core.World; +using AcDream.Core.World.Cells; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Streaming; + +public sealed class StreamingFrameControllerTests +{ + [Fact] + public void LivePreLogin_AdvancesOriginConvergenceButSuppressesStreaming() + { + var fixture = new Fixture(liveMode: true); + + fixture.Controller.Tick(); + + Assert.Equal(1, fixture.Convergence.Advances); + Assert.Empty(fixture.Streaming.Calls); + Assert.Equal(0, fixture.Dungeon.Captures); + Assert.Empty(fixture.Rescues.Calls); + } + + [Fact] + public void LiveInWorldWithoutAuthoritativeOrigin_RemainsGated() + { + var fixture = new Fixture(liveMode: true); + fixture.Session.IsInWorld = true; + + fixture.Controller.Tick(); + + Assert.Equal(1, fixture.Convergence.Advances); + Assert.Empty(fixture.Streaming.Calls); + } + + [Fact] + public void ChaseLifetimeAfterEntry_AllowsConvergenceWithNoCurrentSession() + { + var fixture = new Fixture(liveMode: true); + fixture.Mode.ChaseModeEverEntered = true; + + fixture.Controller.Tick(); + + Assert.Equal([(10, 20, false)], fixture.Streaming.Calls); + } + + [Fact] + public void ConvergenceCompletesBeforeObserverSelection() + { + var fixture = new Fixture(); + fixture.Convergence.OnAdvance = () => fixture.Origin.Recenter(30, 40); + + fixture.Controller.Tick(); + + Assert.Equal([(30, 40, false)], fixture.Streaming.Calls); + } + + [Fact] + public void PendingConvergence_StillTicksBackendBeforeRescueSoBackendOwnsItsGate() + { + var calls = new List(); + var fixture = new Fixture(); + fixture.Convergence.AdvanceResult = false; + fixture.Streaming.OnTick = (_, _, _) => calls.Add("backend"); + fixture.Rescues.OnRebucket = (_, _) => calls.Add("rescues"); + + fixture.Controller.Tick(); + + Assert.Equal(["backend", "rescues"], calls); + Assert.Equal([(10, 20, false)], fixture.Streaming.Calls); + Assert.Equal([(10, 20)], fixture.Rescues.Calls); + } + + [Fact] + public void LivePrePlayerMode_UsesAuthoritativePlayerLandblock() + { + var fixture = new Fixture(liveMode: true, initializeOrigin: true); + fixture.Session.IsInWorld = true; + fixture.PlayerLandblock.LastKnownLandblockId = 0xA1B20001u; + fixture.Offline.Position = new Vector3(9000f, 9000f, 0f); + + fixture.Controller.Tick(); + + Assert.Equal([(0xA1, 0xB2, false)], fixture.Streaming.Calls); + Assert.Equal(0, fixture.Offline.Reads); + } + + [Fact] + public void LivePrePlayerModeWithoutPositionHint_StaysOnAcceptedOrigin() + { + var fixture = new Fixture(liveMode: true, initializeOrigin: true); + fixture.Session.IsInWorld = true; + fixture.Offline.Position = new Vector3(9000f, 9000f, 0f); + + fixture.Controller.Tick(); + + Assert.Equal([(10, 20, false)], fixture.Streaming.Calls); + Assert.Equal(0, fixture.Offline.Reads); + } + + [Fact] + public void PlayerModeWithoutController_InWorldStillUsesAuthoritativePlayerHint() + { + var fixture = new Fixture(liveMode: true, initializeOrigin: true); + fixture.Mode.IsPlayerMode = true; + fixture.Session.IsInWorld = true; + fixture.PlayerLandblock.LastKnownLandblockId = 0xC95B0001u; + + fixture.Controller.Tick(); + + Assert.Equal([(0xC9, 0x5B, false)], fixture.Streaming.Calls); + Assert.Equal(0, fixture.Offline.Reads); + } + + [Fact] + public void PlayerModeWithoutController_OfflineFallsBackToFlyObserver() + { + var fixture = new Fixture(); + fixture.Mode.IsPlayerMode = true; + fixture.Offline.Position = new Vector3(192f, 384f, 0f); + + fixture.Controller.Tick(); + + Assert.Equal([(11, 22, false)], fixture.Streaming.Calls); + Assert.Equal(1, fixture.Offline.Reads); + } + + [Fact] + public void PlayerMode_UsesPhysicsResolvedPositionIncludingNegativeFloor() + { + var fixture = new Fixture(initializeOrigin: true); + fixture.Mode.IsPlayerMode = true; + var player = new PlayerMovementController(new PhysicsEngine()); + player.SetPosition( + new Vector3(383f, -0.01f, 0f), + 0x0A140001u, + new Vector3(1f, 1f, 0f)); + fixture.Player.Controller = player; + + fixture.Controller.Tick(); + + Assert.Equal([(11, 19, false)], fixture.Streaming.Calls); + } + + [Fact] + public void PortalSpace_PinsDestinationOriginAndSuppressesFrozenSourceDungeon() + { + var fixture = new Fixture(initializeOrigin: true); + fixture.Mode.IsPlayerMode = true; + var player = new PlayerMovementController(new PhysicsEngine()) + { + State = PlayerState.PortalSpace, + }; + player.SetPosition( + new Vector3(1000f, -1000f, 0f), + 0x0A140001u, + new Vector3(1f, 1f, 0f)); + fixture.Player.Controller = player; + fixture.Dungeon.Snapshot = new StreamingDungeonCellSnapshot( + 0xAABB0001u, + IsSealedDungeon: true); + + fixture.Controller.Tick(); + + Assert.Equal([(10, 20, false)], fixture.Streaming.Calls); + } + + [Fact] + public void SealedDungeon_PinsObserverToCurrentCellsOwnLandblock() + { + var fixture = new Fixture(initializeOrigin: true); + fixture.Mode.IsPlayerMode = true; + var player = new PlayerMovementController(new PhysicsEngine()); + player.SetPosition( + new Vector3(1000f, -1000f, 0f), + 0x0A140001u, + new Vector3(1f, 1f, 0f)); + fixture.Player.Controller = player; + fixture.Dungeon.Snapshot = new StreamingDungeonCellSnapshot( + 0xAABB0001u, + IsSealedDungeon: true); + + fixture.Controller.Tick(); + + Assert.Equal([(0xAA, 0xBB, true)], fixture.Streaming.Calls); + } + + [Fact] + public void OfflineMode_UsesFlyCameraRelativeToPlaceholderOrigin() + { + var fixture = new Fixture(); + fixture.Offline.Position = new Vector3(384f, -0.01f, 0f); + + fixture.Controller.Tick(); + + Assert.Equal([(12, 19, false)], fixture.Streaming.Calls); + Assert.Equal(1, fixture.Offline.Reads); + } + + [Fact] + public void StreamingBackendTickPrecedesRescueRebucketAtSameObserver() + { + var calls = new List(); + var fixture = new Fixture(); + fixture.Streaming.OnTick = (_, _, _) => calls.Add("streaming"); + fixture.Rescues.OnRebucket = (_, _) => calls.Add("rescues"); + + fixture.Controller.Tick(); + + Assert.Equal(["streaming", "rescues"], calls); + Assert.Equal([(10, 20)], fixture.Rescues.Calls); + } + + [Fact] + public void PhysicsDungeonCellSource_MapsMissingOutdoorAndSealedCells() + { + var physics = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + var source = new PhysicsStreamingDungeonCellSource(physics); + + Assert.Equal(default, source.Capture()); + + var heights = new byte[81]; + var heightTable = new float[256]; + physics.DataCache.CellGraph.RegisterTerrain( + 0xAABB0000u, + new TerrainSurface(heights, heightTable), + Vector3.Zero); + physics.UpdatePlayerCurrCell(0xAABB0001u); + + Assert.Equal( + new StreamingDungeonCellSnapshot(0xAABB0001u, IsSealedDungeon: false), + source.Capture()); + + var outsideVisibleCell = new AcDream.Core.World.Cells.EnvCell( + 0xCCDD0100u, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector3.Zero, + Vector3.One, + Array.Empty(), + Array.Empty(), + seenOutside: true, + containmentBsp: null); + physics.DataCache.CellGraph.Add(outsideVisibleCell); + physics.UpdatePlayerCurrCell(outsideVisibleCell.Id); + + Assert.Equal( + new StreamingDungeonCellSnapshot( + outsideVisibleCell.Id, + IsSealedDungeon: false), + source.Capture()); + + var sealedCell = new AcDream.Core.World.Cells.EnvCell( + 0xCCDD0101u, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector3.Zero, + Vector3.One, + Array.Empty(), + Array.Empty(), + seenOutside: false, + containmentBsp: null); + physics.DataCache.CellGraph.Add(sealedCell); + physics.UpdatePlayerCurrCell(sealedCell.Id); + + Assert.Equal( + new StreamingDungeonCellSnapshot(sealedCell.Id, IsSealedDungeon: true), + source.Capture()); + } + + [Fact] + public void LiveProjectionRescueRebucketter_RebucketsIntoExactObserverLandblock() + { + const uint guid = 0x50000031u; + const uint sourceLandblock = 0x0101FFFFu; + const uint sourceCell = 0x01010001u; + var state = new GpuWorldState(); + state.AddLandblock(EmptyLandblock(sourceLandblock)); + var runtime = Runtime(state); + runtime.RegisterLiveEntity(Spawn(guid, sourceCell)); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, + sourceCell, + id => Entity(id, guid))!; + state.MarkPersistent(guid); + state.RemoveLandblock(sourceLandblock); + Assert.Equal(1, state.PendingRescueCount); + + new LiveProjectionRescueRebucketter(state, runtime).RebucketAll(0xAA, 0xBB); + + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + Assert.Same(entity, record.WorldEntity); + Assert.Equal(0xAABBFFFFu, record.CanonicalLandblockId); + Assert.Equal(0, state.PendingRescueCount); + Assert.Equal(1, state.PendingLiveEntityCount); + } + + [Fact] + public void LiveProjectionRescueRebucketter_IgnoresEmptyAndDeletedRescues() + { + const uint guid = 0x50000032u; + const uint sourceLandblock = 0x0101FFFFu; + const uint sourceCell = 0x01010001u; + var state = new GpuWorldState(); + var runtime = Runtime(state); + var adapter = new LiveProjectionRescueRebucketter(state, runtime); + + adapter.RebucketAll(0xAA, 0xBB); + Assert.Equal(0, state.PendingLiveEntityCount); + + state.AddLandblock(EmptyLandblock(sourceLandblock)); + runtime.RegisterLiveEntity(Spawn(guid, sourceCell)); + runtime.MaterializeLiveEntity(guid, sourceCell, id => Entity(id, guid)); + state.MarkPersistent(guid); + state.RemoveLandblock(sourceLandblock); + Assert.Equal(1, state.PendingRescueCount); + runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false); + + adapter.RebucketAll(0xAA, 0xBB); + + Assert.Equal(0, state.PendingRescueCount); + Assert.Equal(0, state.PendingLiveEntityCount); + Assert.Empty(state.Entities); + } + + [Fact] + public void StreamingPublicationMakesSameFrameInboundProjectionResident() + { + const uint landblock = 0x0A14FFFFu; + const uint cell = 0x0A140001u; + const uint guid = 0x70000041u; + var state = new GpuWorldState(); + var outbox = new Queue(); + outbox.Enqueue(new LandblockStreamResult.Loaded( + landblock, + LandblockStreamTier.Near, + EmptyLandblock(landblock), + new LandblockMeshData(Array.Empty(), Array.Empty()))); + var streaming = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: max => Drain(outbox, max), + applyTerrain: static (_, _) => { }, + state, + nearRadius: 0, + farRadius: 0); + var fixture = new Fixture(streamingBackend: streaming); + + fixture.Controller.Tick(); + Assert.True(state.IsLoaded(landblock)); + + var runtime = Runtime(state); + runtime.RegisterLiveEntity(Spawn(guid, cell)); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, + cell, + id => Entity(id, guid))!; + + Assert.Same(entity, Assert.Single(state.Entities)); + Assert.Equal(0, state.PendingLiveEntityCount); + } + + [Fact] + public void SessionResetClosesChaseLifetimeOriginAndPlayerHintBeforeNextTick() + { + var fixture = new Fixture(liveMode: true, initializeOrigin: true); + fixture.Mode.IsPlayerMode = true; + fixture.Mode.ChaseModeEverEntered = true; + fixture.Session.IsInWorld = true; + fixture.PlayerLandblock.LastKnownLandblockId = 0xAABB0001u; + + fixture.Mode.ResetSession(); + fixture.Origin.Reset(); + fixture.Session.IsInWorld = false; + fixture.PlayerLandblock.LastKnownLandblockId = null; + fixture.Controller.Tick(); + + Assert.Equal(1, fixture.Convergence.Advances); + Assert.Empty(fixture.Streaming.Calls); + Assert.Empty(fixture.Rescues.Calls); + Assert.False(fixture.Mode.IsPlayerMode); + Assert.False(fixture.Mode.ChaseModeEverEntered); + Assert.False(fixture.Origin.IsKnown); + } + + private sealed class Fixture + { + internal Fixture( + bool liveMode = false, + bool initializeOrigin = false, + IStreamingFrameBackend? streamingBackend = null) + { + Origin.SetPlaceholder(10, 20); + if (initializeOrigin) + Assert.True(Origin.TryInitialize(10, 20)); + Controller = new StreamingFrameController( + liveMode, + Mode, + Player, + Session, + Origin, + PlayerLandblock, + Offline, + Dungeon, + Convergence, + streamingBackend ?? Streaming, + Rescues); + } + + internal LocalPlayerModeState Mode { get; } = new(); + internal LocalPlayerControllerSlot Player { get; } = new(); + internal SessionSource Session { get; } = new(); + internal LiveWorldOriginState Origin { get; } = new(); + internal PlayerLandblockSource PlayerLandblock { get; } = new(); + internal OfflineObserverSource Offline { get; } = new(); + internal DungeonCellSource Dungeon { get; } = new(); + internal OriginConvergence Convergence { get; } = new(); + internal StreamingBackend Streaming { get; } = new(); + internal RescueRebucketter Rescues { get; } = new(); + internal StreamingFrameController Controller { get; } + } + + private sealed class SessionSource : ILiveInWorldSource + { + public bool IsInWorld { get; set; } + } + + private sealed class PlayerLandblockSource : ILocalPlayerLandblockSource + { + public uint? LastKnownLandblockId { get; set; } + } + + private sealed class OfflineObserverSource : IOfflineStreamingObserverSource + { + private Vector3 _position; + internal int Reads { get; private set; } + public Vector3 Position + { + get + { + Reads++; + return _position; + } + set => _position = value; + } + } + + private sealed class DungeonCellSource : IStreamingDungeonCellSource + { + internal int Captures { get; private set; } + internal StreamingDungeonCellSnapshot Snapshot { get; set; } + public StreamingDungeonCellSnapshot Capture() + { + Captures++; + return Snapshot; + } + } + + private sealed class OriginConvergence : IStreamingOriginConvergence + { + internal int Advances { get; private set; } + internal Action? OnAdvance { get; set; } + internal bool AdvanceResult { get; set; } = true; + public bool Advance() + { + Advances++; + OnAdvance?.Invoke(); + return AdvanceResult; + } + } + + private sealed class StreamingBackend : IStreamingFrameBackend + { + internal List<(int X, int Y, bool Dungeon)> Calls { get; } = []; + internal Action? OnTick { get; set; } + public void Tick(int observerCx, int observerCy, bool insideDungeon = false) + { + Calls.Add((observerCx, observerCy, insideDungeon)); + OnTick?.Invoke(observerCx, observerCy, insideDungeon); + } + } + + private sealed class RescueRebucketter : ILiveProjectionRescueRebucketter + { + internal List<(int X, int Y)> Calls { get; } = []; + internal Action? OnRebucket { get; set; } + public void RebucketAll(int observerCx, int observerCy) + { + Calls.Add((observerCx, observerCy)); + OnRebucket?.Invoke(observerCx, observerCy); + } + } + + private static LiveEntityRuntime Runtime(GpuWorldState state) => + new(state, new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + + private static LoadedLandblock EmptyLandblock(uint landblock) => + new(landblock, new LandBlock(), Array.Empty()); + + private static WorldEntity Entity(uint id, uint guid) => new() + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) + { + var position = new CreateObject.ServerPosition( + cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "streaming frame fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1); + } + + private static IReadOnlyList Drain( + Queue queue, + int max) + { + var drained = new List(max); + while (drained.Count < max && queue.TryDequeue(out LandblockStreamResult? result)) + drained.Add(result); + return drained; + } +} diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs index 52460ba3..498d6694 100644 --- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -80,6 +80,7 @@ public sealed class GameWindowLiveEntityCompositionTests [InlineData(typeof(AcDream.App.Update.LiveObjectFrameController))] [InlineData(typeof(AcDream.App.Update.LiveEffectFrameController))] [InlineData(typeof(AcDream.App.Update.LiveSpatialPresentationReconciler))] + [InlineData(typeof(AcDream.App.Streaming.StreamingFrameController))] [InlineData(typeof(LiveEntityAnimationPresenter))] [InlineData(typeof(LiveAnimationPresentationContext))] [InlineData(typeof(StaticLiveRootCommitter))] @@ -106,6 +107,7 @@ public sealed class GameWindowLiveEntityCompositionTests typeof(AcDream.App.Update.LiveObjectFrameController), typeof(AcDream.App.Update.LiveEffectFrameController), typeof(AcDream.App.Update.LiveSpatialPresentationReconciler), + typeof(AcDream.App.Streaming.StreamingFrameController), typeof(LiveEntityAnimationScheduler), typeof(LiveEntityAnimationPresenter), typeof(LiveAnimationPresentationContext), @@ -167,6 +169,20 @@ public sealed class GameWindowLiveEntityCompositionTests Assert.Contains("new AcDream.App.Physics.DatProjectileSetupResolver", source); } + [Fact] + public void SessionReset_ClosesEveryStreamingReadinessOwner() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs")); + + Assert.Contains("_localPlayerMode.ResetSession();", source, StringComparison.Ordinal); + Assert.Contains( + "_liveEntityNetworkUpdates?.ResetSessionState();", + source, + StringComparison.Ordinal); + Assert.Contains("_liveWorldOrigin.Reset();", source, StringComparison.Ordinal); + } + private static string FindRepoRoot() { DirectoryInfo? directory = new(AppContext.BaseDirectory); diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 64e35436..770f334d 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -349,6 +349,30 @@ public sealed class UpdateFrameOrchestratorTests StringComparison.Ordinal); } + [Fact] + public void GameWindow_DelegatesTheCompleteStreamingFrameBody() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.Contains("new AcDream.App.Streaming.StreamingFrameController(", source); + Assert.Equal(1, CountOccurrences(source, "_streamingFrame.Tick();")); + AssertAppearsInOrder( + source, + "_streamingFrame.Tick();", + "_inputDispatcher?.Tick();", + "_liveFrameCoordinator.Tick(frameDelta);"); + Assert.DoesNotContain("_streamingController.Tick(observerCx", source, + StringComparison.Ordinal); + Assert.DoesNotContain("DungeonStreamingGate.Compute", source, + StringComparison.Ordinal); + Assert.DoesNotContain("DrainRescued()", source, StringComparison.Ordinal); + } + private static UpdateFrameOrchestrator Create( List calls, RecordingTeardown? teardown = null, From c5570383533b89658dd93688f794dcad09d462b3 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 01:38:40 +0200 Subject: [PATCH 76/98] refactor(input): extract the gameplay frame --- ...ndow-slice-6-update-frame-orchestration.md | 15 +- .../Combat/CombatAttackController.cs | 106 ++- .../Combat/CombatAttackTargetSource.cs | 116 ++++ .../Combat/LiveCombatAttackOperations.cs | 180 ++++++ .../Input/DispatcherMovementInputSource.cs | 83 +++ .../Input/GameplayInputFrameController.cs | 87 +++ src/AcDream.App/Input/InputCaptureSources.cs | 74 +++ .../Input/LocalPlayerOutboundController.cs | 15 +- src/AcDream.App/Input/MouseLookController.cs | 262 ++++++++ .../MovementTruthDiagnosticController.cs | 161 +++++ .../Input/RetailLocalPlayerFrameController.cs | 10 +- src/AcDream.App/Input/SilkMouseSource.cs | 50 +- src/AcDream.App/Net/ILiveInWorldSource.cs | 5 + src/AcDream.App/Net/LiveSessionController.cs | 3 +- .../LiveEntityNetworkUpdateController.cs | 10 +- src/AcDream.App/Rendering/GameWindow.cs | 610 ++++-------------- .../Input/InputDispatcher.cs | 6 +- .../Combat/CombatAttackTargetSourceTests.cs | 209 ++++++ .../Combat/LiveCombatAttackOperationsTests.cs | 151 +++++ .../DispatcherMovementInputSourceTests.cs | 152 +++++ .../GameplayInputFrameControllerTests.cs | 194 ++++++ .../Input/MouseLookControllerTests.cs | 309 +++++++++ .../RetailLocalPlayerFrameControllerTests.cs | 27 +- .../World/UpdateFrameOrchestratorTests.cs | 157 ++++- 24 files changed, 2433 insertions(+), 559 deletions(-) create mode 100644 src/AcDream.App/Combat/CombatAttackTargetSource.cs create mode 100644 src/AcDream.App/Combat/LiveCombatAttackOperations.cs create mode 100644 src/AcDream.App/Input/DispatcherMovementInputSource.cs create mode 100644 src/AcDream.App/Input/GameplayInputFrameController.cs create mode 100644 src/AcDream.App/Input/InputCaptureSources.cs create mode 100644 src/AcDream.App/Input/MouseLookController.cs create mode 100644 src/AcDream.App/Input/MovementTruthDiagnosticController.cs create mode 100644 tests/AcDream.App.Tests/Combat/CombatAttackTargetSourceTests.cs create mode 100644 tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs create mode 100644 tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs create mode 100644 tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md index d10905cf..8388e1d8 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md @@ -20,7 +20,7 @@ never hidden behind a retry, delay, suppression flag, or reordered callback. `GameWindow` from `RetailLiveFrameCoordinator`. - [x] C — extract streaming-origin convergence, observer selection, dungeon collapse, streaming tick, and rescued-entity rebucketing. -- [ ] D — extract dispatcher/raw-mouse/combat input sampling without changing +- [x] D — extract dispatcher/raw-mouse/combat input sampling without changing movement-command or UI-capture semantics. - [ ] E — extract the complete local-player teleport owner and wire it between the existing post-network liveness and player-mode auto-entry phases. @@ -61,6 +61,19 @@ and sealed/SeenOutside EnvCells, pending recenter, exact rescue rebucketing, GUID deletion, and session reset are pinned; all three corrected-diff reviews are clean. `GameWindow.cs` is now 8,569 raw lines. +Checkpoint D moved semantic hold delivery, movement sampling, autorun, +instant mouse-look, cursor capture, raw filtering/idle handling, and combat +intent into `GameplayInputFrameController` and focused typed owners. Devtools +keyboard capture pauses all gameplay movement without clearing the autorun +latch; retained chat suppresses held keys while autorun continues. Every +focus, teleport, camera/player-mode, reset, and shutdown boundary now releases +mouse-look through one lifecycle path and clears stale RMB orbit state. +Production combat requests, automatic hostile selection, UI-capture probes, +and movement-truth diagnostics no longer retain callbacks into `GameWindow`. +The 2,787-test App suite and complete 7,145-test Release suite pass (5 +fixture/environment skips); all three corrected-diff reviews are clean. +`GameWindow.cs` is now 8,205 raw lines. + ## 1. Outcome and non-goals At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one diff --git a/src/AcDream.App/Combat/CombatAttackController.cs b/src/AcDream.App/Combat/CombatAttackController.cs index 047aa8b2..34847e89 100644 --- a/src/AcDream.App/Combat/CombatAttackController.cs +++ b/src/AcDream.App/Combat/CombatAttackController.cs @@ -4,6 +4,56 @@ using AcDream.UI.Abstractions.Input; namespace AcDream.App.Combat; +internal interface ICombatAttackOperations +{ + bool CanStartAttack(); + void PrepareAttackRequest(); + bool SendAttack(AttackHeight height, float power); + void SendCancelAttack(); + bool IsDualWield { get; } + bool PlayerReadyForAttack { get; } + bool AutoRepeatAttack { get; } +} + +internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations +{ + private readonly Func _canStartAttack; + private readonly Action _prepareAttackRequest; + private readonly Func _sendAttack; + private readonly Action _sendCancelAttack; + private readonly Func _isDualWield; + private readonly Func _playerReadyForAttack; + private readonly Func _autoRepeatAttack; + + public DelegateCombatAttackOperations( + Func canStartAttack, + Func sendAttack, + Action? prepareAttackRequest, + Action? sendCancelAttack, + Func? isDualWield, + Func? playerReadyForAttack, + Func? autoRepeatAttack) + { + _canStartAttack = canStartAttack + ?? throw new ArgumentNullException(nameof(canStartAttack)); + _sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack)); + _prepareAttackRequest = prepareAttackRequest ?? (() => { }); + _sendCancelAttack = sendCancelAttack ?? (() => { }); + _isDualWield = isDualWield ?? (() => false); + _playerReadyForAttack = playerReadyForAttack ?? (() => true); + _autoRepeatAttack = autoRepeatAttack ?? (() => false); + } + + public bool CanStartAttack() => _canStartAttack(); + public void PrepareAttackRequest() => _prepareAttackRequest(); + public bool SendAttack(AttackHeight height, float power) => + _sendAttack(height, power); + public void SendCancelAttack() => _sendCancelAttack(); + public bool IsDualWield => _isDualWield(); + public bool PlayerReadyForAttack => _playerReadyForAttack(); + public bool AutoRepeatAttack => _autoRepeatAttack(); +} + /// /// Owns retail's basic-combat attack request and power-bar state machine. /// UI buttons and keyboard actions both enter through @@ -26,13 +76,7 @@ public sealed class CombatAttackController : IDisposable public const float DesiredPowerStep = 1f / 6f; private readonly CombatState _combat; - private readonly Func _canStartAttack; - private readonly Action _prepareAttackRequest; - private readonly Func _sendAttack; - private readonly Action _sendCancelAttack; - private readonly Func _isDualWield; - private readonly Func _playerReadyForAttack; - private readonly Func _autoRepeatAttack; + private readonly ICombatAttackOperations _operations; private readonly Func _now; private bool _buildInProgress; @@ -57,15 +101,27 @@ public sealed class CombatAttackController : IDisposable Func? playerReadyForAttack = null, Func? autoRepeatAttack = null, Func? now = null) + : this( + combat, + new DelegateCombatAttackOperations( + canStartAttack, + sendAttack, + prepareAttackRequest, + sendCancelAttack, + isDualWield, + playerReadyForAttack, + autoRepeatAttack), + now) + { + } + + internal CombatAttackController( + CombatState combat, + ICombatAttackOperations operations, + Func? now = null) { _combat = combat ?? throw new ArgumentNullException(nameof(combat)); - _canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack)); - _prepareAttackRequest = prepareAttackRequest ?? (() => { }); - _sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack)); - _sendCancelAttack = sendCancelAttack ?? (() => { }); - _isDualWield = isDualWield ?? (() => false); - _playerReadyForAttack = playerReadyForAttack ?? (() => true); - _autoRepeatAttack = autoRepeatAttack ?? (() => false); + _operations = operations ?? throw new ArgumentNullException(nameof(operations)); _now = now ?? (() => Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency); _combat.CombatModeChanged += OnCombatModeChanged; @@ -196,7 +252,7 @@ public sealed class CombatAttackController : IDisposable && !_repeatAttacking) return; - _sendCancelAttack(); + _operations.SendCancelAttack(); _repeatAttacking = false; if (_buildInProgress) @@ -219,7 +275,7 @@ public sealed class CombatAttackController : IDisposable if (!_buildInProgress) return; - if (!_playerReadyForAttack()) + if (!_operations.PlayerReadyForAttack) { if (_attackRequestInProgress) { @@ -258,7 +314,7 @@ public sealed class CombatAttackController : IDisposable private void StartAttackRequest() { if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode) - || !_canStartAttack()) + || !_operations.CanStartAttack()) return; // Retail StartAttackRequest (0x0056C040) stores request-in-progress @@ -267,7 +323,7 @@ public sealed class CombatAttackController : IDisposable // player movement object and must run before any later attack send. _attackRequestInProgress = true; _requestedAttackPower = 1f; - _prepareAttackRequest(); + _operations.PrepareAttackRequest(); _currentBuildIsAutomatic = false; AttemptStartBuildingAttack(); } @@ -276,7 +332,7 @@ public sealed class CombatAttackController : IDisposable { if (_buildInProgress || _attackServerResponsePending - || !_playerReadyForAttack()) + || !_operations.PlayerReadyForAttack) return; StartPowerBarBuild(); } @@ -292,7 +348,7 @@ public sealed class CombatAttackController : IDisposable { if (!_buildInProgress) return 0f; - double duration = _isDualWield() + double duration = _operations.IsDualWield ? DualWieldPowerUpSeconds : AttackPowerUpSeconds; return (float)Math.Clamp((_now() - _buildStartTime) / duration, 0d, 1d); @@ -301,13 +357,15 @@ public sealed class CombatAttackController : IDisposable private void ExecuteAttack(AttackHeight height, bool setServerPending) { StopBuild(); - if (!_sendAttack(height, Math.Clamp(_requestedAttackPower, 0f, 1f))) + if (!_operations.SendAttack( + height, + Math.Clamp(_requestedAttackPower, 0f, 1f))) { ResetPowerBar(); return; } - if (_autoRepeatAttack()) + if (_operations.AutoRepeatAttack) _repeatAttacking = true; _attackServerResponsePending = setServerPending; } @@ -330,7 +388,7 @@ public sealed class CombatAttackController : IDisposable _repeatAttacking = false; if (!_attackRequestInProgress - && _autoRepeatAttack() + && _operations.AutoRepeatAttack && _repeatAttacking) { if (Math.Abs(_requestedAttackPower - DesiredPower) >= 0.01f) @@ -338,7 +396,7 @@ public sealed class CombatAttackController : IDisposable ExecuteAttack(RequestedHeight, setServerPending: false); } - if (!_autoRepeatAttack() || !_repeatAttacking) + if (!_operations.AutoRepeatAttack || !_repeatAttacking) { _repeatAttacking = false; ResetPowerBar(); diff --git a/src/AcDream.App/Combat/CombatAttackTargetSource.cs b/src/AcDream.App/Combat/CombatAttackTargetSource.cs new file mode 100644 index 00000000..854698f4 --- /dev/null +++ b/src/AcDream.App/Combat/CombatAttackTargetSource.cs @@ -0,0 +1,116 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.World; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.World; + +namespace AcDream.App.Combat; + +/// +/// Focused automatic-attack target owner. It shares canonical selection and +/// live-entity state without retaining the broader interaction controller. +/// +internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource +{ + private readonly SelectionState _selection; + private readonly LiveEntityRuntime _liveEntities; + private readonly ClientObjectTable _objects; + private readonly ILocalPlayerIdentitySource _player; + + public CombatAttackTargetSource( + SelectionState selection, + LiveEntityRuntime liveEntities, + ClientObjectTable objects, + ILocalPlayerIdentitySource player) + { + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _liveEntities = liveEntities + ?? throw new ArgumentNullException(nameof(liveEntities)); + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _player = player ?? throw new ArgumentNullException(nameof(player)); + } + + public uint? SelectedObjectId => _selection.SelectedObjectId; + + public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) + { + if (_selection.SelectedObjectId is { } selected + && IsHostileMonster(selected)) + { + return selected; + } + + if (!autoTarget) + return null; + + (uint Guid, float DistanceSquared)? closest = FindClosestHostileMonster(); + if (closest is not { } best) + { + _selection.Clear(SelectionChangeSource.Keyboard); + return null; + } + + _selection.Select(best.Guid, SelectionChangeSource.Keyboard); + string? name = _objects.Get(best.Guid)?.Name; + string label = string.IsNullOrWhiteSpace(name) + ? $"0x{best.Guid:X8}" + : name; + Console.WriteLine( + $"combat: selected target 0x{best.Guid:X8} {label} dist={MathF.Sqrt(best.DistanceSquared):F1}"); + return best.Guid; + } + + private (uint Guid, float DistanceSquared)? FindClosestHostileMonster() + { + if (!_liveEntities.TryGetWorldEntity( + _player.ServerGuid, + out WorldEntity playerEntity)) + { + return null; + } + + (uint Guid, float DistanceSquared)? best = null; + foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities) + { + if (!IsHostileMonster(guid)) + continue; + + float distanceSquared = Vector3.DistanceSquared( + entity.Position, + playerEntity.Position); + if (best is null || distanceSquared < best.Value.DistanceSquared) + best = (guid, distanceSquared); + } + return best; + } + + private bool IsHostileMonster(uint serverGuid) + { + uint playerGuid = _player.ServerGuid; + if (serverGuid == playerGuid + || !_liveEntities.TryGetInteractionEligibleRecord( + serverGuid, + out LiveEntityRecord record) + || record.WorldEntity is not { } entity) + { + return false; + } + + if (_liveEntities.TryGetAnimationRuntime(entity.Id, out var animation) + && animation.CurrentMotion == MotionCommand.Dead) + { + return false; + } + + ClientObject? candidate = _objects.Get(serverGuid); + return candidate is not null + && (candidate.Type & ItemType.Creature) != 0 + && CombatTargetPolicy.IsHostileMonster( + playerGuid, + _objects.Get(playerGuid), + candidate); + } +} diff --git a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs new file mode 100644 index 00000000..0d5ee7ee --- /dev/null +++ b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs @@ -0,0 +1,180 @@ +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.Core.Combat; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Combat; + +internal interface ICombatAttackTargetSource +{ + uint? SelectedObjectId { get; } + uint? GetSelectedOrClosestCombatTarget(bool autoTarget); +} + +internal interface ICombatGameplaySettingsSource +{ + bool AutoTarget { get; } + bool AutoRepeatAttack { get; } +} + +internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource +{ + public GameplaySettings Value { get; set; } = GameplaySettings.Default; + public bool AutoTarget => Value.AutoTarget; + public bool AutoRepeatAttack => Value.AutoRepeatAttack; +} + +internal interface ICombatFeedbackSink +{ + void Show(string message); +} + +internal sealed class CombatFeedbackSlot : ICombatFeedbackSink +{ + public AcDream.UI.Abstractions.Panels.Debug.DebugVM? ViewModel { get; set; } + public void Show(string message) => ViewModel?.AddToast(message); +} + +internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations +{ + private ICombatAttackOperations? _owner; + + public void Bind(ICombatAttackOperations owner) + { + ArgumentNullException.ThrowIfNull(owner); + if (_owner is not null && !ReferenceEquals(_owner, owner)) + throw new InvalidOperationException( + "Combat attack operations are already bound."); + _owner = owner; + } + + public bool CanStartAttack() => _owner?.CanStartAttack() == true; + public void PrepareAttackRequest() => _owner?.PrepareAttackRequest(); + public bool SendAttack(AttackHeight height, float power) => + _owner?.SendAttack(height, power) == true; + public void SendCancelAttack() => _owner?.SendCancelAttack(); + public bool IsDualWield => _owner?.IsDualWield == true; + public bool PlayerReadyForAttack => _owner?.PlayerReadyForAttack == true; + public bool AutoRepeatAttack => _owner?.AutoRepeatAttack == true; +} + +/// +/// Production combat request dependencies. The attack state machine retains +/// this typed owner rather than callbacks into the application window. +/// +internal sealed class LiveCombatAttackOperations : ICombatAttackOperations +{ + private readonly CombatState _combat; + private readonly ICombatAttackTargetSource _targets; + private readonly ICombatGameplaySettingsSource _settings; + private readonly ILocalPlayerControllerSource _player; + private readonly LocalPlayerOutboundController _outbound; + private readonly ILiveInWorldSource _inWorld; + private readonly ILiveWorldSessionSource _session; + private readonly ICombatFeedbackSink _feedback; + + public LiveCombatAttackOperations( + CombatState combat, + ICombatAttackTargetSource targets, + ICombatGameplaySettingsSource settings, + ILocalPlayerControllerSource player, + LocalPlayerOutboundController outbound, + ILiveInWorldSource inWorld, + ILiveWorldSessionSource session, + ICombatFeedbackSink feedback) + { + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _targets = targets ?? throw new ArgumentNullException(nameof(targets)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _player = player ?? throw new ArgumentNullException(nameof(player)); + _outbound = outbound ?? throw new ArgumentNullException(nameof(outbound)); + _inWorld = inWorld ?? throw new ArgumentNullException(nameof(inWorld)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _feedback = feedback ?? throw new ArgumentNullException(nameof(feedback)); + } + + public bool IsDualWield => + _player.Controller?.Motion.InterpretedState.CurrentStyle + == CombatInputPlanner.DualWieldCombatStyle; + + public bool PlayerReadyForAttack + { + get + { + if (_player.Controller is not { } controller) + return false; + var motion = controller.Motion.InterpretedState; + return CombatInputPlanner.PlayerInReadyPositionForAttack( + _combat.CurrentMode, + motion.CurrentStyle, + motion.ForwardCommand); + } + } + + public bool AutoRepeatAttack => _settings.AutoRepeatAttack; + + public bool CanStartAttack() + { + if (!_inWorld.IsInWorld) + return false; + + if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)) + { + _feedback.Show("Enter melee or missile combat first"); + Console.WriteLine( + "combat: attack ignored; not in melee/missile combat mode"); + return false; + } + + if (_targets.GetSelectedOrClosestCombatTarget(_settings.AutoTarget) is null) + { + _feedback.Show("No monster target"); + Console.WriteLine("combat: attack ignored; no creature target found"); + return false; + } + + return true; + } + + public bool SendAttack(AttackHeight height, float power) + { + if (!CanStartAttack() + || _session.CurrentSession is not { } session + || _targets.SelectedObjectId is not { } target) + { + return false; + } + + power = Math.Clamp(power, 0f, 1f); + if (_combat.CurrentMode == CombatMode.Missile) + { + session.SendMissileAttack(target, height, power); + Console.WriteLine( + $"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}"); + } + else + { + session.SendMeleeAttack(target, height, power); + Console.WriteLine( + $"combat: melee attack target=0x{target:X8} height={height} power={power:F2}"); + } + return true; + } + + public void SendCancelAttack() => + _session.CurrentSession?.SendCancelAttack(); + + public void PrepareAttackRequest() + { + if (_player.Controller is not { } controller + || !controller.PrepareForAttackRequest()) + { + return; + } + + _outbound.TrySendMovement( + _session.CurrentSession, + controller, + controller.CaptureMovementResult(mouseLookEvent: false)); + } +} diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs new file mode 100644 index 00000000..c5207ab7 --- /dev/null +++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs @@ -0,0 +1,83 @@ +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Input; + +internal interface IMovementInputSource +{ + MovementInput Capture(); +} + +/// +/// Owns held movement sampling and the retail autorun latch. The input +/// dispatcher remains the only keyboard/mouse-button state source. +/// +internal sealed class DispatcherMovementInputSource : IMovementInputSource +{ + private readonly IInputCaptureSource? _capture; + private InputDispatcher? _dispatcher; + + public DispatcherMovementInputSource(IInputCaptureSource? capture = null) => + _capture = capture; + + public bool AutoRunActive { get; private set; } + + public void Bind(InputDispatcher dispatcher) + { + ArgumentNullException.ThrowIfNull(dispatcher); + if (_dispatcher is not null && !ReferenceEquals(_dispatcher, dispatcher)) + throw new InvalidOperationException( + "The movement input source is already bound to another dispatcher."); + _dispatcher = dispatcher; + } + + public MovementInput Capture() + { + // Devtools owns the whole gameplay keyboard while active, including + // a latched autorun. Retained chat owns physical key state only; + // retail's autorun latch continues until an explicit cancel action. + if (_capture?.DevToolsWantCaptureKeyboard == true) + return default; + + if (_dispatcher is not { } dispatcher) + return default; + + bool walking = dispatcher.IsActionHeld(InputAction.MovementWalkMode); + bool forward = dispatcher.IsActionHeld(InputAction.MovementForward); + return new MovementInput( + Forward: forward || AutoRunActive, + Backward: dispatcher.IsActionHeld(InputAction.MovementBackup), + StrafeLeft: dispatcher.IsActionHeld(InputAction.MovementStrafeLeft), + StrafeRight: dispatcher.IsActionHeld(InputAction.MovementStrafeRight), + TurnLeft: dispatcher.IsActionHeld(InputAction.MovementTurnLeft), + TurnRight: dispatcher.IsActionHeld(InputAction.MovementTurnRight), + Run: !walking, + Jump: dispatcher.IsActionHeld(InputAction.MovementJump)); + } + + /// + /// Applies the press-only autorun policy at the same point in the semantic + /// action pipeline as the former GameWindow body. + /// + /// True when the action is fully consumed. + public bool HandlePressedAction(InputAction action) + { + if (action == InputAction.MovementRunLock) + { + AutoRunActive = !AutoRunActive; + return true; + } + + if (AutoRunActive && action is ( + InputAction.MovementBackup + or InputAction.MovementStop + or InputAction.MovementStrafeLeft + or InputAction.MovementStrafeRight)) + { + AutoRunActive = false; + } + + return false; + } + + public void ResetSession() => AutoRunActive = false; +} diff --git a/src/AcDream.App/Input/GameplayInputFrameController.cs b/src/AcDream.App/Input/GameplayInputFrameController.cs new file mode 100644 index 00000000..35bf16c6 --- /dev/null +++ b/src/AcDream.App/Input/GameplayInputFrameController.cs @@ -0,0 +1,87 @@ +using AcDream.App.Combat; +using AcDream.App.Update; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Input; + +internal interface ICombatInputFrameController +{ + void Tick(); + void HandleMovementInput(InputAction action, ActivationType activation); + bool HandleInputAction(InputAction action, ActivationType activation); +} + +internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameController +{ + private readonly CombatAttackController _owner; + + public CombatAttackInputFrameAdapter(CombatAttackController owner) => + _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + + public void Tick() => _owner.Tick(); + + public void HandleMovementInput(InputAction action, ActivationType activation) => + _owner.HandleMovementInput(action, activation); + + public bool HandleInputAction(InputAction action, ActivationType activation) => + _owner.HandleInputAction(action, activation); +} + +/// +/// Owns the complete pre-object input frame: held semantic dispatch, one raw +/// mouse-look sample, and combat attack intent. +/// +internal sealed class GameplayInputFrameController : IGameplayInputFramePhase +{ + private readonly InputDispatcher? _dispatcher; + private readonly DispatcherMovementInputSource _movement; + private readonly IMouseLookInputFrameController? _mouseLook; + private readonly ICombatInputFrameController _combat; + + public GameplayInputFrameController( + InputDispatcher? dispatcher, + DispatcherMovementInputSource movement, + IMouseLookInputFrameController? mouseLook, + ICombatInputFrameController combat) + { + _dispatcher = dispatcher; + _movement = movement ?? throw new ArgumentNullException(nameof(movement)); + _mouseLook = mouseLook; + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + } + + public bool MouseLookActive => _mouseLook?.Active == true; + + public void Tick(UpdateFrameTiming timing) + { + _ = timing; + _dispatcher?.Tick(); + _mouseLook?.Tick(); + _combat.Tick(); + } + + public bool HandlePointerAction(InputAction action, ActivationType activation) => + _mouseLook?.HandlePointerAction(action, activation) == true; + + public bool HandleCombatAction(InputAction action, ActivationType activation) + { + // ACCmdInterp::HandleNewForwardMovement @ 0x0058B1F0 aborts + // automatic combat before the movement command enters the interpreter. + _combat.HandleMovementInput(action, activation); + return _combat.HandleInputAction(action, activation); + } + + public bool HandlePressedMovementAction(InputAction action) => + _movement.HandlePressedAction(action); + + public void QueueRawMouseDelta(float dx, float dy) => + _mouseLook?.QueueRawDelta(dx, dy); + + public void EndMouseLook() => _mouseLook?.EndForLifecycle(); + + public void ResetSession() + { + _mouseLook?.ResetSession(); + _movement.ResetSession(); + } +} diff --git a/src/AcDream.App/Input/InputCaptureSources.cs b/src/AcDream.App/Input/InputCaptureSources.cs new file mode 100644 index 00000000..7ec77fe5 --- /dev/null +++ b/src/AcDream.App/Input/InputCaptureSources.cs @@ -0,0 +1,74 @@ +using AcDream.App.UI; + +namespace AcDream.App.Input; + +internal interface IInputCaptureSource +{ + bool WantCaptureMouse { get; } + bool WantCaptureKeyboard { get; } + bool DevToolsWantCaptureKeyboard { get; } +} + +internal sealed class DevToolsInputCaptureSource +{ + private readonly bool _enabled; + + public DevToolsInputCaptureSource(bool enabled) => _enabled = enabled; + + public bool WantCaptureMouse => + _enabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse; + + public bool WantCaptureKeyboard => + _enabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard; +} + +internal sealed class RetainedUiInputCaptureSlot +{ + public UiRoot? Root { get; set; } + + public bool WantCaptureMouse => Root?.WantsMouse ?? false; + public bool WantCaptureKeyboard => Root?.WantsKeyboard ?? false; +} + +internal sealed class CompositeInputCaptureSource : IInputCaptureSource +{ + private readonly DevToolsInputCaptureSource _devTools; + private readonly RetainedUiInputCaptureSlot _retained; + + public CompositeInputCaptureSource( + DevToolsInputCaptureSource devTools, + RetainedUiInputCaptureSlot retained) + { + _devTools = devTools ?? throw new ArgumentNullException(nameof(devTools)); + _retained = retained ?? throw new ArgumentNullException(nameof(retained)); + } + + public bool WantCaptureMouse => + _devTools.WantCaptureMouse || _retained.WantCaptureMouse; + + public bool WantCaptureKeyboard => + DevToolsWantCaptureKeyboard || _retained.WantCaptureKeyboard; + + public bool DevToolsWantCaptureKeyboard => + _devTools.WantCaptureKeyboard; +} + +internal sealed class DelegateInputCaptureSource : IInputCaptureSource +{ + private readonly Func _wantCaptureMouse; + private readonly Func _wantCaptureKeyboard; + + public DelegateInputCaptureSource( + Func wantCaptureMouse, + Func wantCaptureKeyboard) + { + _wantCaptureMouse = wantCaptureMouse + ?? throw new ArgumentNullException(nameof(wantCaptureMouse)); + _wantCaptureKeyboard = wantCaptureKeyboard + ?? throw new ArgumentNullException(nameof(wantCaptureKeyboard)); + } + + public bool WantCaptureMouse => _wantCaptureMouse(); + public bool WantCaptureKeyboard => _wantCaptureKeyboard(); + public bool DevToolsWantCaptureKeyboard => false; +} diff --git a/src/AcDream.App/Input/LocalPlayerOutboundController.cs b/src/AcDream.App/Input/LocalPlayerOutboundController.cs index a0b8c6a1..7adc7903 100644 --- a/src/AcDream.App/Input/LocalPlayerOutboundController.cs +++ b/src/AcDream.App/Input/LocalPlayerOutboundController.cs @@ -11,13 +11,18 @@ namespace AcDream.App.Input; /// public sealed class LocalPlayerOutboundController { - private readonly Action _diagnostic; + private readonly IMovementTruthDiagnosticSink _diagnostic; + + internal LocalPlayerOutboundController(IMovementTruthDiagnosticSink diagnostic) + { + _diagnostic = diagnostic + ?? throw new ArgumentNullException(nameof(diagnostic)); + } public LocalPlayerOutboundController( Action diagnostic) { - _diagnostic = diagnostic - ?? throw new ArgumentNullException(nameof(diagnostic)); + _diagnostic = new DelegateMovementTruthDiagnosticSink(diagnostic); } /// @@ -107,7 +112,7 @@ public sealed class LocalPlayerOutboundController teleportSequence: session.TeleportSequence, forcePositionSequence: session.ForcePositionSequence, lastContact: contactByte); - _diagnostic( + _diagnostic.OnOutbound( "AP", sequence, movement, @@ -193,7 +198,7 @@ public sealed class LocalPlayerOutboundController forcePositionSequence: session.ForcePositionSequence, contact: contactByte != 0, standingLongjump: false); - _diagnostic( + _diagnostic.OnOutbound( "MTS", sequence, movement, diff --git a/src/AcDream.App/Input/MouseLookController.cs b/src/AcDream.App/Input/MouseLookController.cs new file mode 100644 index 00000000..69c9efbe --- /dev/null +++ b/src/AcDream.App/Input/MouseLookController.cs @@ -0,0 +1,262 @@ +using AcDream.App.Net; +using AcDream.App.Rendering; +using AcDream.Core.Rendering; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Input; + +internal interface IInputMonotonicClock +{ + float NowSeconds { get; } +} + +internal sealed class EnvironmentInputMonotonicClock : IInputMonotonicClock +{ + public float NowSeconds => (float)(Environment.TickCount64 / 1000.0); +} + +internal interface IPointerPositionSource +{ + float X { get; } + float Y { get; } +} + +internal sealed class PointerPositionState : IPointerPositionSource +{ + public float X { get; set; } + public float Y { get; set; } +} + +internal interface IMouseLookCursor +{ + bool HasSavedMode { get; } + void Hide(); + void Restore(); +} + +internal interface IMouseLookInputFrameController +{ + bool Active { get; } + bool HandlePointerAction(InputAction action, ActivationType activation); + void QueueRawDelta(float dx, float dy); + void Tick(); + void EndAndRestoreCursor(); + void EndForLifecycle(); + void ResetSession(); +} + +internal sealed class SilkMouseLookCursor : IMouseLookCursor +{ + private readonly IMouse _mouse; + private CursorMode? _savedMode; + + public SilkMouseLookCursor(IMouse mouse) => + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + + public bool HasSavedMode => _savedMode.HasValue; + + public void Hide() + { + _savedMode = _mouse.Cursor.CursorMode; + _mouse.Cursor.CursorMode = CursorMode.Hidden; + } + + public void Restore() + { + _mouse.Cursor.CursorMode = _savedMode ?? CursorMode.Normal; + _savedMode = null; + } +} + +/// +/// The current chase-camera input targets. GameWindow keeps compatibility +/// properties over this one slot until checkpoint F moves camera ownership. +/// +internal sealed class ChaseCameraInputState +{ + public ChaseCamera? Legacy { get; set; } + public RetailChaseCamera? Retail { get; set; } + public float Sensitivity { get; set; } = 0.15f; + public bool RmbOrbitHeld { get; set; } +} + +/// +/// Owns instant mouse-look state, raw-sample timing/filtering, movement +/// transition sends, and cursor capture across every exit path. +/// +internal sealed class MouseLookController : IMouseLookInputFrameController +{ + private readonly IMouseSource _mouseSource; + private readonly IPointerPositionSource _pointer; + private readonly ILocalPlayerModeSource _playerMode; + private readonly ILocalPlayerControllerSource _playerController; + private readonly CameraController _camera; + private readonly ChaseCameraInputState _chase; + private readonly IMovementInputSource _movementInput; + private readonly LocalPlayerOutboundController _outbound; + private readonly ILiveWorldSessionSource _session; + private readonly IMouseLookCursor _cursor; + private readonly IInputMonotonicClock _clock; + private readonly MouseLookState _state; + private bool _lastWantCaptureMouse; + + public MouseLookController( + IMouseSource mouseSource, + IPointerPositionSource pointer, + ILocalPlayerModeSource playerMode, + ILocalPlayerControllerSource playerController, + CameraController camera, + ChaseCameraInputState chase, + IMovementInputSource movementInput, + LocalPlayerOutboundController outbound, + ILiveWorldSessionSource session, + IMouseLookCursor cursor, + IInputMonotonicClock clock) + { + _mouseSource = mouseSource ?? throw new ArgumentNullException(nameof(mouseSource)); + _pointer = pointer ?? throw new ArgumentNullException(nameof(pointer)); + _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); + _playerController = playerController + ?? throw new ArgumentNullException(nameof(playerController)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _chase = chase ?? throw new ArgumentNullException(nameof(chase)); + _movementInput = movementInput + ?? throw new ArgumentNullException(nameof(movementInput)); + _outbound = outbound ?? throw new ArgumentNullException(nameof(outbound)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _cursor = cursor ?? throw new ArgumentNullException(nameof(cursor)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _state = new MouseLookState(ApplyHorizontalAdjustment); + } + + public bool Active => _state.Active; + + public bool HandlePointerAction(InputAction action, ActivationType activation) + { + if (action == InputAction.AcdreamRmbOrbitHold) + { + if (activation == ActivationType.Press) + _chase.RmbOrbitHeld = _playerMode.IsPlayerMode && _camera.IsChaseMode; + else if (activation == ActivationType.Release) + _chase.RmbOrbitHeld = false; + return true; + } + + if (action != InputAction.CameraInstantMouseLook) + return false; + + if (activation == ActivationType.Press) + Begin(); + else if (activation == ActivationType.Release) + EndAndRestoreCursor(); + return true; + } + + public void QueueRawDelta(float dx, float dy) => _state.QueueDelta(dx, dy); + + public void Tick() + { + bool wantCaptureMouse = _mouseSource.WantCaptureMouse; + if (wantCaptureMouse != _lastWantCaptureMouse) + { + if (wantCaptureMouse && _state.Active) + EndAndRestoreCursor(); + _state.OnWantCaptureMouseChanged(wantCaptureMouse); + _lastWantCaptureMouse = wantCaptureMouse; + } + + float nowSeconds = _clock.NowSeconds; + if (!_state.TryTakeRawSample(nowSeconds, out float rawX, out float rawY)) + return; + + PlayerMovementController? controller = _playerController.Controller; + if (rawX == 0f && rawY == 0f) + controller?.StopMouseDrift(_movementInput.Capture()); + + (float filteredX, float filteredY) = + CameraDiagnostics.UseRetailChaseCamera && _chase.Retail is { } retail + ? retail.FilterMouseDelta(rawX, rawY, weight: 0.5f, nowSec: nowSeconds) + : (rawX, rawY); + _state.ApplyDelta(filteredX, _chase.Sensitivity); + if (_chase.Retail is { } retailCamera) + retailCamera.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity); + else + _chase.Legacy?.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity); + } + + public void EndAndRestoreCursor() + { + bool stateWasActive = _state.Active; + _state.Release(); + + PlayerMovementController? controller = _playerController.Controller; + if (controller is not null && controller.EndMouseLook(_movementInput.Capture())) + { + _outbound.TrySendMovement( + _session.CurrentSession, + controller, + controller.CaptureMovementResult(mouseLookEvent: false)); + } + + if (stateWasActive || _cursor.HasSavedMode) + _cursor.Restore(); + } + + public void EndForLifecycle() + { + EndAndRestoreCursor(); + _chase.RmbOrbitHeld = false; + } + + /// + /// Session teardown releases presentation capture without emitting a + /// movement packet into the ending session, matching the prior reset edge. + /// + public void ResetSession() + { + bool stateWasActive = _state.Active; + _state.Release(); + _chase.RmbOrbitHeld = false; + _lastWantCaptureMouse = false; + if (stateWasActive || _cursor.HasSavedMode) + _cursor.Restore(); + } + + private void Begin() + { + PlayerMovementController? controller = _playerController.Controller; + if (!_playerMode.IsPlayerMode + || !_camera.IsChaseMode + || controller is not { State: PlayerState.InWorld }) + { + return; + } + + float nowSeconds = _clock.NowSeconds; + _state.Press( + _pointer.X, + _pointer.Y, + _mouseSource.WantCaptureMouse, + nowSeconds); + if (!_state.Active) + return; + + if (!controller.BeginMouseLook(_movementInput.Capture())) + { + _state.Release(); + return; + } + + _outbound.TrySendMovement( + _session.CurrentSession, + controller, + controller.CaptureMovementResult(mouseLookEvent: false)); + _cursor.Hide(); + } + + private void ApplyHorizontalAdjustment(float adjustment) => + _playerController.Controller?.SubmitMouseTurnAdjustment( + adjustment, + _movementInput.Capture()); +} diff --git a/src/AcDream.App/Input/MovementTruthDiagnosticController.cs b/src/AcDream.App/Input/MovementTruthDiagnosticController.cs new file mode 100644 index 00000000..1ed2a296 --- /dev/null +++ b/src/AcDream.App/Input/MovementTruthDiagnosticController.cs @@ -0,0 +1,161 @@ +using System.Numerics; +using AcDream.Core.Net; + +namespace AcDream.App.Input; + +internal interface IMovementTruthDiagnosticSink +{ + void OnOutbound( + string kind, + uint sequence, + MovementResult result, + Vector3 wirePosition, + uint wireCellId, + byte contactByte); + + void OnServerEcho( + WorldSession.EntityPositionUpdate update, + Vector3 serverWorldPosition); + + void ResetSession(); +} + +internal sealed class MovementTruthDiagnosticController + : IMovementTruthDiagnosticSink +{ + private readonly bool _enabled; + private readonly ILocalPlayerControllerSource _player; + private readonly ILocalPlayerIdentitySource _identity; + private MovementTruthOutbound? _lastOutbound; + + private readonly record struct MovementTruthOutbound( + string Kind, + uint Sequence, + DateTime TimeUtc, + Vector3 LocalWorldPosition, + Vector3 WirePosition, + uint WireCellId, + bool IsOnGround, + byte ContactByte, + Vector3 Velocity); + + public MovementTruthDiagnosticController( + bool enabled, + ILocalPlayerControllerSource player, + ILocalPlayerIdentitySource identity) + { + _enabled = enabled; + _player = player ?? throw new ArgumentNullException(nameof(player)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + } + + public void OnOutbound( + string kind, + uint sequence, + MovementResult result, + Vector3 wirePosition, + uint wireCellId, + byte contactByte) + { + if (!_enabled) + return; + + Vector3 velocity = _player.Controller?.BodyVelocity ?? Vector3.Zero; + _lastOutbound = new MovementTruthOutbound( + kind, + sequence, + DateTime.UtcNow, + result.Position, + wirePosition, + wireCellId, + result.IsOnGround, + contactByte, + velocity); + + Console.WriteLine(FormattableString.Invariant( + $"move-truth OUT kind={kind} seq={sequence} local={Fmt(result.Position)} localCell=0x{result.CellId:X8} wire={Fmt(wirePosition)} wireCell=0x{wireCellId:X8} grounded={result.IsOnGround} contact={contactByte} vel={Fmt(velocity)} f={FmtCmd(result.ForwardCommand)} s={FmtCmd(result.SidestepCommand)} t={FmtCmd(result.TurnCommand)}")); + } + + public void OnServerEcho( + WorldSession.EntityPositionUpdate update, + Vector3 serverWorldPosition) + { + if (!_enabled || update.Guid != _identity.ServerGuid) + return; + + DateTime now = DateTime.UtcNow; + PlayerMovementController? controller = _player.Controller; + Vector3? localPosition = controller?.Position; + uint? localCellId = controller?.CellId; + Vector3? deltaLocal = localPosition.HasValue + ? serverWorldPosition - localPosition.Value + : null; + + string localText = localPosition.HasValue ? Fmt(localPosition.Value) : "-"; + string localCellText = localCellId.HasValue + ? FormattableString.Invariant($"0x{localCellId.Value:X8}") + : "-"; + string deltaLocalText = deltaLocal.HasValue ? Fmt(deltaLocal.Value) : "-"; + string deltaLocalLength = deltaLocal.HasValue + ? FormattableString.Invariant($"{deltaLocal.Value.Length():F3}") + : "-"; + + string lastText = "-"; + if (_lastOutbound is { } last) + { + Vector3 deltaOut = serverWorldPosition - last.LocalWorldPosition; + double ageMs = (now - last.TimeUtc).TotalMilliseconds; + lastText = FormattableString.Invariant( + $"{last.Kind}:{last.Sequence} ageMs={ageMs:F0} outGrounded={last.IsOnGround} outContact={last.ContactByte} outCell=0x{last.WireCellId:X8} deltaOut={Fmt(deltaOut)} distOut={deltaOut.Length():F3}"); + } + + string state = controller?.State.ToString() ?? "-"; + string velocityText = update.Velocity.HasValue + ? Fmt(update.Velocity.Value) + : "-"; + + Console.WriteLine(FormattableString.Invariant( + $"move-truth ECHO guid=0x{update.Guid:X8} server={Fmt(serverWorldPosition)} serverCell=0x{update.Position.LandblockId:X8} local={localText} localCell={localCellText} deltaLocal={deltaLocalText} distLocal={deltaLocalLength} serverVel={velocityText} state={state} lastOut={lastText}")); + } + + public void ResetSession() => _lastOutbound = null; + + private static string Fmt(Vector3 value) => + FormattableString.Invariant( + $"({value.X:F3},{value.Y:F3},{value.Z:F3})"); + + private static string FmtCmd(uint? command) => + command.HasValue + ? FormattableString.Invariant($"0x{command.Value:X8}") + : "-"; +} + +internal sealed class DelegateMovementTruthDiagnosticSink + : IMovementTruthDiagnosticSink +{ + private readonly Action + _outbound; + + public DelegateMovementTruthDiagnosticSink( + Action outbound) => + _outbound = outbound ?? throw new ArgumentNullException(nameof(outbound)); + + public void OnOutbound( + string kind, + uint sequence, + MovementResult result, + Vector3 wirePosition, + uint wireCellId, + byte contactByte) => + _outbound(kind, sequence, result, wirePosition, wireCellId, contactByte); + + public void OnServerEcho( + WorldSession.EntityPositionUpdate update, + Vector3 serverWorldPosition) + { + } + + public void ResetSession() + { + } +} diff --git a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs index 51bb8fd7..a5599bdc 100644 --- a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs +++ b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs @@ -16,7 +16,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram { private readonly Func _canPresentPlayer; private readonly Func _getController; - private readonly Func _captureInput; + private readonly IMovementInputSource _movementInput; private readonly Func _resolveLocalEntityId; private readonly Action _handleTargeting; private readonly Func _isHidden; @@ -53,7 +53,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram public RetailLocalPlayerFrameController( Func canPresentPlayer, Func getController, - Func captureInput, + IMovementInputSource movementInput, Func resolveLocalEntityId, Action handleTargeting, Func isHidden, @@ -67,8 +67,8 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram ?? throw new ArgumentNullException(nameof(canPresentPlayer)); _getController = getController ?? throw new ArgumentNullException(nameof(getController)); - _captureInput = captureInput - ?? throw new ArgumentNullException(nameof(captureInput)); + _movementInput = movementInput + ?? throw new ArgumentNullException(nameof(movementInput)); _resolveLocalEntityId = resolveLocalEntityId ?? throw new ArgumentNullException(nameof(resolveLocalEntityId)); _handleTargeting = handleTargeting @@ -134,7 +134,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram MovementResult movement = hidden ? controller.TickHidden(deltaSeconds, _handleTargeting) - : controller.Update(deltaSeconds, _captureInput(), _handleTargeting); + : controller.Update(deltaSeconds, _movementInput.Capture(), _handleTargeting); _project(controller, movement, hidden); _sendPreNetwork(controller, movement, hidden); diff --git a/src/AcDream.App/Input/SilkMouseSource.cs b/src/AcDream.App/Input/SilkMouseSource.cs index 3669189f..39e29487 100644 --- a/src/AcDream.App/Input/SilkMouseSource.cs +++ b/src/AcDream.App/Input/SilkMouseSource.cs @@ -11,18 +11,15 @@ namespace AcDream.App.Input; /// . /// /// -/// We don't link Hexa.NET.ImGui or ImGuiNET directly here — the -/// constructor takes two delegates so the App.Rendering layer can -/// proxy ImGui.GetIO().WantCaptureMouse via whichever ImGui -/// package is currently active without leaking the type onto the -/// abstraction interface. +/// Production receives a typed capture source, keeping retained-UI and +/// developer-UI ownership outside the Silk adapter. The public delegate +/// overload remains as a compatibility seam for standalone consumers. /// /// public sealed class SilkMouseSource : IMouseSource { private readonly IMouse _mouse; - private readonly Func _wantCaptureMouse; - private readonly Func _wantCaptureKeyboard; + private readonly IInputCaptureSource _capture; private float _lastX; private float _lastY; private bool _haveLastPos; @@ -32,10 +29,21 @@ public sealed class SilkMouseSource : IMouseSource public event Action? MouseMove; public event Action? Scroll; - /// Caller-supplied probe for the current modifier mask. Reused - /// from the keyboard source so mouse events carry consistent modifier - /// state. - public Func ModifierProbe { get; set; } = () => ModifierMask.None; + /// The keyboard source that owns the current modifier mask, so + /// mouse events and keyboard events resolve identical chords. + public IKeyboardSource? ModifierSource { get; set; } + + internal SilkMouseSource( + IMouse mouse, + IInputCaptureSource capture, + IKeyboardSource modifierSource) + { + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _capture = capture ?? throw new ArgumentNullException(nameof(capture)); + ModifierSource = modifierSource + ?? throw new ArgumentNullException(nameof(modifierSource)); + Subscribe(); + } public SilkMouseSource( IMouse mouse, @@ -43,11 +51,16 @@ public sealed class SilkMouseSource : IMouseSource Func wantCaptureKeyboard) { _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); - _wantCaptureMouse = wantCaptureMouse ?? throw new ArgumentNullException(nameof(wantCaptureMouse)); - _wantCaptureKeyboard = wantCaptureKeyboard ?? throw new ArgumentNullException(nameof(wantCaptureKeyboard)); + _capture = new DelegateInputCaptureSource( + wantCaptureMouse, + wantCaptureKeyboard); + Subscribe(); + } - _mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ModifierProbe()); - _mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ModifierProbe()); + private void Subscribe() + { + _mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ReadModifiers()); + _mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ReadModifiers()); _mouse.MouseMove += (_, pos) => { float dx, dy; @@ -69,8 +82,11 @@ public sealed class SilkMouseSource : IMouseSource _mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y); } + private ModifierMask ReadModifiers() => + ModifierSource?.CurrentModifiers ?? ModifierMask.None; + public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button); - public bool WantCaptureMouse => _wantCaptureMouse(); - public bool WantCaptureKeyboard => _wantCaptureKeyboard(); + public bool WantCaptureMouse => _capture.WantCaptureMouse; + public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard; } diff --git a/src/AcDream.App/Net/ILiveInWorldSource.cs b/src/AcDream.App/Net/ILiveInWorldSource.cs index 48121eb7..1482777c 100644 --- a/src/AcDream.App/Net/ILiveInWorldSource.cs +++ b/src/AcDream.App/Net/ILiveInWorldSource.cs @@ -4,3 +4,8 @@ internal interface ILiveInWorldSource { bool IsInWorld { get; } } + +internal interface ILiveWorldSessionSource +{ + AcDream.Core.Net.WorldSession? CurrentSession { get; } +} diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs index 1f956f21..5e0ebebf 100644 --- a/src/AcDream.App/Net/LiveSessionController.cs +++ b/src/AcDream.App/Net/LiveSessionController.cs @@ -161,7 +161,8 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations internal sealed class LiveSessionController : IDisposable, ILiveSessionFramePhase, - ILiveInWorldSource + ILiveInWorldSource, + ILiveWorldSessionSource { private sealed class SessionScope( WorldSession session, diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 96f9c50b..69d10dfd 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -58,8 +58,7 @@ internal sealed class LiveEntityNetworkUpdateController private readonly Func _session; private readonly LiveEntityInboundAuthorityGate _authorityGate; private readonly Action _aimTeleportDestination; - private readonly Action - _dumpMovementTruthServerEcho; + private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics; private PlayerMovementController? _playerController => _playerControllerSource(); private EntityPhysicsHost? _playerHost => _playerHostSource(); @@ -106,7 +105,7 @@ internal sealed class LiveEntityNetworkUpdateController Func session, Action publishTimestamps, Action aimTeleportDestination, - Action dumpMovementTruthServerEcho) + IMovementTruthDiagnosticSink movementTruthDiagnostics) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _objects = objects ?? throw new ArgumentNullException(nameof(objects)); @@ -140,7 +139,8 @@ internal sealed class LiveEntityNetworkUpdateController liveEntities, publishTimestamps); _aimTeleportDestination = aimTeleportDestination ?? throw new ArgumentNullException(nameof(aimTeleportDestination)); - _dumpMovementTruthServerEcho = dumpMovementTruthServerEcho ?? throw new ArgumentNullException(nameof(dumpMovementTruthServerEcho)); + _movementTruthDiagnostics = movementTruthDiagnostics + ?? throw new ArgumentNullException(nameof(movementTruthDiagnostics)); } internal void ResetSessionState() => _authorityGate.ResetSessionState(); @@ -1106,7 +1106,7 @@ internal sealed class LiveEntityNetworkUpdateController var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition ? entity.Rotation : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); - _dumpMovementTruthServerEcho(update, worldPos); + _movementTruthDiagnostics.OnServerEcho(update, worldPos); bool remoteHardTeleport = update.Guid != _playerServerGuid && timestamps.TeleportHookRequired; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b02100d8..018c1b79 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -34,8 +34,17 @@ public sealed class GameWindow : IDisposable private CameraController? _cameraController; private IMouse? _capturedMouse; private IDatReaderWriter? _dats; - private float _lastMouseX; - private float _lastMouseY; + private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new(); + private float _lastMouseX + { + get => _pointerPosition.X; + set => _pointerPosition.X = value; + } + private float _lastMouseY + { + get => _pointerPosition.Y; + set => _pointerPosition.Y = value; + } private Shader? _meshShader; private TextureCache? _textureCache; /// Phase N.4+: WB-backed rendering pipeline adapter. Always non-null @@ -156,6 +165,8 @@ public sealed class GameWindow : IDisposable private LiveEntityAnimationScheduler _liveAnimationScheduler = null!; private LiveEntityAnimationPresenter _animationPresenter = null!; private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; + private readonly AcDream.App.Input.MovementTruthDiagnosticController + _movementTruthDiagnostics; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!; @@ -434,6 +445,10 @@ public sealed class GameWindow : IDisposable // Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1. private AcDream.App.UI.UiHost? _uiHost; private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime; + private readonly AcDream.App.Combat.CombatAttackOperationsSlot + _combatAttackOperations = new(); + private readonly AcDream.App.Combat.CombatFeedbackSlot + _combatFeedback = new(); private AcDream.App.Combat.CombatAttackController? _combatAttackController; private AcDream.App.Combat.CombatTargetController? _combatTargetController; private AcDream.App.UI.ItemInteractionController? _itemInteractionController; @@ -452,11 +467,8 @@ public sealed class GameWindow : IDisposable // _panelHost does. Self-subscribes to CombatState in its ctor, so // disposing isn't required (panel host holds the only ref). private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm; - // DevToolsEnabled + DumpMoveTruthEnabled now read through _options - // (RuntimeOptions.DevTools / DumpMoveTruth). Kept the same names for - // local readability via expression-bodied properties. + // DevToolsEnabled reads through typed RuntimeOptions. private bool DevToolsEnabled => _options.DevTools; - private bool DumpMoveTruthEnabled => _options.DumpMoveTruth; // Slice 3: the reset transaction remains cached by the host, while the // lifecycle controller owns each exact event/command/session generation. @@ -530,8 +542,17 @@ public sealed class GameWindow : IDisposable get => _playerControllerSlot.Controller; set => _playerControllerSlot.Controller = value; } - private AcDream.App.Rendering.ChaseCamera? _chaseCamera; - private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera; + private readonly AcDream.App.Input.ChaseCameraInputState _chaseCameraInput = new(); + private AcDream.App.Rendering.ChaseCamera? _chaseCamera + { + get => _chaseCameraInput.Legacy; + set => _chaseCameraInput.Legacy = value; + } + private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera + { + get => _chaseCameraInput.Retail; + set => _chaseCameraInput.Retail = value; + } private readonly AcDream.App.Input.LocalPlayerModeState _localPlayerMode = new(); private bool _playerMode { @@ -549,21 +570,8 @@ public sealed class GameWindow : IDisposable private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 _characterOptions1 = AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; - private MovementTruthOutbound? _lastMovementTruthOutbound; private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new(); - private readonly record struct MovementTruthOutbound( - string Kind, - uint Sequence, - System.DateTime TimeUtc, - System.Numerics.Vector3 LocalWorldPosition, - uint LocalCellId, - System.Numerics.Vector3 WirePosition, - uint WireCellId, - bool IsOnGround, - byte ContactByte, - System.Numerics.Vector3 Velocity); - // K-fix7 (2026-04-26): server-authoritative Run + Jump skill values // received from PlayerDescription. -1 = "not yet received, fall back // to the controller's default (env-var or hardcoded 200/300)". @@ -577,36 +585,25 @@ public sealed class GameWindow : IDisposable // Phase D.2b-C — live character-sheet assembly + raise flow (extracted // feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1. private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider; - // K.1b: this field is RESERVED — written when entering / leaving player - // mode and previously fed mouse-X into MovementInput.MouseDeltaX. Now - // never consumed by MovementInput (mouse never drives character yaw — - // K.1b regression-prevention). Kept around as plumbing for the future - // K.2 MMB-mouse-look path which will re-enable mouse → character-yaw - // when MMB is held. The pragma silences the dead-write warning until K.2 - // wires the read-side back in. -#pragma warning disable CS0414 // assigned but never used — see comment above - private float _playerMouseDeltaX; -#pragma warning restore CS0414 - // Mouse sensitivity multipliers — one per camera mode because the visual // feel is very different. Adjust via F8 / F9 for whichever mode is // currently active. Chase default is low because the character + camera // rotating together is overwhelming at fly speeds. - private float _sensChase = 0.15f; + private float _sensChase + { + get => _chaseCameraInput.Sensitivity; + set => _chaseCameraInput.Sensitivity = value; + } private float _sensFly = 1.0f; private float _sensOrbit = 1.0f; // Right-mouse-button held → free-orbit the chase camera around the // player without turning the character. Release leaves the camera at // the orbited position (no snap back). - private bool _rmbHeld; - - // K-fix1 (2026-04-26): autorun is a TOGGLE — Press Q to start - // forward-running, press Q again (or any movement-cancel key like - // X / S / Backward / Forward) to stop. Mirrors retail's - // AutoRun action. While true, MovementInput.Forward is forced - // true regardless of W's state. - private bool _autoRunActive; + private bool _rmbHeld + { + get => _chaseCameraInput.RmbOrbitHeld; + } // Phase K.2 — auto-enter player mode after a successful login. Armed // by ApplyLiveSessionEnteredWorld; ticked from @@ -617,21 +614,6 @@ public sealed class GameWindow : IDisposable // the bool here. private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry; - // Phase K.2 — MMB-hold instant mouse-look state. Live throughout - // the session; flips Active on Press/Release. Defense-in-depth on - // ImGui's WantCaptureMouse — the dispatcher already filters, but - // OnWantCaptureMouseChanged also suspends the state if a panel - // claims focus mid-hold. - private AcDream.UI.Abstractions.Input.MouseLookState? _mouseLook; - // Tracks the previous WantCaptureMouse value so we can fire the - // changed-edge callback once per transition (vs every frame). - private bool _lastWantCaptureMouse; - // Cursor mode prior to entering MMB mouse-look. Restored on - // release so the user lands back in the same camera mode as - // before (raw for chase/fly, normal for orbit). Set non-null while - // mouse-look is active. - private Silk.NET.Input.CursorMode? _mouseLookSavedCursorMode; - // Phase K.1b — single input path. Every keyboard/mouse-button reaction // flows through InputDispatcher.Fired (see OnInputAction below) or // IsActionHeld (per-frame polling for movement). The legacy direct @@ -641,6 +623,11 @@ public sealed class GameWindow : IDisposable private AcDream.App.Input.SilkKeyboardSource? _kbSource; private AcDream.App.Input.SilkMouseSource? _mouseSource; private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher; + private readonly AcDream.App.Input.RetainedUiInputCaptureSlot _retainedInputCapture; + private readonly AcDream.App.Input.CompositeInputCaptureSource _inputCapture; + private readonly AcDream.App.Input.DispatcherMovementInputSource _movementInput; + private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor; + private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame; // K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json, // falling back to the retail-faithful defaults if the file is missing // or corrupt. This is THE single source of truth for the keymap at @@ -750,6 +737,12 @@ public sealed class GameWindow : IDisposable AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); + _retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot(); + _inputCapture = new AcDream.App.Input.CompositeInputCaptureSource( + new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools), + _retainedInputCapture); + _movementInput = new AcDream.App.Input.DispatcherMovementInputSource( + _inputCapture); _datDir = options.DatDir; _worldGameState = worldGameState; _worldEvents = worldEvents; @@ -785,16 +778,17 @@ public sealed class GameWindow : IDisposable _liveEntities?.RebucketLiveEntity(serverGuid, landblockId), isCurrentVisibleProjection: IsCurrentVisibleLocalPlayerProjection, suspendShadow: SuspendLocalPlayerShadow); + _movementTruthDiagnostics = + new AcDream.App.Input.MovementTruthDiagnosticController( + options.DumpMoveTruth, + _playerControllerSlot, + _localPlayerIdentity); _localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController( - DumpMovementTruthOutbound); + _movementTruthDiagnostics); _localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController( canPresentPlayer: CanAdvanceLocalPlayer, getController: () => _playerController, - captureInput: () => - { - _playerMouseDeltaX = 0f; - return CaptureMovementInput(); - }, + movementInput: _movementInput, resolveLocalEntityId: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) ? entity.Id @@ -904,30 +898,16 @@ public sealed class GameWindow : IDisposable _kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb); _mouseSource = new AcDream.App.Input.SilkMouseSource( firstMouse, - wantCaptureMouse: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) - || (_uiHost?.Root.WantsMouse ?? false), - wantCaptureKeyboard: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard) - || (_uiHost?.Root.WantsKeyboard ?? false)); - _mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers; + _inputCapture, + _kbSource); _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( _kbSource, _mouseSource, _keyBindings); + _movementInput.Bind(_inputDispatcher); + _mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse); _inputDispatcher.Fired += OnInputAction; Combat.CombatModeChanged += SetInputCombatScope; SetInputCombatScope(Combat.CurrentMode); - // Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 / - // 0x00458310): the callback submits a filtered horizontal - // adjustment to the player's MotionInterpreter. It must never - // mutate visible Yaw directly; doing so leaves ACE's authoritative - // heading stale and makes targeted attacks skip their server turn. - _mouseLook = new AcDream.UI.Abstractions.Input.MouseLookState( - applyHorizontalAdjustment: adjustment => - { - _playerController?.SubmitMouseTurnAdjustment( - adjustment, - CaptureMovementInput()); - }); - // Phase K.2 — auto-enter player mode after EnterWorld // succeeds. Predicates close over GameWindow state; the // entry callback flips into player mode via the same code @@ -975,12 +955,12 @@ public sealed class GameWindow : IDisposable if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null) { float sens = _sensChase; - if (_mouseLook is not null && _mouseLook.Active) + if (_gameplayInputFrame?.MouseLookActive == true) { // DirectInput may deliver several device records before // one retail GetInput pass. Accumulate raw motion here; // the update loop filters the aggregate exactly once. - _mouseLook.QueueDelta(dx, dy); + _gameplayInputFrame.QueueRawMouseDelta(dx, dy); } else if (_rmbHeld) { @@ -1269,6 +1249,7 @@ public sealed class GameWindow : IDisposable // it fires, so the chase camera doesn't snap on top of // the fly camera mid-inspection. _debugVm.ToggleFlyMode = ToggleFlyOrChase; + _combatFeedback.ViewModel = _debugVm; _debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm); _panelHost.Register(_debugPanel); @@ -1439,6 +1420,7 @@ public sealed class GameWindow : IDisposable _panelHost = null; _vitalsVm = null; _vitalsPanel = null; + _combatFeedback.ViewModel = null; _debugVm = null; _debugPanel = null; _chatPanel = null; @@ -1626,14 +1608,7 @@ public sealed class GameWindow : IDisposable // semantics even when the retail renderer is disabled. _combatAttackController = new AcDream.App.Combat.CombatAttackController( Combat, - CanStartLiveCombatAttack, - SendLiveCombatAttack, - prepareAttackRequest: PreparePlayerForAttackRequest, - sendCancelAttack: () => LiveSession?.SendCancelAttack(), - isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle - == AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle, - playerReadyForAttack: IsPlayerReadyForCombatAttack, - autoRepeatAttack: () => _persistedGameplay.AutoRepeatAttack); + _combatAttackOperations); _combatTargetController = new AcDream.App.Combat.CombatTargetController( Combat, _selection, @@ -1702,6 +1677,7 @@ public sealed class GameWindow : IDisposable { _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); + _retainedInputCapture.Root = _uiHost.Root; _uiHost.Root.UiLocked = _persistedGameplay.LockUI; var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( _itemInteractionController, @@ -1732,7 +1708,7 @@ public sealed class GameWindow : IDisposable accountName: () => LiveSession?.Characters?.AccountName ?? _options.LiveUser ?? string.Empty, - stopCompletely: PreparePlayerForAttackRequest, + stopCompletely: _combatAttackOperations.PrepareAttackRequest, sendUntargeted: spellId => LiveSession?.SendCastUntargetedSpell(spellId), sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId), displayMessage: text => Chat.OnSystemMessage(text, 0x1Au), @@ -2631,7 +2607,7 @@ public sealed class GameWindow : IDisposable () => LiveSession, PublishLocalPhysicsTimestamps, AimTeleportDestination, - DumpMovementTruthServerEcho); + _movementTruthDiagnostics); _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController( _liveEntities, () => _playerServerGuid, @@ -2652,6 +2628,41 @@ public sealed class GameWindow : IDisposable // gated behind ACDREAM_LIVE=1 so the default run path is unchanged. _liveWorldOrigin.SetPlaceholder(centerX, centerY); _liveSessionController = new AcDream.App.Net.LiveSessionController(); + _combatAttackOperations.Bind( + new AcDream.App.Combat.LiveCombatAttackOperations( + Combat, + new AcDream.App.Combat.CombatAttackTargetSource( + _selection, + _liveEntities!, + Objects, + _localPlayerIdentity), + _gameplaySettings, + _playerControllerSlot, + _localPlayerOutbound, + _liveSessionController, + _liveSessionController, + _combatFeedback)); + AcDream.App.Input.MouseLookController? mouseLookController = + _mouseSource is not null && _mouseLookCursor is not null + ? new AcDream.App.Input.MouseLookController( + _mouseSource, + _pointerPosition, + _localPlayerMode, + _playerControllerSlot, + _cameraController!, + _chaseCameraInput, + _movementInput, + _localPlayerOutbound, + _liveSessionController, + _mouseLookCursor, + new AcDream.App.Input.EnvironmentInputMonotonicClock()) + : null; + _gameplayInputFrame = new AcDream.App.Input.GameplayInputFrameController( + _inputDispatcher, + _movementInput, + mouseLookController, + new AcDream.App.Input.CombatAttackInputFrameAdapter( + _combatAttackController!)); _streamingFrame = new AcDream.App.Streaming.StreamingFrameController( _options.LiveMode, _localPlayerMode, @@ -2801,12 +2812,7 @@ public sealed class GameWindow : IDisposable }); private void ResetSessionMouseCapture() - { - bool wasActive = _mouseLook?.Active == true; - _mouseLook?.Release(); - if (wasActive || _mouseLookSavedCursorMode.HasValue) - RestoreCursorAfterMouseLook(); - } + => _gameplayInputFrame?.ResetSession(); private void ResetSessionPlayerPresentation() { @@ -2822,10 +2828,7 @@ public sealed class GameWindow : IDisposable _playerHost = null; _chaseCamera = null; _retailChaseCamera = null; - _playerMouseDeltaX = 0f; - _rmbHeld = false; - _autoRunActive = false; - _lastMovementTruthOutbound = null; + _movementTruthDiagnostics.ResetSession(); _localPlayerShadow.Clear(); _spawnClaimRangeMemo = null; } @@ -3583,7 +3586,7 @@ public sealed class GameWindow : IDisposable || !_teleportTransit.CanBegin(teleportSequence)) return; - EndMouseLookAndRestoreCursor(); + _gameplayInputFrame?.EndMouseLook(); // A fresh sequence is a new logical transit. Discard the prior // destination and presentation before this sequence can observe them; // its destination PositionUpdate may arrive on a later network tick. @@ -3856,49 +3859,7 @@ public sealed class GameWindow : IDisposable // Input callbacks feed the current object tick. Packets already read // by Core.Net remain queued until the retail object/physics phase has // consumed that input and published its final pose. - _inputDispatcher?.Tick(); - - // Phase K.2 — mouse-look is an input source for this object's - // movement tick, so sample it before the retail CPhysics/network - // barrier just like held keyboard actions. - if (_mouseLook is not null) - { - bool wantCaptureMouse = IsUiCapturingMouse(); - if (wantCaptureMouse != _lastWantCaptureMouse) - { - if (wantCaptureMouse && _mouseLook.Active) - EndMouseLookAndRestoreCursor(); - _mouseLook.OnWantCaptureMouseChanged(wantCaptureMouse); - _lastWantCaptureMouse = wantCaptureMouse; - } - - float nowSeconds = (float)(Environment.TickCount64 / 1000.0); - if (_mouseLook.TryTakeRawSample(nowSeconds, out float rawX, out float rawY)) - { - // GetInput synthesizes (0,0) only after >0.2 s idle. - // MouseLookHandler stops drift before filtering that sample; - // the filter tail may then start a smaller Rotate again. - if (rawX == 0f && rawY == 0f) - _playerController?.StopMouseDrift(CaptureMovementInput()); - - (float filteredX, float filteredY) = - AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera - && _retailChaseCamera is not null - ? _retailChaseCamera.FilterMouseDelta( - rawX, - rawY, - weight: 0.5f, - nowSec: nowSeconds) - : (rawX, rawY); - _mouseLook.ApplyDelta(filteredX, _sensChase); - if (_retailChaseCamera is not null) - _retailChaseCamera.AdjustPitch(filteredY * 0.003f * _sensChase); - else - _chaseCamera?.AdjustPitch(filteredY * 0.003f * _sensChase); - } - } - - _combatAttackController?.Tick(); + _gameplayInputFrame!.Tick(frameTiming); // Drain pending live-session traffic AFTER streaming so any incoming // CreateObject events find their landblock already loaded in @@ -4043,10 +4004,7 @@ public sealed class GameWindow : IDisposable // character yaw (regression-prevention per K.1b plan §D); // MouseDeltaX is hardcoded 0f. RMB held still pans the chase // camera (handled in the mouse-move handler via _rmbHeld). - // The _playerMouseDeltaX field is preserved as plumbing for the - // future MMB-mouse-look behavior coming back in K.2. if (_inputDispatcher is null) return; - _playerMouseDeltaX = 0f; // defensive: ensure no leakage even if some path writes it // Retail-style held-key offset integration. Only active when // retail chase is selected; legacy camera ignores these. @@ -4069,7 +4027,7 @@ public sealed class GameWindow : IDisposable // walk speed. // * Q = AUTORUN TOGGLE: pressing Q latches forward-running // until Q is pressed again. Handled in OnInputAction; here - // we just OR _autoRunActive into the Forward flag. + // DispatcherMovementInputSource owns the autorun latch. // * Mouse never drives character yaw (K.1b regression-prevention). if (!_localPlayerFrame.TryGetPresentationAfterNetwork(out var playerFrame)) return; @@ -4128,82 +4086,12 @@ public sealed class GameWindow : IDisposable && _inputDispatcher is not null && !(DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); - private void DumpMovementTruthOutbound( - string kind, - uint sequence, - AcDream.App.Input.MovementResult result, - System.Numerics.Vector3 wirePosition, - uint wireCellId, - byte contactByte) - { - if (!DumpMoveTruthEnabled) return; - - var velocity = _playerController?.BodyVelocity ?? System.Numerics.Vector3.Zero; - _lastMovementTruthOutbound = new MovementTruthOutbound( - kind, - sequence, - System.DateTime.UtcNow, - result.Position, - result.CellId, - wirePosition, - wireCellId, - result.IsOnGround, - contactByte, - velocity); - - Console.WriteLine(System.FormattableString.Invariant($"move-truth OUT kind={kind} seq={sequence} local={Fmt(result.Position)} localCell=0x{result.CellId:X8} wire={Fmt(wirePosition)} wireCell=0x{wireCellId:X8} grounded={result.IsOnGround} contact={contactByte} vel={Fmt(velocity)} f={FmtCmd(result.ForwardCommand)} s={FmtCmd(result.SidestepCommand)} t={FmtCmd(result.TurnCommand)}")); - } - - private void DumpMovementTruthServerEcho( - AcDream.Core.Net.WorldSession.EntityPositionUpdate update, - System.Numerics.Vector3 serverWorldPosition) - { - if (!DumpMoveTruthEnabled || update.Guid != _playerServerGuid) return; - - var now = System.DateTime.UtcNow; - var localPosition = _playerController?.Position; - var localCellId = _playerController?.CellId; - var deltaLocal = localPosition.HasValue - ? serverWorldPosition - localPosition.Value - : (System.Numerics.Vector3?)null; - - string localText = localPosition.HasValue ? Fmt(localPosition.Value) : "-"; - string localCellText = localCellId.HasValue - ? System.FormattableString.Invariant($"0x{localCellId.Value:X8}") - : "-"; - string deltaLocalText = deltaLocal.HasValue ? Fmt(deltaLocal.Value) : "-"; - string deltaLocalLen = deltaLocal.HasValue - ? System.FormattableString.Invariant($"{deltaLocal.Value.Length():F3}") - : "-"; - - string lastText = "-"; - if (_lastMovementTruthOutbound is { } last) - { - var deltaOut = serverWorldPosition - last.LocalWorldPosition; - var ageMs = (now - last.TimeUtc).TotalMilliseconds; - lastText = System.FormattableString.Invariant($"{last.Kind}:{last.Sequence} ageMs={ageMs:F0} outGrounded={last.IsOnGround} outContact={last.ContactByte} outCell=0x{last.WireCellId:X8} deltaOut={Fmt(deltaOut)} distOut={deltaOut.Length():F3}"); - } - - string state = _playerController?.State.ToString() ?? "-"; - string velocityText = update.Velocity.HasValue ? Fmt(update.Velocity.Value) : "-"; - - Console.WriteLine(System.FormattableString.Invariant($"move-truth ECHO guid=0x{update.Guid:X8} server={Fmt(serverWorldPosition)} serverCell=0x{update.Position.LandblockId:X8} local={localText} localCell={localCellText} deltaLocal={deltaLocalText} distLocal={deltaLocalLen} serverVel={velocityText} state={state} lastOut={lastText}")); - } - - private static string Fmt(System.Numerics.Vector3 v) => - System.FormattableString.Invariant($"({v.X:F3},{v.Y:F3},{v.Z:F3})"); - - private static string FmtCmd(uint? command) => - command.HasValue - ? System.FormattableString.Invariant($"0x{command.Value:X8}") - : "-"; - private void OnCameraModeChanged(bool _modeBool) { - if (_mouseLook?.Active == true + if (_gameplayInputFrame?.MouseLookActive == true && _cameraController?.IsChaseMode != true) { - EndMouseLookAndRestoreCursor(); + _gameplayInputFrame?.EndMouseLook(); } if (_input is null) return; @@ -4218,8 +4106,7 @@ public sealed class GameWindow : IDisposable // the only correct gate. Cursor visible by default in chase / // orbit modes; Raw cursor only in fly mode (continuous // look-and-fly affordance). Mouse-look (raw mode) when MMB is - // held is handled separately by HideCursorForMouseLook / - // RestoreCursorAfterMouseLook. + // held is handled separately by the gameplay input owner. bool needsRawCursor = _cameraController?.IsFlyMode == true; mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal; _capturedMouse = needsRawCursor ? mouse : null; @@ -6805,8 +6692,13 @@ public sealed class GameWindow : IDisposable = AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default; private AcDream.UI.Abstractions.Panels.Settings.AudioSettings _persistedAudio = AcDream.UI.Abstractions.Panels.Settings.AudioSettings.Default; + private readonly AcDream.App.Combat.GameplaySettingsState + _gameplaySettings = new(); private AcDream.UI.Abstractions.Panels.Settings.GameplaySettings _persistedGameplay - = AcDream.UI.Abstractions.Panels.Settings.GameplaySettings.Default; + { + get => _gameplaySettings.Value; + set => _gameplaySettings.Value = value; + } private AcDream.UI.Abstractions.Panels.Settings.ChatSettings _persistedChat = AcDream.UI.Abstractions.Panels.Settings.ChatSettings.Default; private AcDream.UI.Abstractions.Panels.Settings.CharacterSettings _persistedCharacter @@ -7111,61 +7003,14 @@ public sealed class GameWindow : IDisposable // chords also fire Press on key-down + Release on key-up; we // ignore the in-between Hold ticks here (the mouse-move handler // checks _rmbHeld each frame anyway). - if (action == AcDream.UI.Abstractions.Input.InputAction.AcdreamRmbOrbitHold) - { - if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press) - _rmbHeld = _playerMode && _cameraController?.IsChaseMode == true; - else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release) - _rmbHeld = false; + if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true) return; - } // Phase K.2 — MMB-hold instant mouse-look. Press hides the // cursor + activates yaw drive; release restores. WantCapture // edge handling lives in OnUpdate; only Press needs to read it // for the initial gate (defense in depth — the dispatcher // already filters on WantCaptureMouse in OnMouseDown). - if (action == AcDream.UI.Abstractions.Input.InputAction.CameraInstantMouseLook) - { - if (_mouseLook is null) return; - if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press) - { - if (!_playerMode - || _cameraController?.IsChaseMode != true - || _playerController is not { State: AcDream.App.Input.PlayerState.InWorld }) - return; - - bool wcm = IsUiCapturingMouse(); - float nowSeconds = (float)(Environment.TickCount64 / 1000.0); - _mouseLook.Press(_lastMouseX, _lastMouseY, wcm, nowSeconds); - if (_mouseLook.Active) - { - if (_playerController is not { } controller - || !controller.BeginMouseLook(CaptureMovementInput())) - { - // Keep capture ownership atomic with the movement - // transition. A portal/state change between the outer - // gate and this call must not leave a hidden cursor - // with no active movement owner. - _mouseLook.Release(); - return; - } - - // ToggleMouseLook calls SendMovementEvent at the input - // boundary. This also preserves down+up events that occur - // before the next physics update. - TrySendPlayerMovementEvent( - controller.CaptureMovementResult(mouseLookEvent: false)); - HideCursorForMouseLook(); - } - } - else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release) - { - EndMouseLookAndRestoreCursor(); - } - return; - } - // ScrollUp / ScrollDown — emit by InputDispatcher.OnScroll on every // wheel tick. Press is the only activation type for wheel. if (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp @@ -7180,12 +7025,10 @@ public sealed class GameWindow : IDisposable // combat before the movement command enters CommandInterpreter. This // notification does not consume the input; the movement owner below // still receives it normally. - _combatAttackController?.HandleMovementInput(action, activation); - // Retail attack actions consume their full transition stream: key-down // starts the power build and key-up commits it. Handle them before the // generic Press-only gate below. - if (_combatAttackController?.HandleInputAction(action, activation) == true) + if (_gameplayInputFrame?.HandleCombatAction(action, activation) == true) return; // Every other action fires on Press only (no Release / Hold side- @@ -7212,22 +7055,8 @@ public sealed class GameWindow : IDisposable // — retail-faithful: any deliberate movement input wins. (Pressing // Forward AGAIN does NOT cancel — retail's autorun stays active // even when you press W; the two stack.) - if (action == AcDream.UI.Abstractions.Input.InputAction.MovementRunLock) - { - _autoRunActive = !_autoRunActive; + if (_gameplayInputFrame?.HandlePressedMovementAction(action) == true) return; - } - if (_autoRunActive - && (action == AcDream.UI.Abstractions.Input.InputAction.MovementBackup - || action == AcDream.UI.Abstractions.Input.InputAction.MovementStop - || action == AcDream.UI.Abstractions.Input.InputAction.MovementStrafeLeft - || action == AcDream.UI.Abstractions.Input.InputAction.MovementStrafeRight)) - { - _autoRunActive = false; - // Fall through — these actions still need their normal handling - // (e.g. Stop is currently a no-op in the switch, but keeping the - // fall-through means future logic fires). - } switch (action) { @@ -7313,7 +7142,7 @@ public sealed class GameWindow : IDisposable _cameraController.ToggleFly(); // exit fly, release cursor else if (_playerMode) { - EndMouseLookAndRestoreCursor(); + _gameplayInputFrame?.EndMouseLook(); _playerMode = false; _cameraController?.ExitChaseMode(); _playerController = null; @@ -7347,97 +7176,10 @@ public sealed class GameWindow : IDisposable _debugVm?.AddToast(text); } - private bool IsPlayerReadyForCombatAttack() - { - if (_playerController is null) - return false; - var motion = _playerController.Motion.InterpretedState; - return AcDream.Core.Combat.CombatInputPlanner.PlayerInReadyPositionForAttack( - Combat.CurrentMode, - motion.CurrentStyle, - motion.ForwardCommand); - } - - private bool CanStartLiveCombatAttack() - { - if (_liveSessionController?.IsInWorld != true) - return false; - - if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)) - { - _debugVm?.AddToast("Enter melee or missile combat first"); - Console.WriteLine("combat: attack ignored; not in melee/missile combat mode"); - return false; - } - - uint? target = GetSelectedOrClosestCombatTarget(); - if (target is null) - { - _debugVm?.AddToast("No monster target"); - Console.WriteLine("combat: attack ignored; no creature target found"); - return false; - } - - return true; - } - - private bool SendLiveCombatAttack( - AcDream.Core.Combat.AttackHeight height, - float power) - { - if (!CanStartLiveCombatAttack()) - return false; - - AcDream.Core.Net.WorldSession? session = LiveSession; - if (session is null) - return false; - - uint target = _selection.SelectedObjectId!.Value; - power = Math.Clamp(power, 0f, 1f); - if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile) - { - session.SendMissileAttack(target, height, power); - Console.WriteLine($"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}"); - } - else - { - session.SendMeleeAttack(target, height, power); - Console.WriteLine($"combat: melee attack target=0x{target:X8} height={height} power={power:F2}"); - } - return true; - } - - /// - /// Retail ClientCombatSystem::StartAttackRequest (0x0056C040) - /// calls MaybeStopCompletely, whose successful path sends the - /// stopped movement state synchronously. Doing this in the host preserves - /// wire ordering: ACE sees the final player heading before the later - /// targeted attack request. - /// - private void PreparePlayerForAttackRequest() - { - if (_playerController is not { } controller - || !controller.PrepareForAttackRequest()) - return; - - TrySendPlayerMovementEvent( - controller.CaptureMovementResult(mouseLookEvent: false)); - } - internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState( AcDream.App.Input.MovementResult result) => AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result); - private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result) - => _localPlayerOutbound.TrySendMovement( - LiveSession, - _playerController, - result); - - private uint? GetSelectedOrClosestCombatTarget() - => _selectionInteractions?.GetSelectedOrClosestCombatTarget( - _persistedGameplay.AutoTarget); - /// /// Resolves retail's combat-camera target. ClientCombatSystem:: /// UpdateTargetTracking (0x0056A950) enables CameraSet::TrackTarget only @@ -7531,12 +7273,11 @@ public sealed class GameWindow : IDisposable } else { - EndMouseLookAndRestoreCursor(); + _gameplayInputFrame?.EndMouseLook(); _cameraController?.ExitChaseMode(); _playerController = null; _chaseCamera = null; _retailChaseCamera = null; - _playerMouseDeltaX = 0f; } } @@ -8009,11 +7750,6 @@ public sealed class GameWindow : IDisposable Aspect = _window!.Size.X / (float)_window.Size.Y, CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine), }; - // K.1b: _playerMouseDeltaX is no longer consumed by - // MovementInput, but we still reset it here so any stale - // accumulated value from a previous session doesn't leak - // into a future code path that re-enables mouse-yaw. - _playerMouseDeltaX = 0f; _cameraController?.EnterChaseMode(_chaseCamera, _retailChaseCamera); // K-fix1 (2026-04-26): latch the "we have entered chase at least // once" flag so the live-mode pre-login render gate stops @@ -8024,103 +7760,6 @@ public sealed class GameWindow : IDisposable return true; } - /// - /// Both presentation stacks participate in the same gameplay-input gate. - /// A retained retail panel capturing the pointer must suspend instant - /// mouse-look just as an ImGui developer panel does. - /// - private bool IsUiCapturingMouse() - => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) - || (_uiHost?.Root.WantsMouse ?? false); - - private AcDream.App.Input.MovementInput CaptureMovementInput() - { - if (_inputDispatcher is null) - return default; - - bool walking = _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementWalkMode); - bool forward = _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementForward); - return new AcDream.App.Input.MovementInput( - Forward: forward || _autoRunActive, - Backward: _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementBackup), - StrafeLeft: _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementStrafeLeft), - StrafeRight: _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementStrafeRight), - TurnLeft: _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft), - TurnRight: _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight), - Run: !walking, - Jump: _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementJump)); - } - - /// - /// Single idempotent teardown for every mouse-look exit path. Retail sends - /// the stopped movement state synchronously; doing so before nulling the - /// player controller preserves ACE's final authoritative heading. - /// - private void EndMouseLookAndRestoreCursor() - { - bool stateWasActive = _mouseLook?.Active == true; - _mouseLook?.Release(); - - if (_playerController is { } controller - && controller.EndMouseLook(CaptureMovementInput())) - { - TrySendPlayerMovementEvent( - controller.CaptureMovementResult(mouseLookEvent: false)); - } - - if (stateWasActive || _mouseLookSavedCursorMode.HasValue) - RestoreCursorAfterMouseLook(); - } - - /// - /// Phase K.2: hide the system cursor while MMB instant mouse-look is - /// held. Saves the previous CursorMode so - /// can put it back exactly. Skips when no mouse / no input — tests - /// and headless runs stay clean. - /// - private void HideCursorForMouseLook() - { - if (_input is null) return; - var mouse = _input.Mice.FirstOrDefault(); - if (mouse is null) return; - // Save previous mode (Normal in orbit, Raw in chase/fly) so the - // exact pre-hold mode is restored on release. - _mouseLookSavedCursorMode = mouse.Cursor.CursorMode; - mouse.Cursor.CursorMode = CursorMode.Hidden; - } - - /// - /// Phase K.2: restore the saved cursor mode after MMB instant - /// mouse-look ends. Called from the Release branch and from the - /// WantCaptureMouse-edge suspend path so the cursor never gets - /// stuck hidden. - /// - private void RestoreCursorAfterMouseLook() - { - if (_input is null) return; - var mouse = _input.Mice.FirstOrDefault(); - if (mouse is null) return; - if (_mouseLookSavedCursorMode is { } saved) - { - mouse.Cursor.CursorMode = saved; - _mouseLookSavedCursorMode = null; - } - else - { - // Defense in depth: never observed the saved value, fall - // back to Normal so the user always gets a visible cursor. - mouse.Cursor.CursorMode = CursorMode.Normal; - } - } - /// /// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies /// the currently-active mode's sensitivity (chase / fly / orbit) by the @@ -8389,11 +8028,12 @@ public sealed class GameWindow : IDisposable ]), new ResourceShutdownStage("session dependents", [ - new("mouse capture", EndMouseLookAndRestoreCursor), + new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()), new("retail UI", () => { _retailUiRuntime?.Dispose(); _retailUiRuntime = null; + _retainedInputCapture.Root = null; _uiHost = null; }), new("combat target", () => @@ -8540,7 +8180,7 @@ public sealed class GameWindow : IDisposable private void OnFocusChanged(bool focused) { if (!focused) - EndMouseLookAndRestoreCursor(); + _gameplayInputFrame?.EndMouseLook(); } public void Dispose() diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index 4c9dec74..2c5bd267 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -220,9 +220,9 @@ public sealed class InputDispatcher if (action == InputAction.None) return false; // While a text field owns the keyboard ("write mode"), held game actions read as // released: typing "swd" must not move the character. This is the polling-path twin - // of the WantCaptureKeyboard gate on Fired actions. NOTE: this suppresses KEY-driven - // movement only — latched state that isn't a key (e.g. autorun, ORed into Forward at - // the call site) keeps driving the character, so chat doesn't cancel autorun. + // of the WantCaptureKeyboard gate on Fired actions. This suppresses physical keys; + // the App movement owner separately pauses all movement for devtools capture while + // allowing retained-chat capture to preserve the retail autorun latch. if (_mouse.WantCaptureKeyboard) return false; if (_automationHeldActions.Contains(action)) return true; foreach (var b in _bindings.ForAction(action)) diff --git a/tests/AcDream.App.Tests/Combat/CombatAttackTargetSourceTests.cs b/tests/AcDream.App.Tests/Combat/CombatAttackTargetSourceTests.cs new file mode 100644 index 00000000..80c3b5ea --- /dev/null +++ b/tests/AcDream.App.Tests/Combat/CombatAttackTargetSourceTests.cs @@ -0,0 +1,209 @@ +using System.Numerics; +using AcDream.App.Combat; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Combat; + +public sealed class CombatAttackTargetSourceTests +{ + private const uint Player = 0x5000_0001u; + + [Fact] + public void ExplicitSelectedHostileIsAcceptedWithoutAutoTarget() + { + var harness = new Harness(); + const uint target = 0x7000_0001u; + harness.Add(target, new Vector3(2f, 0f, 0f), attackable: true); + harness.Selection.Select(target, SelectionChangeSource.Keyboard); + + Assert.Equal( + target, + harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: false)); + } + + [Fact] + public void AutoTargetUsesNearestEligibleLiveHostile() + { + var harness = new Harness(); + const uint valid = 0x7000_0010u; + harness.Add(valid, new Vector3(8f, 0f, 0f), attackable: true); + harness.Add(0x7000_0011u, new Vector3(2f, 0f, 0f), attackable: false); + WorldEntity dead = harness.Add( + 0x7000_0012u, + new Vector3(3f, 0f, 0f), + attackable: true); + harness.Runtime.SetAnimationRuntime( + dead.ServerGuid, + new Animation(dead, MotionCommand.Dead)); + WorldEntity hidden = harness.Add( + 0x7000_0013u, + new Vector3(4f, 0f, 0f), + attackable: true); + Assert.True(harness.Runtime.TryApplyState( + new SetState.Parsed( + hidden.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden), + InstanceSequence: 1, + StateSequence: 2), + out _)); + WorldEntity pending = harness.Add( + 0x7000_0014u, + new Vector3(5f, 0f, 0f), + attackable: true); + Assert.True(harness.Runtime.WithdrawLiveEntityProjection(pending.ServerGuid)); + + uint? selected = harness.Targets.GetSelectedOrClosestCombatTarget( + autoTarget: true); + + Assert.Equal(valid, selected); + Assert.Equal(valid, harness.Selection.SelectedObjectId); + } + + [Fact] + public void MissingAutoTargetClearsAnInvalidSelection() + { + var harness = new Harness(); + const uint nonHostile = 0x7000_0020u; + harness.Add(nonHostile, Vector3.UnitX, attackable: false); + harness.Selection.Select(nonHostile, SelectionChangeSource.Keyboard); + + Assert.Null(harness.Targets.GetSelectedOrClosestCombatTarget(autoTarget: true)); + + Assert.Null(harness.Selection.SelectedObjectId); + } + + private sealed class Harness + { + public ClientObjectTable Objects { get; } = new(); + public SelectionState Selection { get; } = new(); + public LiveEntityRuntime Runtime { get; } + public CombatAttackTargetSource Targets { get; } + + public Harness() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101_FFFFu, + new LandBlock(), + Array.Empty())); + Runtime = new LiveEntityRuntime(spatial, new Resources()); + var identity = new LocalPlayerIdentityState { ServerGuid = Player }; + Targets = new CombatAttackTargetSource( + Selection, + Runtime, + Objects, + identity); + Add(Player, Vector3.Zero, attackable: false, isPlayer: true); + } + + public WorldEntity Add( + uint guid, + Vector3 position, + bool attackable, + bool isPlayer = false) + { + Runtime.RegisterLiveEntity(Spawn(guid)); + WorldEntity entity = Runtime.MaterializeLiveEntity( + guid, + 0x0101_0001u, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = position, + Rotation = Quaternion.Identity, + Scale = 1f, + MeshRefs = [], + })!; + uint flags = attackable ? SelectedObjectHealthPolicy.BfAttackable : 0u; + if (isPlayer) + flags |= SelectedObjectHealthPolicy.BfPlayer; + Objects.AddOrUpdate(new ClientObject + { + ObjectId = guid, + Name = $"Object {guid:X8}", + Type = ItemType.Creature, + PublicWeenieBitfield = flags, + }); + return entity; + } + } + + private sealed record Animation(WorldEntity Entity, uint CurrentMotion) + : ILiveEntityAnimationRuntime; + + private sealed class Resources : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) + { + } + public void Unregister(WorldEntity entity) + { + } + } + + private static WorldSession.EntitySpawn Spawn(uint guid) + { + var position = new CreateObject.ServerPosition( + 0x0101_0001u, + 10f, + 10f, + 5f, + 1f, + 0f, + 0f, + 0f); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x0200_0001u, + MotionTableId: 0x0900_0001u, + 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(1, 1, 1, 1, 0, 1, 0, 1, 1)); + return new WorldSession.EntitySpawn( + guid, + position, + 0x0200_0001u, + [], + [], + [], + null, + null, + "fixture", + null, + null, + 0x0900_0001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} diff --git a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs new file mode 100644 index 00000000..41109816 --- /dev/null +++ b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs @@ -0,0 +1,151 @@ +using AcDream.App.Combat; +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.Core.Combat; +using AcDream.Core.Net; + +namespace AcDream.App.Tests.Combat; + +public sealed class LiveCombatAttackOperationsTests +{ + [Fact] + public void UnboundSlotFailsClosedAndRejectsASecondOwner() + { + var slot = new CombatAttackOperationsSlot(); + var first = new FakeOperations(); + var second = new FakeOperations(); + + Assert.False(slot.CanStartAttack()); + Assert.False(slot.SendAttack(AttackHeight.Medium, 0.5f)); + Assert.False(slot.PlayerReadyForAttack); + + slot.Bind(first); + slot.Bind(first); + Assert.Throws(() => slot.Bind(second)); + } + + [Fact] + public void CanStartRequiresLiveWorldBeforeTargetResolution() + { + Harness harness = CreateHarness(); + harness.Targets.Acquired = 0x1234u; + + Assert.False(harness.Owner.CanStartAttack()); + + Assert.Equal(0, harness.Targets.ResolveCount); + } + + [Fact] + public void UnsupportedCombatModeUsesTypedFeedbackSink() + { + Harness harness = CreateHarness(inWorld: true); + + Assert.False(harness.Owner.CanStartAttack()); + + Assert.Equal(["Enter melee or missile combat first"], harness.Feedback.Messages); + Assert.Equal(0, harness.Targets.ResolveCount); + } + + [Fact] + public void SupportedModeResolvesTargetUsingLiveAutoTargetSetting() + { + Harness harness = CreateHarness(inWorld: true); + harness.Combat.SetCombatMode(CombatMode.Melee); + harness.Settings.AutoTarget = true; + harness.Targets.Acquired = 0x1234u; + harness.Targets.SelectedObjectId = 0x1234u; + + Assert.True(harness.Owner.CanStartAttack()); + + Assert.True(harness.Targets.LastAutoTarget); + Assert.Equal(1, harness.Targets.ResolveCount); + Assert.Empty(harness.Feedback.Messages); + } + + [Fact] + public void MissingTargetPreservesRetailUserFeedback() + { + Harness harness = CreateHarness(inWorld: true); + harness.Combat.SetCombatMode(CombatMode.Missile); + + Assert.False(harness.Owner.CanStartAttack()); + + Assert.Equal(["No monster target"], harness.Feedback.Messages); + } + + private static Harness CreateHarness(bool inWorld = false) + { + var combat = new CombatState(); + var targets = new FakeTargets(); + var settings = new FakeSettings(); + var player = new LocalPlayerControllerSlot(); + var live = new FakeLiveSource { IsInWorld = inWorld }; + var feedback = new FakeFeedback(); + var outbound = new LocalPlayerOutboundController( + static (_, _, _, _, _, _) => { }); + var owner = new LiveCombatAttackOperations( + combat, + targets, + settings, + player, + outbound, + live, + live, + feedback); + return new Harness(owner, combat, targets, settings, feedback); + } + + private sealed record Harness( + LiveCombatAttackOperations Owner, + CombatState Combat, + FakeTargets Targets, + FakeSettings Settings, + FakeFeedback Feedback); + + private sealed class FakeTargets : ICombatAttackTargetSource + { + public uint? SelectedObjectId { get; set; } + public uint? Acquired { get; set; } + public int ResolveCount { get; private set; } + public bool LastAutoTarget { get; private set; } + public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) + { + ResolveCount++; + LastAutoTarget = autoTarget; + return Acquired; + } + } + + private sealed class FakeSettings : ICombatGameplaySettingsSource + { + public bool AutoTarget { get; set; } + public bool AutoRepeatAttack { get; set; } + } + + private sealed class FakeLiveSource : ILiveInWorldSource, ILiveWorldSessionSource + { + public bool IsInWorld { get; set; } + public WorldSession? CurrentSession => null; + } + + private sealed class FakeFeedback : ICombatFeedbackSink + { + public List Messages { get; } = []; + public void Show(string message) => Messages.Add(message); + } + + private sealed class FakeOperations : ICombatAttackOperations + { + public bool CanStartAttack() => true; + public void PrepareAttackRequest() + { + } + public bool SendAttack(AttackHeight height, float power) => true; + public void SendCancelAttack() + { + } + public bool IsDualWield => true; + public bool PlayerReadyForAttack => true; + public bool AutoRepeatAttack => true; + } +} diff --git a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs new file mode 100644 index 00000000..ceee9f13 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs @@ -0,0 +1,152 @@ +using AcDream.App.Input; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class DispatcherMovementInputSourceTests +{ + [Fact] + public void UnboundSourceCapturesNeutralInput() + { + var source = new DispatcherMovementInputSource(); + + Assert.Equal(default, source.Capture()); + } + + [Fact] + public void CapturesDispatcherHeldStateAndRetailWalkModifier() + { + var (dispatcher, _, _) = CreateDispatcher(); + var source = new DispatcherMovementInputSource(); + source.Bind(dispatcher); + + Assert.True(dispatcher.TrySetAutomationActionHeld( + InputAction.MovementForward, + held: true)); + Assert.True(dispatcher.TrySetAutomationActionHeld( + InputAction.MovementWalkMode, + held: true)); + + MovementInput captured = source.Capture(); + + Assert.True(captured.Forward); + Assert.False(captured.Run); + } + + [Fact] + public void RetainedKeyboardCaptureSilencesHeldKeysButDoesNotCancelAutorun() + { + var (dispatcher, _, mouse) = CreateDispatcher(); + var source = new DispatcherMovementInputSource(); + source.Bind(dispatcher); + dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, held: true); + Assert.True(source.HandlePressedAction(InputAction.MovementRunLock)); + + mouse.WantCaptureKeyboard = true; + MovementInput captured = source.Capture(); + + Assert.True(captured.Forward); + Assert.True(source.AutoRunActive); + + source.HandlePressedAction(InputAction.MovementRunLock); + captured = source.Capture(); + Assert.False(captured.Forward); + } + + [Fact] + public void DevToolsKeyboardCapturePausesAutorunWithoutClearingItsLatch() + { + var capture = new FakeCapture(); + var (dispatcher, _, _) = CreateDispatcher(); + var source = new DispatcherMovementInputSource(capture); + source.Bind(dispatcher); + source.HandlePressedAction(InputAction.MovementRunLock); + + capture.DevToolsWantCaptureKeyboard = true; + Assert.Equal(default, source.Capture()); + Assert.True(source.AutoRunActive); + + capture.DevToolsWantCaptureKeyboard = false; + Assert.True(source.Capture().Forward); + } + + [Theory] + [InlineData(InputAction.MovementBackup)] + [InlineData(InputAction.MovementStop)] + [InlineData(InputAction.MovementStrafeLeft)] + [InlineData(InputAction.MovementStrafeRight)] + public void RetailCancelActionsClearAutorun(InputAction action) + { + var source = new DispatcherMovementInputSource(); + source.HandlePressedAction(InputAction.MovementRunLock); + + Assert.False(source.HandlePressedAction(action)); + + Assert.False(source.AutoRunActive); + } + + [Fact] + public void ForwardDoesNotCancelAutorunAndResetDoes() + { + var source = new DispatcherMovementInputSource(); + source.HandlePressedAction(InputAction.MovementRunLock); + + Assert.False(source.HandlePressedAction(InputAction.MovementForward)); + Assert.True(source.AutoRunActive); + + source.ResetSession(); + Assert.False(source.AutoRunActive); + } + + [Fact] + public void BindingIsIdempotentOnlyForTheSameDispatcher() + { + var source = new DispatcherMovementInputSource(); + var (first, _, _) = CreateDispatcher(); + var (second, _, _) = CreateDispatcher(); + + source.Bind(first); + source.Bind(first); + + Assert.Throws(() => source.Bind(second)); + } + + private static (InputDispatcher Dispatcher, FakeKeyboard Keyboard, FakeMouse Mouse) + CreateDispatcher() + { + var keyboard = new FakeKeyboard(); + var mouse = new FakeMouse(); + return (new InputDispatcher(keyboard, mouse, new KeyBindings()), keyboard, mouse); + } + + private sealed class FakeKeyboard : IKeyboardSource + { +#pragma warning disable CS0067 + public event Action? KeyDown; + public event Action? KeyUp; +#pragma warning restore CS0067 + public bool IsHeld(Key key) => false; + public ModifierMask CurrentModifiers => ModifierMask.None; + } + + private sealed class FakeMouse : IMouseSource + { +#pragma warning disable CS0067 + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool WantCaptureKeyboard { get; set; } + public bool WantCaptureMouse { get; set; } + public bool IsHeld(MouseButton button) => false; + } + + private sealed class FakeCapture : IInputCaptureSource + { + public bool WantCaptureMouse { get; set; } + public bool WantCaptureKeyboard { get; set; } + public bool DevToolsWantCaptureKeyboard { get; set; } + } +} diff --git a/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs new file mode 100644 index 00000000..ee4e984c --- /dev/null +++ b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs @@ -0,0 +1,194 @@ +using AcDream.App.Input; +using AcDream.App.Update; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class GameplayInputFrameControllerTests +{ + [Fact] + public void TickPreservesDispatcherMouseLookCombatOrder() + { + var calls = new List(); + var keyboard = new HeldKeyboard(Key.W); + var mouseSource = new NullMouse(); + var bindings = new KeyBindings(); + bindings.Add(new Binding( + new KeyChord(Key.W, ModifierMask.None), + InputAction.MovementForward, + ActivationType.Hold)); + var dispatcher = new InputDispatcher(keyboard, mouseSource, bindings); + keyboard.Press(Key.W); + dispatcher.Fired += (_, activation) => + { + if (activation == ActivationType.Hold) + calls.Add("dispatcher"); + }; + var mouseLook = new FakeMouseLook(calls); + var combat = new FakeCombat(calls); + var controller = new GameplayInputFrameController( + dispatcher, + new DispatcherMovementInputSource(), + mouseLook, + combat); + + controller.Tick(new UpdateFrameTiming(1d / 60d, 1f / 60f, 1d / 60d)); + + Assert.Equal(["dispatcher", "mouse-look", "combat"], calls); + } + + [Fact] + public void MissingInputDevicesStillTicksCombat() + { + var calls = new List(); + var controller = new GameplayInputFrameController( + dispatcher: null, + new DispatcherMovementInputSource(), + mouseLook: null, + new FakeCombat(calls)); + + controller.Tick(new UpdateFrameTiming(0d, 0f, 0d)); + + Assert.Equal(["combat"], calls); + } + + [Fact] + public void CombatActionFirstAbortsAutomaticAttackThenRoutesAction() + { + var calls = new List(); + var controller = new GameplayInputFrameController( + dispatcher: null, + new DispatcherMovementInputSource(), + mouseLook: null, + new FakeCombat(calls, consumes: true)); + + bool consumed = controller.HandleCombatAction( + InputAction.MovementForward, + ActivationType.Press); + + Assert.True(consumed); + Assert.Equal(["combat-movement", "combat-action"], calls); + } + + [Fact] + public void ResetSessionReleasesMouseLookAndAutorun() + { + var calls = new List(); + var movement = new DispatcherMovementInputSource(); + movement.HandlePressedAction(InputAction.MovementRunLock); + var mouseLook = new FakeMouseLook(calls); + var controller = new GameplayInputFrameController( + dispatcher: null, + movement, + mouseLook, + new FakeCombat(calls)); + + controller.ResetSession(); + + Assert.False(movement.AutoRunActive); + Assert.Equal(["mouse-reset"], calls); + } + + [Fact] + public void PointerAndRawDeltaRemainOwnedByMouseLookController() + { + var calls = new List(); + var mouseLook = new FakeMouseLook(calls, active: true, consumes: true); + var controller = new GameplayInputFrameController( + dispatcher: null, + new DispatcherMovementInputSource(), + mouseLook, + new FakeCombat(calls)); + + Assert.True(controller.MouseLookActive); + Assert.True(controller.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Press)); + controller.QueueRawMouseDelta(3f, -2f); + controller.EndMouseLook(); + + Assert.Equal(["mouse-action", "mouse-delta", "mouse-lifecycle-end"], calls); + } + + private sealed class FakeCombat : ICombatInputFrameController + { + private readonly List _calls; + private readonly bool _consumes; + + public FakeCombat(List calls, bool consumes = false) + { + _calls = calls; + _consumes = consumes; + } + + public void Tick() => _calls.Add("combat"); + public void HandleMovementInput(InputAction action, ActivationType activation) => + _calls.Add("combat-movement"); + public bool HandleInputAction(InputAction action, ActivationType activation) + { + _calls.Add("combat-action"); + return _consumes; + } + } + + private sealed class FakeMouseLook : IMouseLookInputFrameController + { + private readonly List _calls; + private readonly bool _consumes; + + public FakeMouseLook( + List calls, + bool active = false, + bool consumes = false) + { + _calls = calls; + _consumes = consumes; + Active = active; + } + + public bool Active { get; } + public void Tick() => _calls.Add("mouse-look"); + public bool HandlePointerAction(InputAction action, ActivationType activation) + { + _calls.Add("mouse-action"); + return _consumes; + } + public void QueueRawDelta(float dx, float dy) => _calls.Add("mouse-delta"); + public void EndAndRestoreCursor() => _calls.Add("mouse-end"); + public void EndForLifecycle() => _calls.Add("mouse-lifecycle-end"); + public void ResetSession() => _calls.Add("mouse-reset"); + } + + private sealed class HeldKeyboard : IKeyboardSource + { + private readonly HashSet _held = []; + + public HeldKeyboard(params Key[] held) + { + foreach (Key key in held) + _held.Add(key); + } + + public event Action? KeyDown; +#pragma warning disable CS0067 + public event Action? KeyUp; +#pragma warning restore CS0067 + public bool IsHeld(Key key) => _held.Contains(key); + public ModifierMask CurrentModifiers => ModifierMask.None; + public void Press(Key key) => KeyDown?.Invoke(key, ModifierMask.None); + } + + private sealed class NullMouse : IMouseSource + { +#pragma warning disable CS0067 + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool IsHeld(MouseButton button) => false; + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => false; + } +} diff --git a/tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs b/tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs new file mode 100644 index 00000000..6c6e2889 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/MouseLookControllerTests.cs @@ -0,0 +1,309 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.App.Rendering; +using AcDream.Core.Net; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class MouseLookControllerTests +{ + [Fact] + public void InstantMouseLookRequiresPlayerChaseAndInWorldState() + { + Harness harness = CreateHarness(); + + Assert.True(harness.Owner.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Press)); + Assert.False(harness.Owner.Active); + + harness.Mode.IsPlayerMode = true; + harness.EnterChase(); + harness.Player.State = PlayerState.PortalSpace; + harness.Owner.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Press); + Assert.False(harness.Owner.Active); + + harness.Player.State = PlayerState.InWorld; + harness.Owner.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Press); + + Assert.True(harness.Owner.Active); + Assert.Equal(1, harness.Cursor.HideCount); + } + + [Fact] + public void ReleaseEndsMouseLookAndRestoresCursorExactlyOnce() + { + Harness harness = CreateActiveHarness(); + + harness.Owner.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Release); + harness.Owner.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Release); + + Assert.False(harness.Owner.Active); + Assert.Equal(1, harness.Cursor.RestoreCount); + Assert.False(harness.Player.EndMouseLook(new MovementInput())); + } + + [Fact] + public void UiCaptureTransitionEndsActiveMouseLook() + { + Harness harness = CreateActiveHarness(); + + harness.Mouse.WantCaptureMouse = true; + harness.Owner.Tick(); + + Assert.False(harness.Owner.Active); + Assert.Equal(1, harness.Cursor.RestoreCount); + Assert.False(harness.Player.EndMouseLook(new MovementInput())); + } + + [Fact] + public void SessionResetRestoresCaptureAndClearsRmbOrbitWithoutWireSend() + { + Harness harness = CreateActiveHarness(); + harness.Owner.HandlePointerAction( + InputAction.AcdreamRmbOrbitHold, + ActivationType.Press); + Assert.True(harness.Chase.RmbOrbitHeld); + + harness.Owner.ResetSession(); + + Assert.False(harness.Owner.Active); + Assert.False(harness.Chase.RmbOrbitHeld); + Assert.Equal(1, harness.Cursor.RestoreCount); + Assert.Null(harness.Session.CurrentSession); + } + + [Fact] + public void SixRawSamplesCrossRetailExtentGateAndSubmitTurnMotion() + { + Harness harness = CreateActiveHarness(); + + for (int i = 0; i < 6; i++) + { + harness.Clock.NowSeconds += 0.01f; + harness.Owner.QueueRawDelta(2f, 0f); + harness.Owner.Tick(); + } + + MovementResult result = harness.Player.Update( + PhysicsBody.MinQuantum + 0.001f, + new MovementInput()); + Assert.NotNull(result.TurnCommand); + } + + [Fact] + public void StrictIdleBoundaryStopsMouseDriftOnlyAfterPointTwoSeconds() + { + bool previousRetailCamera = CameraDiagnostics.UseRetailChaseCamera; + CameraDiagnostics.UseRetailChaseCamera = false; + try + { + Harness harness = CreateActiveHarness(); + for (int i = 0; i < 6; i++) + { + harness.Clock.NowSeconds += 0.01f; + harness.Owner.QueueRawDelta(2f, 0f); + harness.Owner.Tick(); + } + + MovementResult turning = harness.Player.Update( + PhysicsBody.MinQuantum + 0.001f, + new MovementInput(Run: true)); + Assert.NotNull(turning.TurnCommand); + + float boundary = harness.Clock.NowSeconds + + MouseLookState.IdleZeroDelaySeconds; + harness.Clock.NowSeconds = boundary; + harness.Owner.Tick(); + MovementResult held = harness.Player.Update( + PhysicsBody.MinQuantum + 0.001f, + new MovementInput(Run: true)); + Assert.NotNull(held.TurnCommand); + + harness.Clock.NowSeconds = boundary + 0.0001f; + harness.Owner.Tick(); + MovementResult stopped = harness.Player.Update( + PhysicsBody.MinQuantum + 0.001f, + new MovementInput(Run: true)); + Assert.Null(stopped.TurnCommand); + } + finally + { + CameraDiagnostics.UseRetailChaseCamera = previousRetailCamera; + } + } + + [Fact] + public void LifecycleExitAlsoClearsRmbOrbitLatch() + { + Harness harness = CreateActiveHarness(); + harness.Owner.HandlePointerAction( + InputAction.AcdreamRmbOrbitHold, + ActivationType.Press); + Assert.True(harness.Chase.RmbOrbitHeld); + + harness.Owner.EndForLifecycle(); + + Assert.False(harness.Owner.Active); + Assert.False(harness.Chase.RmbOrbitHeld); + Assert.False(harness.Player.EndMouseLook(new MovementInput())); + } + + [Fact] + public void RmbOrbitLatchOnlyEngagesInPlayerChaseMode() + { + Harness harness = CreateHarness(); + + harness.Owner.HandlePointerAction( + InputAction.AcdreamRmbOrbitHold, + ActivationType.Press); + Assert.False(harness.Chase.RmbOrbitHeld); + + harness.Mode.IsPlayerMode = true; + harness.EnterChase(); + harness.Owner.HandlePointerAction( + InputAction.AcdreamRmbOrbitHold, + ActivationType.Press); + Assert.True(harness.Chase.RmbOrbitHeld); + + harness.Owner.HandlePointerAction( + InputAction.AcdreamRmbOrbitHold, + ActivationType.Release); + Assert.False(harness.Chase.RmbOrbitHeld); + } + + private static Harness CreateActiveHarness() + { + Harness harness = CreateHarness(); + harness.Mode.IsPlayerMode = true; + harness.EnterChase(); + harness.Owner.HandlePointerAction( + InputAction.CameraInstantMouseLook, + ActivationType.Press); + Assert.True(harness.Owner.Active); + return harness; + } + + private static Harness CreateHarness() + { + PlayerMovementController player = CreatePlayer(); + var mode = new LocalPlayerModeState(); + var slot = new LocalPlayerControllerSlot { Controller = player }; + var camera = new CameraController(new OrbitCamera(), new FlyCamera()); + var chase = new ChaseCameraInputState(); + var mouse = new FakeMouse(); + var pointer = new PointerPositionState { X = 320f, Y = 240f }; + var movement = new FixedMovementInputSource(); + var cursor = new FakeCursor(); + var clock = new FakeClock(); + var session = new NullSessionSource(); + var outbound = new LocalPlayerOutboundController( + static (_, _, _, _, _, _) => { }); + var owner = new MouseLookController( + mouse, + pointer, + mode, + slot, + camera, + chase, + movement, + outbound, + session, + cursor, + clock); + return new Harness(owner, mode, player, camera, chase, mouse, cursor, clock, session); + } + + private static PlayerMovementController CreatePlayer() + { + var engine = new PhysicsEngine(); + var heights = new byte[81]; + Array.Fill(heights, (byte)50); + var heightTable = new float[256]; + for (int i = 0; i < heightTable.Length; i++) + heightTable[i] = i; + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u); + return controller; + } + + private sealed record Harness( + MouseLookController Owner, + LocalPlayerModeState Mode, + PlayerMovementController Player, + CameraController Camera, + ChaseCameraInputState Chase, + FakeMouse Mouse, + FakeCursor Cursor, + FakeClock Clock, + NullSessionSource Session) + { + public void EnterChase() => + Camera.EnterChaseMode(new ChaseCamera(), new RetailChaseCamera()); + } + + private sealed class FixedMovementInputSource : IMovementInputSource + { + public MovementInput Capture() => new(Run: true); + } + + private sealed class FakeClock : IInputMonotonicClock + { + public float NowSeconds { get; set; } + } + + private sealed class FakeCursor : IMouseLookCursor + { + public int HideCount { get; private set; } + public int RestoreCount { get; private set; } + public bool HasSavedMode { get; private set; } + public void Hide() + { + HideCount++; + HasSavedMode = true; + } + public void Restore() + { + RestoreCount++; + HasSavedMode = false; + } + } + + private sealed class NullSessionSource : ILiveWorldSessionSource + { + public WorldSession? CurrentSession => null; + } + + private sealed class FakeMouse : IMouseSource + { +#pragma warning disable CS0067 + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool WantCaptureMouse { get; set; } + public bool WantCaptureKeyboard { get; set; } + public bool IsHeld(MouseButton button) => false; + } +} diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs index 058c4694..794f40e1 100644 --- a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs @@ -18,7 +18,7 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => true, getController: () => controller, - captureInput: () => new MovementInput(Forward: true), + movementInput: Input(() => new MovementInput(Forward: true)), resolveLocalEntityId: () => 7u, handleTargeting: () => calls.Add("target"), isHidden: () => false, @@ -82,7 +82,7 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => controller is not null, getController: () => controller, - captureInput: () => new MovementInput(Forward: true), + movementInput: Input(() => new MovementInput(Forward: true)), resolveLocalEntityId: () => 9u, handleTargeting: () => { }, isHidden: () => false, @@ -112,7 +112,7 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => true, getController: () => controller, - captureInput: () => new MovementInput(), + movementInput: Input(() => new MovementInput()), resolveLocalEntityId: () => 7u, handleTargeting: () => { }, isHidden: () => false, @@ -143,7 +143,7 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => true, getController: () => controller, - captureInput: () => new MovementInput(Forward: true), + movementInput: Input(() => new MovementInput(Forward: true)), resolveLocalEntityId: () => 7u, handleTargeting: () => positionSeenByTargetManager = controller.Position, isHidden: () => false, @@ -169,11 +169,11 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => true, getController: () => controller, - captureInput: () => + movementInput: Input(() => { inputCaptures++; return new MovementInput(Forward: true); - }, + }), resolveLocalEntityId: () => 7u, handleTargeting: () => throw new InvalidOperationException( "Frozen object advanced its manager tail."), @@ -204,7 +204,7 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => true, getController: () => controller, - captureInput: () => new MovementInput(), + movementInput: Input(() => new MovementInput()), resolveLocalEntityId: () => 7u, handleTargeting: () => { }, isHidden: () => true, @@ -231,11 +231,11 @@ public sealed class RetailLocalPlayerFrameControllerTests var local = new RetailLocalPlayerFrameController( canPresentPlayer: () => true, getController: () => controller, - captureInput: () => + movementInput: Input(() => { inputCaptures++; return new MovementInput(Forward: true); - }, + }), resolveLocalEntityId: () => 7u, handleTargeting: () => targeting++, isHidden: () => false, @@ -293,4 +293,13 @@ public sealed class RetailLocalPlayerFrameControllerTests Assert.True(clock.IsActive); return controller; } + + private static IMovementInputSource Input(Func capture) => + new TestMovementInputSource(capture); + + private sealed class TestMovementInputSource(Func capture) + : IMovementInputSource + { + public MovementInput Capture() => capture(); + } } diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 770f334d..dd1a69eb 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -230,12 +230,15 @@ public sealed class UpdateFrameOrchestratorTests Assert.DoesNotContain(ownerFields, field => field.FieldType == typeof(GameWindow)); if (owner == typeof(AcDream.App.Input.RetailLocalPlayerFrameController)) { - // Checkpoint B deliberately leaves this single legacy callback - // composition for D/F, which own input capture and the mutable - // player-mode/controller slot. No other phase owner may add one. + // Checkpoint D replaced input capture with its typed owner. + // Checkpoint F still owns the remaining player presentation + // callback composition. No other phase owner may add one. Assert.Contains( ownerFields, field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + Assert.DoesNotContain( + ownerFields, + field => field.FieldType == typeof(Func)); continue; } Assert.DoesNotContain( @@ -364,7 +367,7 @@ public sealed class UpdateFrameOrchestratorTests AssertAppearsInOrder( source, "_streamingFrame.Tick();", - "_inputDispatcher?.Tick();", + "_gameplayInputFrame!.Tick(frameTiming);", "_liveFrameCoordinator.Tick(frameDelta);"); Assert.DoesNotContain("_streamingController.Tick(observerCx", source, StringComparison.Ordinal); @@ -373,6 +376,152 @@ public sealed class UpdateFrameOrchestratorTests Assert.DoesNotContain("DrainRescued()", source, StringComparison.Ordinal); } + [Fact] + public void GameWindow_DelegatesTheCompleteGameplayInputFrameBody() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.Contains("new AcDream.App.Input.GameplayInputFrameController(", source); + Assert.Equal(1, CountOccurrences(source, "_gameplayInputFrame!.Tick(frameTiming);")); + Assert.DoesNotContain("_inputDispatcher?.Tick()", source, + StringComparison.Ordinal); + Assert.DoesNotContain("TryTakeRawSample", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_combatAttackController?.Tick()", source, + StringComparison.Ordinal); + Assert.DoesNotContain("CaptureMovementInput", source, + StringComparison.Ordinal); + Assert.DoesNotContain("EndMouseLookAndRestoreCursor", source, + StringComparison.Ordinal); + Assert.DoesNotContain("HideCursorForMouseLook", source, + StringComparison.Ordinal); + Assert.DoesNotContain("RestoreCursorAfterMouseLook", source, + StringComparison.Ordinal); + Assert.DoesNotContain("CanStartLiveCombatAttack", source, + StringComparison.Ordinal); + Assert.DoesNotContain("SendLiveCombatAttack", source, + StringComparison.Ordinal); + Assert.DoesNotContain("PreparePlayerForAttackRequest", source, + StringComparison.Ordinal); + Assert.DoesNotContain("DumpMovementTruthOutbound", source, + StringComparison.Ordinal); + Assert.DoesNotContain("DumpMovementTruthServerEcho", source, + StringComparison.Ordinal); + Assert.DoesNotContain("wantCaptureMouse: ()", source, + StringComparison.Ordinal); + Assert.Equal(5, CountOccurrences( + source, + "_gameplayInputFrame?.EndMouseLook();")); + Assert.Contains( + "new(\"mouse capture\", () => _gameplayInputFrame?.EndMouseLook())", + source, + StringComparison.Ordinal); + AssertAppearsInOrder( + source, + "_teleportTransit.CanBegin(teleportSequence)", + "_gameplayInputFrame?.EndMouseLook();", + "_teleportTransit.QueueStart(teleportSequence)"); + AssertAppearsInOrder( + source, + "private void OnFocusChanged(bool focused)", + "if (!focused)", + "_gameplayInputFrame?.EndMouseLook();"); + } + + [Fact] + public void GameplayInputOwnersUseTypedSeamsWithoutGameWindowBackReferences() + { + Type[] owners = + [ + typeof(AcDream.App.Input.GameplayInputFrameController), + typeof(AcDream.App.Input.DispatcherMovementInputSource), + typeof(AcDream.App.Input.MouseLookController), + ]; + + foreach (Type owner in owners) + { + FieldInfo[] fields = owner.GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + Assert.DoesNotContain( + typeof(AcDream.App.Input.MouseLookController).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.FieldType == typeof(Silk.NET.Input.IMouse)); + + FieldInfo combatOperations = Assert.Single( + typeof(AcDream.App.Combat.CombatAttackController).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_operations"); + Assert.Equal( + typeof(AcDream.App.Combat.ICombatAttackOperations), + combatOperations.FieldType); + Assert.DoesNotContain( + typeof(AcDream.App.Combat.LiveCombatAttackOperations).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + FieldInfo combatTargets = Assert.Single( + typeof(AcDream.App.Combat.LiveCombatAttackOperations).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_targets"); + Assert.Equal( + typeof(AcDream.App.Combat.ICombatAttackTargetSource), + combatTargets.FieldType); + Assert.DoesNotContain( + typeof(AcDream.App.Combat.CombatAttackTargetSource).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + Assert.DoesNotContain( + typeof(AcDream.App.Combat.CombatAttackTargetSource).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.FieldType + == typeof(AcDream.App.Interaction.SelectionInteractionController)); + + FieldInfo outboundDiagnostics = Assert.Single( + typeof(AcDream.App.Input.LocalPlayerOutboundController).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_diagnostic"); + Assert.Equal( + typeof(AcDream.App.Input.IMovementTruthDiagnosticSink), + outboundDiagnostics.FieldType); + Assert.DoesNotContain( + typeof(AcDream.App.Input.MovementTruthDiagnosticController).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + + string mouseLookSource = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Input", + "MouseLookController.cs")); + AssertAppearsInOrder( + mouseLookSource, + "controller?.StopMouseDrift", + "retail.FilterMouseDelta", + "_state.ApplyDelta"); + + string localPlayerSource = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Input", + "RetailLocalPlayerFrameController.cs")); + Assert.DoesNotContain("Func", localPlayerSource, + StringComparison.Ordinal); + Assert.Contains("IMovementInputSource", localPlayerSource, + StringComparison.Ordinal); + } + private static UpdateFrameOrchestrator Create( List calls, RecordingTeardown? teardown = null, From eeb0f6b45c96579a9c563c13410e0bb8c8e99eb4 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 02:50:15 +0200 Subject: [PATCH 77/98] refactor(runtime): extract local teleport and player mode Move local teleport, player-mode, animation, shadow, and sealed-dungeon lifetimes out of GameWindow. Make player-mode entry transactional and isolate approach completions by controller lifetime and approach generation so stale callbacks cannot affect replacements. Co-authored-by: Codex --- ...ndow-slice-6-update-frame-orchestration.md | 14 + .../Input/GameplayInputFrameController.cs | 4 +- .../Input/LocalPlayerAnimationController.cs | 75 ++ .../Input/LocalPlayerRuntimeState.cs | 71 + src/AcDream.App/Input/PlayerModeAutoEntry.cs | 10 +- src/AcDream.App/Input/PlayerModeController.cs | 572 ++++++++ .../Input/PlayerMovementController.cs | 49 +- .../PlayerApproachCompletionState.cs | 141 ++ .../PlayerInteractionMovementSink.cs | 17 +- .../SelectionInteractionController.cs | 42 +- src/AcDream.App/Physics/EntityPhysicsHost.cs | 30 + .../LiveEntityNetworkUpdateController.cs | 23 +- .../Physics/LocalPlayerShadowSynchronizer.cs | 117 ++ src/AcDream.App/Rendering/CameraController.cs | 23 + src/AcDream.App/Rendering/GameWindow.cs | 1174 +++-------------- .../LocalPlayerTeleportController.cs | 761 +++++++++++ .../Streaming/SealedDungeonCellClassifier.cs | 44 + .../World/LiveEntityHydrationPorts.cs | 11 +- ...PlayerMovementPlacementTransactionTests.cs | 93 ++ .../PlayerApproachCompletionStateTests.cs | 79 ++ .../PlayerInteractionMovementSinkTests.cs | 13 +- .../SelectionInteractionControllerTests.cs | 61 +- .../Rendering/CameraControllerTests.cs | 26 + .../LocalPlayerTeleportControllerTests.cs | 747 +++++++++++ .../GameWindowLiveEntityCompositionTests.cs | 11 +- .../LiveEntityPhysicsHostOwnershipTests.cs | 34 + .../LiveEntityWorldOriginCoordinatorTests.cs | 10 +- .../World/UpdateFrameOrchestratorTests.cs | 107 +- .../Input/AutoEnterPlayerModeTests.cs | 25 +- 29 files changed, 3311 insertions(+), 1073 deletions(-) create mode 100644 src/AcDream.App/Input/LocalPlayerAnimationController.cs create mode 100644 src/AcDream.App/Input/PlayerModeController.cs create mode 100644 src/AcDream.App/Interaction/PlayerApproachCompletionState.cs create mode 100644 src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs create mode 100644 src/AcDream.App/Streaming/LocalPlayerTeleportController.cs create mode 100644 src/AcDream.App/Streaming/SealedDungeonCellClassifier.cs create mode 100644 tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs create mode 100644 tests/AcDream.App.Tests/Interaction/PlayerApproachCompletionStateTests.cs create mode 100644 tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md index 8388e1d8..4b2af346 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md @@ -425,6 +425,20 @@ one named legacy owner only; D/F must remove the exception before final cutover. inbound delete/recreate generation safety; - preserve the completed portal visuals and world-reveal barrier unchanged. +**Completed 2026-07-22.** `LocalPlayerTeleportController` now owns the full +local transit lifetime and its immutable network handoff, while +`PlayerModeController` is the sole writer of controller, physics-host, +player-mode, and chase-camera slots. Local animation, shadow synchronization, +sealed-dungeon classification, viewport aspect, and approach-completion +handoff are focused collaborators rather than window callbacks. Portal entry +retires the one-shot auto-entry guard, host publication is transactional, and +teleport work revalidates the live generation after every reentrant edge. +Focused App/Core tests cover destination readiness, forced holds, same-cell +placement, LoginComplete order, reset/disposal, GUID reuse, reentrancy, +host rollback, auto-entry idempotence, and completion-mailbox ordering. The +full App suite is green at 2,814 passed / 3 skipped. `GameWindow.cs` is 7,351 +lines at this checkpoint, down from the slice baseline of 15,723. + ### F/G — camera, cutover, and deletion - extract player-mode enter/exit/toggle and current-owner state before camera diff --git a/src/AcDream.App/Input/GameplayInputFrameController.cs b/src/AcDream.App/Input/GameplayInputFrameController.cs index 35bf16c6..aad93671 100644 --- a/src/AcDream.App/Input/GameplayInputFrameController.cs +++ b/src/AcDream.App/Input/GameplayInputFrameController.cs @@ -31,7 +31,9 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle /// Owns the complete pre-object input frame: held semantic dispatch, one raw /// mouse-look sample, and combat attack intent. /// -internal sealed class GameplayInputFrameController : IGameplayInputFramePhase +internal sealed class GameplayInputFrameController + : IGameplayInputFramePhase, + AcDream.App.Streaming.ILocalPlayerTeleportInputLifetime { private readonly InputDispatcher? _dispatcher; private readonly DispatcherMovementInputSource _movement; diff --git a/src/AcDream.App/Input/LocalPlayerAnimationController.cs b/src/AcDream.App/Input/LocalPlayerAnimationController.cs new file mode 100644 index 00000000..c9a09d7a --- /dev/null +++ b/src/AcDream.App/Input/LocalPlayerAnimationController.cs @@ -0,0 +1,75 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.World; +using AcDream.Core.Physics.Motion; +using DatReaderWriter.Types; + +namespace AcDream.App.Input; + +/// +/// Owns the local player's sequencer root-motion and deferred-hook seams. +/// The movement controller borrows these methods when player mode is entered; +/// no animation lifetime is stored in the window host. +/// +internal sealed class LocalPlayerAnimationController +{ + private readonly LiveEntityRuntime _liveEntities; + private readonly ILocalPlayerIdentitySource _identity; + private readonly LiveEntityAnimationRuntimeView _animations; + private readonly LiveEntityAnimationPresenter _presenter; + private readonly AnimationHookFrameQueue _hooks; + + public LocalPlayerAnimationController( + LiveEntityRuntime liveEntities, + ILocalPlayerIdentitySource identity, + LiveEntityAnimationRuntimeView animations, + LiveEntityAnimationPresenter presenter, + AnimationHookFrameQueue hooks) + { + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _animations = animations ?? throw new ArgumentNullException(nameof(animations)); + _presenter = presenter ?? throw new ArgumentNullException(nameof(presenter)); + _hooks = hooks ?? throw new ArgumentNullException(nameof(hooks)); + } + + public void AdvanceRoot(float deltaSeconds, MotionDeltaFrame output) + { + ArgumentNullException.ThrowIfNull(output); + output.Reset(); + + uint playerGuid = _identity.ServerGuid; + if (!_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity) + || !_animations.TryGetValue(entity.Id, out var animation) + || animation.Sequencer is not { } sequencer + || !_liveEntities.ShouldAdvanceRootRuntime(playerGuid) + || _liveEntities.IsHidden(playerGuid)) + { + return; + } + + if (_liveEntities.TryGetRecord(playerGuid, out LiveEntityRecord record)) + _presenter.PrepareAnimation(record, animation); + + Frame rootFrame = animation.RootMotionScratch; + rootFrame.Origin = System.Numerics.Vector3.Zero; + rootFrame.Orientation = System.Numerics.Quaternion.Identity; + animation.PreparedSequenceFrames = animation.CaptureSequenceFrames( + sequencer.Advance(deltaSeconds, rootFrame)); + animation.SequenceAdvancedBeforeAnimationPass = true; + + output.Origin = rootFrame.Origin; + output.Orientation = rootFrame.Orientation; + } + + public void CaptureHooks() + { + uint playerGuid = _identity.ServerGuid; + if (_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity) + && _animations.TryGetValue(entity.Id, out var animation) + && animation.Sequencer is { } sequencer) + { + _hooks.Capture(animation.Entity.Id, sequencer); + } + } +} diff --git a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs index 57c4236c..14e10965 100644 --- a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs +++ b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs @@ -1,4 +1,5 @@ using AcDream.Core.Physics; +using AcDream.App.Physics; namespace AcDream.App.Input; @@ -29,6 +30,20 @@ internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource public PlayerMovementController? Controller { get; set; } } +internal interface ILocalPlayerPhysicsHostSource +{ + EntityPhysicsHost? Host { get; } +} + +/// +/// The one mutable local physics-host slot. Player-mode lifecycle owns +/// assignment; teleport and update owners receive the read-only seam. +/// +internal sealed class LocalPlayerPhysicsHostSlot : ILocalPlayerPhysicsHostSource +{ + public EntityPhysicsHost? Host { get; set; } +} + internal interface ILocalPlayerModeSource { bool IsPlayerMode { get; } @@ -50,3 +65,59 @@ internal sealed class LocalPlayerModeState : ILocalPlayerModeSource ChaseModeEverEntered = false; } } + +/// +/// Session-scoped server-authoritative movement skills. Player-description +/// delivery and player-mode construction share this owner so rebuilding the +/// local physics controller cannot fall back to stale defaults. +/// +internal sealed class LocalPlayerSkillState +{ + public int RunSkill { get; private set; } = -1; + public int JumpSkill { get; private set; } = -1; + public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0; + + public void Update( + int runSkill, + int jumpSkill, + PlayerMovementController? controller) + { + if (runSkill >= 0) + RunSkill = runSkill; + if (jumpSkill >= 0) + JumpSkill = jumpSkill; + ApplyTo(controller); + } + + public bool ApplyTo(PlayerMovementController? controller) + { + if (controller is null || !IsComplete) + return false; + + controller.SetCharacterSkills(RunSkill, JumpSkill); + return true; + } + + public void ResetSession() + { + RunSkill = -1; + JumpSkill = -1; + } +} + +internal interface IViewportAspectSource +{ + float Aspect { get; } +} + +/// Framebuffer aspect published by the window host. +internal sealed class ViewportAspectState : IViewportAspectSource +{ + public float Aspect { get; private set; } = 16f / 9f; + + public void Update(int width, int height) + { + if (width > 0 && height > 0) + Aspect = width / (float)height; + } +} diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs index 2895bee2..a23b20cc 100644 --- a/src/AcDream.App/Input/PlayerModeAutoEntry.cs +++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs @@ -45,6 +45,7 @@ public sealed class PlayerModeAutoEntry private readonly Func _isPlayerEntityPresent; private readonly Func _isPlayerControllerReady; private readonly Func _isWorldReady; + private readonly Func _isPlayerModeActive; private readonly Action _enterPlayerMode; private bool _armed; @@ -76,13 +77,15 @@ public sealed class PlayerModeAutoEntry Func isPlayerEntityPresent, Func isPlayerControllerReady, Func isWorldReady, - Action enterPlayerMode) + Action enterPlayerMode, + Func? isPlayerModeActive = null) { _isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld)); _isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent)); _isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady)); _isWorldReady = isWorldReady ?? throw new ArgumentNullException(nameof(isWorldReady)); _enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode)); + _isPlayerModeActive = isPlayerModeActive ?? (() => false); } /// True iff would still fire if the @@ -112,6 +115,11 @@ public sealed class PlayerModeAutoEntry public bool TryEnter() { if (!_armed) return false; + if (_isPlayerModeActive()) + { + _armed = false; + return false; + } if (!_isLiveInWorld()) return false; if (!_isPlayerEntityPresent()) return false; if (!_isPlayerControllerReady()) return false; diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs new file mode 100644 index 00000000..126f29da --- /dev/null +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -0,0 +1,572 @@ +using System.Numerics; +using AcDream.App.Interaction; +using AcDream.App.Net; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Content; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; + +namespace AcDream.App.Input; + +/// +/// Sole writer for local player-mode, movement-controller, physics-host, and +/// chase-camera lifetimes. The window host wires this owner but never mutates +/// those slots directly. +/// +internal sealed class PlayerModeController : ILocalPlayerTeleportModeOperations +{ + private readonly LocalPlayerModeState _mode; + private readonly LocalPlayerControllerSlot _controllerSlot; + private readonly LocalPlayerPhysicsHostSlot _hostSlot; + private readonly ChaseCameraInputState _chase; + private readonly CameraController _camera; + private readonly PhysicsEngine _physics; + private readonly LiveEntityRuntime _liveEntities; + private readonly ILocalPlayerIdentitySource _identity; + private readonly LiveWorldOriginState _origin; + private readonly ILiveEntityMotionRuntimeBindings _motionBindings; + private readonly IDatReaderWriter _dats; + private readonly object _datLock; + private readonly PhysicsDataCache _physicsDataCache; + private readonly LiveEntityAnimationRuntimeView _animations; + private readonly LocalPlayerAnimationController _animation; + private readonly LocalPlayerShadowSynchronizer _shadow; + private readonly IPlayerApproachCompletionLifetimeOwner _approachCompletions; + private readonly ILocalPlayerTeleportInputLifetime _input; + private readonly ILiveInWorldSource _session; + private readonly MovementTruthDiagnosticController _movementDiagnostics; + private readonly LocalPlayerSkillState _skills; + private readonly IViewportAspectSource _viewport; + private PlayerModeAutoEntry? _autoEntry; + private IPlayerApproachCompletionSink? _approachLifetime; + + public PlayerModeController( + LocalPlayerModeState mode, + LocalPlayerControllerSlot controllerSlot, + LocalPlayerPhysicsHostSlot hostSlot, + ChaseCameraInputState chase, + CameraController camera, + PhysicsEngine physics, + LiveEntityRuntime liveEntities, + ILocalPlayerIdentitySource identity, + LiveWorldOriginState origin, + ILiveEntityMotionRuntimeBindings motionBindings, + IDatReaderWriter dats, + object datLock, + PhysicsDataCache physicsDataCache, + LiveEntityAnimationRuntimeView animations, + LocalPlayerAnimationController animation, + LocalPlayerShadowSynchronizer shadow, + IPlayerApproachCompletionLifetimeOwner approachCompletions, + ILocalPlayerTeleportInputLifetime input, + ILiveInWorldSource session, + MovementTruthDiagnosticController movementDiagnostics, + LocalPlayerSkillState skills, + IViewportAspectSource viewport) + { + _mode = mode ?? throw new ArgumentNullException(nameof(mode)); + _controllerSlot = controllerSlot ?? throw new ArgumentNullException(nameof(controllerSlot)); + _hostSlot = hostSlot ?? throw new ArgumentNullException(nameof(hostSlot)); + _chase = chase ?? throw new ArgumentNullException(nameof(chase)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _motionBindings = motionBindings ?? throw new ArgumentNullException(nameof(motionBindings)); + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); + _physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache)); + _animations = animations ?? throw new ArgumentNullException(nameof(animations)); + _animation = animation ?? throw new ArgumentNullException(nameof(animation)); + _shadow = shadow ?? throw new ArgumentNullException(nameof(shadow)); + _approachCompletions = approachCompletions + ?? throw new ArgumentNullException(nameof(approachCompletions)); + _input = input ?? throw new ArgumentNullException(nameof(input)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _movementDiagnostics = movementDiagnostics + ?? throw new ArgumentNullException(nameof(movementDiagnostics)); + _skills = skills ?? throw new ArgumentNullException(nameof(skills)); + _viewport = viewport ?? throw new ArgumentNullException(nameof(viewport)); + } + + public PlayerMovementController? Controller => _controllerSlot.Controller; + public Matrix4x4 Projection => _camera.Active.Projection; + + public void BindAutoEntry(PlayerModeAutoEntry autoEntry) + { + ArgumentNullException.ThrowIfNull(autoEntry); + if (_autoEntry is not null) + throw new InvalidOperationException("Player-mode auto-entry is already bound."); + _autoEntry = autoEntry; + } + + public void Toggle() + { + if (!_session.IsInWorld) + return; + + _autoEntry?.Cancel(); + if (_mode.IsPlayerMode) + Exit(); + else + _ = TryEnter("Tab"); + } + + public void EnterFromAutoEntry() + { + if (TryEnter("auto-entry")) + { + Console.WriteLine( + $"live: auto-entered player mode for 0x{_identity.ServerGuid:X8}"); + } + } + + public bool TryEnterPortalSpace() + { + // Portal activation supersedes the login-only guard. Otherwise the + // later auto-entry tick can rebuild this just-created controller and + // replace PortalSpace with the default InWorld state. + _autoEntry?.Cancel(); + if (Controller is null && !TryEnter("teleport")) + return false; + + if (Controller is not { } controller) + return false; + + controller.State = PlayerState.PortalSpace; + return true; + } + + public void EnterWorld() + { + if (Controller is { } controller) + controller.State = PlayerState.InWorld; + } + + public void Exit() + { + var failures = new List(); + try { _input.EndMouseLook(); } + catch (Exception error) { failures.Add(error); } + try { _camera.ExitChaseMode(); } + catch (Exception error) { failures.Add(error); } + try { RetireApproachLifetime(); } + catch (Exception error) { failures.Add(error); } + _mode.IsPlayerMode = false; + _controllerSlot.Controller = null; + _hostSlot.Host = null; + _chase.Legacy = null; + _chase.Retail = null; + + if (failures.Count != 0) + throw new AggregateException("Player-mode exit was incomplete.", failures); + } + + public void ToggleFlyOrChase() + { + _autoEntry?.Cancel(); + if (_camera.IsFlyMode + && _mode.IsPlayerMode + && _chase.Legacy is { } legacy) + { + _chase.Retail ??= new RetailChaseCamera + { + Aspect = legacy.Aspect, + CollisionProbe = new PhysicsCameraCollisionProbe(_physics), + }; + _camera.EnterChaseMode(legacy, _chase.Retail); + return; + } + + _camera.ToggleFly(); + } + + public void ResetSession() + { + _autoEntry?.Cancel(); + var failures = new List(); + try { _camera.ExitChaseMode(); } + catch (Exception error) { failures.Add(error); } + try { RetireApproachLifetime(); } + catch (Exception error) { failures.Add(error); } + _mode.ResetSession(); + _controllerSlot.Controller = null; + _hostSlot.Host = null; + _chase.Legacy = null; + _chase.Retail = null; + try { _movementDiagnostics.ResetSession(); } + catch (Exception error) { failures.Add(error); } + try { _shadow.ResetSession(); } + catch (Exception error) { failures.Add(error); } + + if (failures.Count != 0) + throw new AggregateException("Player-mode session reset was incomplete.", failures); + } + + private bool TryEnter(string loggingTag) + { + uint playerGuid = _identity.ServerGuid; + if (!_liveEntities.MaterializedWorldEntities.TryGetValue( + playerGuid, + out WorldEntity? playerEntity)) + { + Console.WriteLine( + $"live: {loggingTag} — player entity 0x{playerGuid:X8} not found yet"); + return false; + } + + if (!_liveEntities.TryGetRecord(playerGuid, out LiveEntityRecord playerRecord)) + { + Console.WriteLine( + $"live: {loggingTag} — player record 0x{playerGuid:X8} not found yet"); + return false; + } + + BuildControllerAndCamera( + loggingTag, + playerGuid, + playerEntity, + playerRecord); + return true; + } + + private void BuildControllerAndCamera( + string loggingTag, + uint playerGuid, + WorldEntity playerEntity, + LiveEntityRecord playerRecord) + { + IPlayerApproachCompletionSink approachLifetime = + _approachCompletions.BeginControllerLifetime(); + bool lifetimeCommitted = false; + bool cameraAttempted = false; + bool shadowAttempted = false; + CameraController.CameraState priorCamera = _camera.CaptureState(); + LocalPlayerShadowState.Snapshot? priorShadow = _shadow.Capture(); + try + { + var controller = new PlayerMovementController( + _physics, + playerRecord.ObjectClock); + 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 = () => + { + 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) + Console.WriteLine($"[autowalk-end] reason=complete err={error}"); + if (error == WeenieError.None) + approachLifetime.PublishNaturalCompletion(); + else + approachLifetime.PublishCancellation(error); + }; + 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.GetMaxSpeed(), + 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 = EntityPhysicsHost.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 (_skills.ApplyTo(controller)) + { + Console.WriteLine( + $"live: {loggingTag} — applied server skills " + + $"run={_skills.RunSkill} jump={_skills.JumpSkill}"); + } + + ApplyStepHeights(controller, playerEntity); + uint initialCellId = ResolveInitialCell(playerGuid, playerEntity); + + if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation) + && animation.Sequencer is { } sequencer) + { + controller.AttachCycleVelocityAccessor(() => sequencer.CurrentVelocity); + controller.ObjectScale = animation.Scale; + controller.AttachAnimationRootMotionSource( + _animation.AdvanceRoot, + _animation.CaptureHooks); + controller.Motion.RemoveLinkAnimations = + sequencer.Manager.HandleEnterWorld; + controller.Motion.InitializeMotionTables = + sequencer.Manager.InitializeState; + controller.Motion.CheckForCompletedMotions = + sequencer.Manager.CheckForCompletedMotions; + controller.Motion.DefaultSink = + new MotionTableDispatchSink(sequencer); + } + + 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 + { + Aspect = _viewport.Aspect, + CollisionProbe = new PhysicsCameraCollisionProbe(_physics), + }; + cameraAttempted = true; + _camera.EnterChaseMode(legacyCamera, retailCamera); + + EntityPhysicsHost stableAfterCamera = + EntityPhysicsHost.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, + playerEntity.Rotation, + initial.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 = EntityPhysicsHost.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; + _chase.Legacy = legacyCamera; + _chase.Retail = retailCamera; + _mode.IsPlayerMode = true; + _mode.ChaseModeEverEntered = true; + _approachLifetime = approachLifetime; + lifetimeCommitted = true; + } + catch (Exception error) + { + var failures = new List { error }; + if (shadowAttempted) + { + try { _shadow.Restore(playerEntity, priorShadow); } + catch (Exception cleanupError) { failures.Add(cleanupError); } + } + if (cameraAttempted) + { + try { _camera.RestoreState(priorCamera); } + catch (Exception cleanupError) { failures.Add(cleanupError); } + } + + _mode.IsPlayerMode = false; + _controllerSlot.Controller = null; + _hostSlot.Host = null; + _chase.Legacy = null; + _chase.Retail = null; + + if (failures.Count != 1) + throw new AggregateException( + "Player-mode entry failed and rollback was incomplete.", + failures); + throw; + } + finally + { + if (!lifetimeCommitted) + _approachCompletions.RetireControllerLifetime(approachLifetime); + } + } + + private void RetireApproachLifetime() + { + if (_approachLifetime is not { } lifetime) + return; + _approachLifetime = null; + _approachCompletions.RetireControllerLifetime(lifetime); + } + + private void ApplyStepHeights( + PlayerMovementController controller, + WorldEntity playerEntity) + { + if ((playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) + { + DatReaderWriter.DBObjs.Setup? setup; + lock (_datLock) + setup = _dats.Get( + playerEntity.SourceGfxObjOrSetupId); + if (setup is not null) + _physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, setup); + controller.StepUpHeight = setup is { StepUpHeight: > 0f } + ? setup.StepUpHeight + : 0.4f; + controller.StepDownHeight = setup is { StepDownHeight: > 0f } + ? setup.StepDownHeight + : 0.4f; + 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})"); + return; + } + + controller.StepUpHeight = 0.4f; + controller.StepDownHeight = 0.4f; + 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/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 7f6cbf00..0450a319 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -1092,7 +1092,10 @@ public sealed class PlayerMovementController // route through here. When PhysicsDiagnostics.ProbeCellEnabled is // off this collapses to a single bool-compare + assignment — zero // logging cost. - private void UpdateCellId(uint newCellId, string reason) + private void UpdateCellId( + uint newCellId, + string reason, + bool publishRenderRoot = true) { if (newCellId != CellId && PhysicsDiagnostics.ProbeCellEnabled) { @@ -1109,7 +1112,8 @@ public sealed class PlayerMovementController // jump-looping near the cottage doorway clobbered the render root every tick → the render // rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear // color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]). - _physics.UpdatePlayerCurrCell(newCellId); + if (publishRenderRoot) + _physics.UpdatePlayerCurrCell(newCellId); } public void SetPosition(Vector3 pos, uint cellId) @@ -1127,11 +1131,47 @@ public sealed class PlayerMovementController /// directly via SnapToCell rather than delta-syncing through the setter. /// public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal) + => SetPositionCore( + pos, + cellId, + cellLocal, + publishSharedState: true); + + /// + /// Builds a new local controller's canonical body pose without publishing + /// the renderer's global current cell or mutating the incarnation-stable + /// PositionManager. Player-mode entry commits those shared edges only + /// after camera, shadow, and host preparation succeeds. + /// + internal void PreparePositionForCommit( + Vector3 pos, + uint cellId, + Vector3 cellLocal) => + SetPositionCore( + pos, + cellId, + cellLocal, + publishSharedState: false); + + internal void CommitPreparedPosition() + { + _physics.UpdatePlayerCurrCell(CellId); + PositionManager?.UnStick(); + } + + private void SetPositionCore( + Vector3 pos, + uint cellId, + Vector3 cellLocal, + bool publishSharedState) { _body.SnapToCell(cellId, pos, cellLocal); _prevPhysicsPos = pos; _currPhysicsPos = pos; - UpdateCellId(_body.CellPosition.ObjCellId, "teleport"); + UpdateCellId( + _body.CellPosition.ObjCellId, + "teleport", + publishSharedState); // Treat as grounded after a server-side position snap. _body.TransientState = TransientStateFlags.Contact @@ -1164,7 +1204,8 @@ public sealed class PlayerMovementController // tears down any active stick. (StopInterpolating/UnConstrain have no // armed acdream counterparts — no local-player InterpolationManager, // constraint leash unarmed per #167.) - PositionManager?.UnStick(); + if (publishSharedState) + PositionManager?.UnStick(); // Reset the edge tracker: the stop wiped the motion state, so keys // still physically held must re-fire as press edges on the next // Update (matches the pre-W6 level-triggered behavior of walking diff --git a/src/AcDream.App/Interaction/PlayerApproachCompletionState.cs b/src/AcDream.App/Interaction/PlayerApproachCompletionState.cs new file mode 100644 index 00000000..404a0e81 --- /dev/null +++ b/src/AcDream.App/Interaction/PlayerApproachCompletionState.cs @@ -0,0 +1,141 @@ +using AcDream.Core.Physics; + +namespace AcDream.App.Interaction; + +internal interface IPlayerApproachCompletionSink +{ + void PublishNaturalCompletion(); + void PublishCancellation(WeenieError error); +} + +internal interface IPlayerApproachCompletionLifetimeOwner +{ + IPlayerApproachCompletionSink BeginControllerLifetime(); + void RetireControllerLifetime(IPlayerApproachCompletionSink lifetime); +} + +internal interface IPlayerApproachTokenSource +{ + bool TryBeginApproach(out PlayerApproachToken token); +} + +internal readonly record struct PlayerApproachToken( + ulong ControllerLifetime, + ulong ApproachGeneration); + +internal readonly record struct PlayerApproachCompletion( + PlayerApproachToken Token, + bool IsNatural, + WeenieError Error); + +/// +/// Same-thread mailbox between local MoveTo completion and the selection/ +/// item interaction phase. It deliberately owns no query, item, transport, +/// window, or callback reference. +/// +internal sealed class PlayerApproachCompletionState + : IPlayerApproachCompletionLifetimeOwner, + IPlayerApproachTokenSource +{ + private readonly Queue _pending = new(); + private ControllerLifetime? _active; + private ulong _nextLifetime; + + public IPlayerApproachCompletionSink BeginControllerLifetime() + { + if (_active is not null) + throw new InvalidOperationException( + "A player approach-completion lifetime is already active."); + + var lifetime = new ControllerLifetime(this, ++_nextLifetime); + _active = lifetime; + return lifetime; + } + + public void RetireControllerLifetime(IPlayerApproachCompletionSink lifetime) + { + ArgumentNullException.ThrowIfNull(lifetime); + if (!ReferenceEquals(_active, lifetime) + || lifetime is not ControllerLifetime retiring) + return; + + PurgeLifetime(retiring.LifetimeId); + if (retiring.TryGetCurrentToken(out PlayerApproachToken token)) + { + _pending.Enqueue(new PlayerApproachCompletion( + token, + IsNatural: false, + WeenieError.ActionCancelled)); + } + _active = null; + } + + public bool TryBeginApproach(out PlayerApproachToken token) + { + if (_active is null) + { + token = default; + return false; + } + + token = _active.BeginApproach(); + return true; + } + + public bool TryTake(out PlayerApproachCompletion completion) => + _pending.TryDequeue(out completion); + + public void Clear() + { + _active = null; + _pending.Clear(); + } + + private void Publish( + ControllerLifetime lifetime, + bool isNatural, + WeenieError error) + { + if (!ReferenceEquals(_active, lifetime) + || !lifetime.TryGetCurrentToken(out PlayerApproachToken token)) + { + return; + } + + _pending.Enqueue(new PlayerApproachCompletion(token, isNatural, error)); + } + + private void PurgeLifetime(ulong lifetimeId) + { + int retained = _pending.Count; + for (int i = 0; i < retained; i++) + { + PlayerApproachCompletion completion = _pending.Dequeue(); + if (completion.Token.ControllerLifetime != lifetimeId) + _pending.Enqueue(completion); + } + } + + private sealed class ControllerLifetime( + PlayerApproachCompletionState owner, + ulong lifetimeId) : IPlayerApproachCompletionSink + { + private ulong _approachGeneration; + public ulong LifetimeId => lifetimeId; + + public PlayerApproachToken BeginApproach() => + new(lifetimeId, ++_approachGeneration); + + public bool TryGetCurrentToken(out PlayerApproachToken token) + { + token = new PlayerApproachToken(lifetimeId, _approachGeneration); + return _approachGeneration != 0; + } + + public void PublishNaturalCompletion() => + owner.Publish(this, isNatural: true, WeenieError.None); + + public void PublishCancellation(WeenieError error) => + owner.Publish(this, isNatural: false, error); + } +} diff --git a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs index 7c8cfb50..f958010d 100644 --- a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs +++ b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs @@ -12,7 +12,9 @@ internal interface IPlayerInteractionMovementSink /// owner arm completion state before an already-facing TurnTo can finish /// synchronously, without letting the preceding cancellation clear it. /// - bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null); + bool BeginApproach( + InteractionApproach approach, + Action? armAfterCancel = null); } /// @@ -20,13 +22,18 @@ internal interface IPlayerInteractionMovementSink /// the same MovementManager used by authoritative movement packets. /// internal sealed class PlayerInteractionMovementSink( - Func player) + Func player, + IPlayerApproachTokenSource approachTokens) : IPlayerInteractionMovementSink { private readonly Func _player = player ?? throw new ArgumentNullException(nameof(player)); + private readonly IPlayerApproachTokenSource _approachTokens = approachTokens + ?? throw new ArgumentNullException(nameof(approachTokens)); - public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null) + public bool BeginApproach( + InteractionApproach approach, + Action? armAfterCancel = null) { PlayerMovementController? controller = _player(); if (controller?.MoveTo is null) @@ -57,7 +64,9 @@ internal sealed class PlayerInteractionMovementSink( // intent is armed so cancellation of the preceding move cannot clear // the new request; the internal second call is then a retail no-op. controller.Movement.CancelMoveTo(WeenieError.ActionCancelled); - armAfterCancel?.Invoke(); + if (!_approachTokens.TryBeginApproach(out PlayerApproachToken token)) + return false; + armAfterCancel?.Invoke(token); // P1's wire MoveToObject store marks the command non-autonomous. // The speculative local install must do the same or the next raw-input diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 47def6ee..794cf8b8 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -22,6 +22,7 @@ internal sealed class SelectionInteractionController private readonly ISelectionInteractionTransport _transport; private readonly IPlayerInteractionMovementSink _movement; private readonly OutboundInteractionQueue _outbound = new(); + private readonly PlayerApproachCompletionState _approachCompletions; private readonly Action? _toast; private PendingPostArrivalAction? _pendingPostArrival; @@ -36,7 +37,8 @@ internal sealed class SelectionInteractionController bool IsPickup, uint DestinationContainerId, int Placement, - ulong PendingPlacementToken); + ulong PendingPlacementToken, + PlayerApproachToken ApproachToken); public SelectionInteractionController( SelectionState selection, @@ -44,7 +46,8 @@ internal sealed class SelectionInteractionController ItemInteractionController items, ISelectionInteractionTransport transport, IPlayerInteractionMovementSink movement, - Action? toast = null) + Action? toast = null, + PlayerApproachCompletionState? approachCompletions = null) { _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _query = query ?? throw new ArgumentNullException(nameof(query)); @@ -52,6 +55,8 @@ internal sealed class SelectionInteractionController _transport = transport ?? throw new ArgumentNullException(nameof(transport)); _movement = movement ?? throw new ArgumentNullException(nameof(movement)); _toast = toast; + _approachCompletions = approachCompletions + ?? new PlayerApproachCompletionState(); } public bool HandleInputAction(InputAction action) @@ -213,11 +218,12 @@ internal sealed class SelectionInteractionController IsPickup: false, DestinationContainerId: 0u, Placement: 0, - PendingPlacementToken: 0u); + PendingPlacementToken: 0u, + ApproachToken: default); bool started = _movement.BeginApproach( approach, - () => _pendingPostArrival = pending); - if (!started && _pendingPostArrival == pending) + token => _pendingPostArrival = pending with { ApproachToken = token }); + if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid) CancelPendingApproach(); return; } @@ -265,11 +271,12 @@ internal sealed class SelectionInteractionController IsPickup: true, destinationContainerId, placement, - pendingPlacementToken); + pendingPlacementToken, + ApproachToken: default); bool started = _movement.BeginApproach( approach, - () => _pendingPostArrival = pending); - if (!started && _pendingPostArrival == pending) + token => _pendingPostArrival = pending with { ApproachToken = token }); + if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid) CancelPendingApproach(); else if (!started) CancelPickupPresentation(itemGuid, pendingPlacementToken); @@ -307,6 +314,9 @@ internal sealed class SelectionInteractionController /// Fires only after natural MoveToComplete(None), never cancellation. public void OnNaturalMoveToComplete() + => HandleNaturalMoveToComplete(); + + private void HandleNaturalMoveToComplete() { if (_pendingPostArrival is not { } pending) return; @@ -351,7 +361,19 @@ internal sealed class SelectionInteractionController } } - public void DrainOutbound() => _outbound.Drain(); + public void DrainOutbound() + { + while (_approachCompletions.TryTake(out PlayerApproachCompletion completion)) + { + if (_pendingPostArrival?.ApproachToken != completion.Token) + continue; + if (completion.IsNatural) + HandleNaturalMoveToComplete(); + else + CancelPendingApproach(); + } + _outbound.Drain(); + } public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach(); @@ -395,6 +417,8 @@ internal sealed class SelectionInteractionController catch (Exception error) { failures.Add(error); } try { _outbound.Clear(); } catch (Exception error) { failures.Add(error); } + try { _approachCompletions.Clear(); } + catch (Exception error) { failures.Add(error); } _pendingPostArrival = null; if (failures.Count != 0) diff --git a/src/AcDream.App/Physics/EntityPhysicsHost.cs b/src/AcDream.App/Physics/EntityPhysicsHost.cs index 3b8a89e9..0f138f25 100644 --- a/src/AcDream.App/Physics/EntityPhysicsHost.cs +++ b/src/AcDream.App/Physics/EntityPhysicsHost.cs @@ -193,6 +193,36 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost return existing; } + /// + /// Validates an exact incarnation and returns the manager-stable host that + /// a later will publish, without changing any + /// live delegates. Player-mode construction uses this preparation edge so + /// DAT/physics/camera failures cannot expose a half-rebound host. + /// + internal static EntityPhysicsHost SelectStableHostWithoutRebind( + LiveEntityRuntime runtime, + LiveEntityRecord expectedRecord, + EntityPhysicsHost configuration) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(expectedRecord); + ArgumentNullException.ThrowIfNull(configuration); + if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current) + || !ReferenceEquals(current, expectedRecord)) + { + throw new InvalidOperationException( + $"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host preparation."); + } + + return current.PhysicsHost switch + { + null => configuration, + EntityPhysicsHost existing => existing, + _ => throw new InvalidOperationException( + $"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation."), + }; + } + /// /// Builds the position-only CPhysicsObj facade used before an animated or /// player movement owner exists. The delegates capture the exact live diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 69d10dfd..41d65415 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -48,8 +48,8 @@ internal sealed class LiveEntityNetworkUpdateController private readonly IAnimationLoader _animLoader; private readonly CombatTargetController? _combatTargetController; private readonly LiveWorldOriginState _origin; - private readonly AcDream.App.Streaming.TeleportTransitCoordinator< - WorldSession.EntityPositionUpdate> _teleportTransit; + private readonly AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink + _localPlayerTeleport; private readonly Func _playerControllerSource; private readonly LocalPlayerOutboundController _localPlayerOutbound; private readonly Func _playerHostSource; @@ -57,7 +57,6 @@ internal sealed class LiveEntityNetworkUpdateController private readonly IPhysicsScriptTimeSource _gameTime; private readonly Func _session; private readonly LiveEntityInboundAuthorityGate _authorityGate; - private readonly Action _aimTeleportDestination; private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics; private PlayerMovementController? _playerController => _playerControllerSource(); @@ -96,7 +95,7 @@ internal sealed class LiveEntityNetworkUpdateController IAnimationLoader animLoader, CombatTargetController? combatTargetController, LiveWorldOriginState origin, - AcDream.App.Streaming.TeleportTransitCoordinator teleportTransit, + AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink localPlayerTeleport, Func playerControllerSource, LocalPlayerOutboundController localPlayerOutbound, Func playerHostSource, @@ -104,7 +103,6 @@ internal sealed class LiveEntityNetworkUpdateController IPhysicsScriptTimeSource gameTime, Func session, Action publishTimestamps, - Action aimTeleportDestination, IMovementTruthDiagnosticSink movementTruthDiagnostics) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); @@ -127,7 +125,8 @@ internal sealed class LiveEntityNetworkUpdateController _animLoader = animLoader ?? throw new ArgumentNullException(nameof(animLoader)); _combatTargetController = combatTargetController; _origin = origin ?? throw new ArgumentNullException(nameof(origin)); - _teleportTransit = teleportTransit ?? throw new ArgumentNullException(nameof(teleportTransit)); + _localPlayerTeleport = localPlayerTeleport + ?? throw new ArgumentNullException(nameof(localPlayerTeleport)); _playerControllerSource = playerControllerSource ?? throw new ArgumentNullException(nameof(playerControllerSource)); _localPlayerOutbound = localPlayerOutbound ?? throw new ArgumentNullException(nameof(localPlayerOutbound)); @@ -138,7 +137,6 @@ internal sealed class LiveEntityNetworkUpdateController _authorityGate = new LiveEntityInboundAuthorityGate( liveEntities, publishTimestamps); - _aimTeleportDestination = aimTeleportDestination ?? throw new ArgumentNullException(nameof(aimTeleportDestination)); _movementTruthDiagnostics = movementTruthDiagnostics ?? throw new ArgumentNullException(nameof(movementTruthDiagnostics)); } @@ -1776,14 +1774,11 @@ internal sealed class LiveEntityNetworkUpdateController // packet first. The presentation coordinator exposes exactly one // sequence-correlated destination without reordering that state. if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Apply - && update.Guid == _playerServerGuid - && _teleportTransit.OfferDestination( - update.TeleportSequence, - timestamps.TeleportAdvanced, - update, - out var teleportDestination)) + && update.Guid == _playerServerGuid) { - _aimTeleportDestination(teleportDestination); + _localPlayerTeleport.OfferDestination( + update, + timestamps.TeleportAdvanced); } } diff --git a/src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs b/src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs new file mode 100644 index 00000000..8b146ce8 --- /dev/null +++ b/src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs @@ -0,0 +1,117 @@ +using AcDream.App.Input; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Physics; + +/// +/// Owns publication and suspension of the local player's collision shadow. +/// Identity/incarnation checks stay beside the cached shadow pose so player +/// mode, projection, and teleport placement cannot publish different owners. +/// +internal sealed class LocalPlayerShadowSynchronizer +{ + private readonly PhysicsEngine _physics; + private readonly LiveEntityRuntime _liveEntities; + private readonly ILocalPlayerIdentitySource _identity; + private readonly LiveWorldOriginState _origin; + private readonly LocalPlayerShadowState _state; + + public LocalPlayerShadowSynchronizer( + PhysicsEngine physics, + LiveEntityRuntime liveEntities, + ILocalPlayerIdentitySource identity, + LiveWorldOriginState origin, + LocalPlayerShadowState state) + { + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _state = state ?? throw new ArgumentNullException(nameof(state)); + } + + public void Sync(WorldEntity playerEntity, uint cellId, bool force = false) + => SyncPose( + playerEntity, + playerEntity.Position, + playerEntity.Rotation, + cellId, + force); + + public LocalPlayerShadowState.Snapshot? Capture() => _state.Current; + + public void SyncPose( + WorldEntity playerEntity, + System.Numerics.Vector3 position, + System.Numerics.Quaternion orientation, + uint cellId, + bool force = false) + { + ArgumentNullException.ThrowIfNull(playerEntity); + if (_liveEntities.IsHidden(_identity.ServerGuid) + || cellId == 0 + || !IsCurrentVisibleProjection(playerEntity)) + { + Suspend(playerEntity); + return; + } + + if (!force + && _state.Current is { } last + && last.CellId == cellId + && System.Numerics.Vector3.DistanceSquared( + last.Position, + position) <= 1e-4f + && MathF.Abs(System.Numerics.Quaternion.Dot( + last.Orientation, + orientation)) >= 0.99999f) + { + return; + } + + ShadowPositionSynchronizer.Sync( + _physics.ShadowObjects, + playerEntity.Id, + position, + orientation, + cellId, + _origin.CenterX, + _origin.CenterY); + _state.Set(position, orientation, cellId); + } + + public void Restore( + WorldEntity playerEntity, + LocalPlayerShadowState.Snapshot? snapshot) + { + ArgumentNullException.ThrowIfNull(playerEntity); + if (snapshot is not { } prior || !IsCurrentVisibleProjection(playerEntity)) + { + Suspend(playerEntity); + return; + } + + SyncPose( + playerEntity, + prior.Position, + prior.Orientation, + prior.CellId, + force: true); + } + + public bool IsCurrentVisibleProjection(WorldEntity playerEntity) => + _liveEntities.TryGetRecord(playerEntity.ServerGuid, out LiveEntityRecord record) + && ReferenceEquals(record.WorldEntity, playerEntity) + && _liveEntities.IsCurrentSpatialRootObject(record); + + public void Suspend(WorldEntity playerEntity) + { + ArgumentNullException.ThrowIfNull(playerEntity); + _physics.ShadowObjects.Suspend(playerEntity.Id); + _state.Clear(); + } + + public void ResetSession() => _state.Clear(); +} diff --git a/src/AcDream.App/Rendering/CameraController.cs b/src/AcDream.App/Rendering/CameraController.cs index 5961f358..60b84ac5 100644 --- a/src/AcDream.App/Rendering/CameraController.cs +++ b/src/AcDream.App/Rendering/CameraController.cs @@ -5,6 +5,11 @@ namespace AcDream.App.Rendering; public sealed class CameraController { + internal readonly record struct CameraState( + int ModeCode, + ChaseCamera? Chase, + RetailChaseCamera? RetailChase); + public OrbitCamera Orbit { get; } public FlyCamera Fly { get; } public ChaseCamera? Chase { get; private set; } @@ -81,4 +86,22 @@ public sealed class CameraController Orbit.Aspect = aspect; Fly.Aspect = aspect; } + + internal CameraState CaptureState() => + new((int)_mode, Chase, RetailChase); + + /// + /// Restores a previously captured camera lifetime before notifying + /// subscribers. State remains restored even when a subscriber throws. + /// + internal void RestoreState(CameraState state) + { + if (state.ModeCode < (int)Mode.Orbit || state.ModeCode > (int)Mode.Chase) + throw new ArgumentOutOfRangeException(nameof(state)); + + Chase = state.Chase; + RetailChase = state.RetailChase; + _mode = (Mode)state.ModeCode; + ModeChanged?.Invoke(IsFlyMode || IsChaseMode); + } } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 018c1b79..7d85fe39 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -132,8 +132,14 @@ public sealed class GameWindow : IDisposable _landblockPresentationPipeline; private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer; private AcDream.App.Streaming.StreamingController? _streamingController; + private AcDream.App.Streaming.StreamingOriginRecenterCoordinator? + _streamingOriginRecenter; private AcDream.App.Update.IStreamingFramePhase _streamingFrame = null!; private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; + private readonly AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink + _localPlayerTeleportSink = new(); + private AcDream.App.Streaming.LocalPlayerTeleportController? + _localPlayerTeleport; private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9) private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25) @@ -538,27 +544,15 @@ public sealed class GameWindow : IDisposable // Phase B.2: player movement mode. private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new(); private AcDream.App.Input.PlayerMovementController? _playerController - { - get => _playerControllerSlot.Controller; - set => _playerControllerSlot.Controller = value; - } + => _playerControllerSlot.Controller; private readonly AcDream.App.Input.ChaseCameraInputState _chaseCameraInput = new(); private AcDream.App.Rendering.ChaseCamera? _chaseCamera - { - get => _chaseCameraInput.Legacy; - set => _chaseCameraInput.Legacy = value; - } + => _chaseCameraInput.Legacy; private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera - { - get => _chaseCameraInput.Retail; - set => _chaseCameraInput.Retail = value; - } + => _chaseCameraInput.Retail; private readonly AcDream.App.Input.LocalPlayerModeState _localPlayerMode = new(); private bool _playerMode - { - get => _localPlayerMode.IsPlayerMode; - set => _localPlayerMode.IsPlayerMode = value; - } + => _localPlayerMode.IsPlayerMode; private readonly AcDream.App.Input.LocalPlayerIdentityState _localPlayerIdentity = new(); private uint _playerServerGuid { @@ -572,16 +566,15 @@ public sealed class GameWindow : IDisposable AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new(); - // K-fix7 (2026-04-26): server-authoritative Run + Jump skill values - // received from PlayerDescription. -1 = "not yet received, fall back - // to the controller's default (env-var or hardcoded 200/300)". - // Captured by the GameEventWiring.WireAll callback the moment PD - // arrives; pushed into _playerController via SetCharacterSkills both - // immediately (if the controller already exists from auto-entry) and - // again at every EnterPlayerModeNow so a player who Tab-toggles in - // and out keeps the right skills. - private int _lastSeenRunSkill = -1; - private int _lastSeenJumpSkill = -1; + private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new(); + private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new(); + private AcDream.App.Input.PlayerModeController? _playerModeController; + private readonly AcDream.App.Interaction.PlayerApproachCompletionState + _playerApproachCompletions = new(); + private AcDream.App.Input.LocalPlayerAnimationController? + _localPlayerAnimation; + private AcDream.App.Physics.LocalPlayerShadowSynchronizer? + _localPlayerShadowSynchronizer; // Phase D.2b-C — live character-sheet assembly + raise flow (extracted // feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1. private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider; @@ -666,7 +659,7 @@ public sealed class GameWindow : IDisposable /// streaming + scene rendering so the user never sees Holtburg flash /// before login completes — the screen stays at the sky/fog clear /// color until the player entity has spawned + the auto-entry guard - /// has triggered . + /// has triggered the player-mode controller's auto-entry edge. /// Offline (LiveModeEnabled false) returns false → unchanged /// orbit-camera Holtburg view stays the foreground. Once chase mode /// is active the property latches false for the rest of the @@ -681,10 +674,7 @@ public sealed class GameWindow : IDisposable // IsLiveModeWaitingForLogin to suppress the pre-login render gate // for subsequent fly-mode toggles. private bool _chaseModeEverEntered - { - get => _localPlayerMode.ChaseModeEverEntered; - set => _localPlayerMode.ChaseModeEverEntered = value; - } + => _localPlayerMode.ChaseModeEverEntered; /// /// Phase 6.6/6.7: server-guid → local WorldEntity lookup so @@ -717,12 +707,15 @@ public sealed class GameWindow : IDisposable // TargetManager voyeur system, stored on the exact live record so remote // entities chasing the player resolve it. Replaces the AP-79 // _playerMoveToTarget* poll fields. - private EntityPhysicsHost? _playerHost; + private readonly AcDream.App.Input.LocalPlayerPhysicsHostSlot + _playerHostSlot = new(); + private EntityPhysicsHost? _playerHost + => _playerHostSlot.Host; // R5-V2: guid → per-entity IPhysicsObjHost registry (retail's // CObjectMaint::GetObjectA lookup). Backs every host's GetObjectA seam, // giving the TargetManager voyeur round-trip its cross-entity delivery - // path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow + // path. Populated for remotes plus the PlayerModeController local entry // (player); pruned only by logical LiveEntityRuntime teardown. private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; @@ -908,22 +901,6 @@ public sealed class GameWindow : IDisposable Combat.CombatModeChanged += SetInputCombatScope; SetInputCombatScope(Combat.CurrentMode); - // Phase K.2 — auto-enter player mode after EnterWorld - // succeeds. Predicates close over GameWindow state; the - // entry callback flips into player mode via the same code - // path TogglePlayerMode uses, just without the early-return - // when the entity isn't ready (the third predicate - // guarantees readiness before this fires). - _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( - isLiveInWorld: () => _liveSessionController?.IsInWorld == true, - isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid), - isPlayerControllerReady: () => true, - // Retail SmartBox::UseTime (0x00455410) completes the player - // position only after CellManager's blocking load clears. The - // shared acdream barrier joins collision, static-mesh upload, - // and composite-texture readiness for both login and portals. - isWorldReady: LoginWorldReady, - enterPlayerMode: EnterPlayerModeFromAutoEntry); } // Mouse delta handler — kept direct because Silk.NET delivers mouse @@ -1248,7 +1225,8 @@ public sealed class GameWindow : IDisposable // auto-entry if the user opts out of player mode before // it fires, so the chase camera doesn't snap on top of // the fly camera mid-inspection. - _debugVm.ToggleFlyMode = ToggleFlyOrChase; + _debugVm.ToggleFlyMode = () => + _playerModeController?.ToggleFlyOrChase(); _combatFeedback.ViewModel = _debugVm; _debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm); _panelHost.Register(_debugPanel); @@ -2313,8 +2291,10 @@ public sealed class GameWindow : IDisposable new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport( () => LiveSession), new AcDream.App.Interaction.PlayerInteractionMovementSink( - () => _playerController), - text => _debugVm?.AddToast(text)); + () => _playerController, + _playerApproachCompletions), + text => _debugVm?.AddToast(text), + _playerApproachCompletions); } // A.5 T22.5: apply A2C gate from quality preset. _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; @@ -2511,6 +2491,10 @@ public sealed class GameWindow : IDisposable var networkUpdateBridge = new AcDream.App.World.DeferredLiveEntityNetworkUpdateSink(); + var sealedDungeonCells = + new AcDream.App.Streaming.DatSealedDungeonCellClassifier( + _dats!, + _datLock); var projectionMaterializer = new DatLiveEntityProjectionMaterializer( _options, _dats!, @@ -2538,7 +2522,7 @@ public sealed class GameWindow : IDisposable _worldState, _worldReveal, () => _playerServerGuid, - IsSealedDungeonCell, + sealedDungeonCells, Console.WriteLine); var liveEntityTeardown = new AcDream.App.World.LiveEntityRuntimeTeardownController( @@ -2598,7 +2582,7 @@ public sealed class GameWindow : IDisposable _animLoader!, _combatTargetController, _liveWorldOrigin, - _teleportTransit, + _localPlayerTeleportSink, () => _playerController, _localPlayerOutbound, () => _playerHost, @@ -2606,7 +2590,6 @@ public sealed class GameWindow : IDisposable _updateFrameClock, () => LiveSession, PublishLocalPhysicsTimestamps, - AimTeleportDestination, _movementTruthDiagnostics); _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController( _liveEntities, @@ -2711,6 +2694,91 @@ public sealed class GameWindow : IDisposable _equippedChildRenderer!, _particleSink!, _liveEntityLights!); + _localPlayerAnimation = + new AcDream.App.Input.LocalPlayerAnimationController( + _liveEntities, + _localPlayerIdentity, + _animatedEntities, + _animationPresenter, + _animationHookFrames!); + _localPlayerShadowSynchronizer = + new AcDream.App.Physics.LocalPlayerShadowSynchronizer( + _physicsEngine, + _liveEntities, + _localPlayerIdentity, + _liveWorldOrigin, + _localPlayerShadow); + _viewportAspect.Update(_window!.Size.X, _window.Size.Y); + _playerModeController = new AcDream.App.Input.PlayerModeController( + _localPlayerMode, + _playerControllerSlot, + _playerHostSlot, + _chaseCameraInput, + _cameraController!, + _physicsEngine, + _liveEntities, + _localPlayerIdentity, + _liveWorldOrigin, + _liveEntityMotionBindings, + _dats!, + _datLock, + _physicsDataCache, + _animatedEntities, + _localPlayerAnimation, + _localPlayerShadowSynchronizer, + _playerApproachCompletions, + _gameplayInputFrame, + _liveSessionController, + _movementTruthDiagnostics, + _localPlayerSkills, + _viewportAspect); + _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( + isLiveInWorld: () => _liveSessionController.IsInWorld, + isPlayerEntityPresent: () => + _liveEntities.MaterializedWorldEntities.ContainsKey( + _playerServerGuid), + isPlayerControllerReady: () => true, + // Retail SmartBox::UseTime @ 0x00455410 completes player + // position only after the destination cell load clears. + isWorldReady: LoginWorldReady, + enterPlayerMode: _playerModeController.EnterFromAutoEntry, + isPlayerModeActive: () => _localPlayerMode.IsPlayerMode); + _playerModeController.BindAutoEntry(_playerModeAutoEntry); + var localTeleportPresentation = + new AcDream.App.Streaming.LocalPlayerTeleportPresentation( + _portalTunnel + ?? throw new InvalidOperationException( + "Portal presentation was not composed before local teleport.")); + _localPlayerTeleport = + new AcDream.App.Streaming.LocalPlayerTeleportController( + new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority( + _liveEntities, + _localPlayerIdentity), + _gameplayInputFrame, + _playerModeController, + new AcDream.App.Streaming.LocalPlayerTeleportStreamingOperations( + _liveWorldOrigin, + _streamingOriginRecenter!, + _streamingController!, + sealedDungeonCells), + _worldReveal, + new AcDream.App.Streaming.LocalPlayerTeleportPlacement( + _physicsEngine, + _liveEntities, + _localPlayerIdentity, + _playerControllerSlot, + _playerHostSlot, + _chaseCameraInput, + _liveWorldOrigin, + _liveSpatialReconciler), + new AcDream.App.Streaming.LocalPlayerTeleportSession( + _liveSessionController), + localTeleportPresentation); + _localPlayerTeleportSink.Bind(_localPlayerTeleport); + // Ownership transferred to LocalPlayerTeleportController. This field + // remains only as the staged-startup fallback used by shutdown when + // composition fails before the transfer. + _portalTunnel = null; _liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( liveObjectFrame, _worldState, @@ -2780,7 +2848,7 @@ public sealed class GameWindow : IDisposable { MouseCapture = ResetSessionMouseCapture, PlayerPresentation = ResetSessionPlayerPresentation, - TeleportTransit = () => ResetTeleportTransitState(clearSession: true), + TeleportTransit = _localPlayerTeleportSink.ResetSession, SessionDialogs = () => _retailUiRuntime?.ResetSessionDialogs(), ChatCommandTargets = () => _retailChatVm?.ResetSessionTargets(), SettingsCharacterContext = () => @@ -2816,22 +2884,8 @@ public sealed class GameWindow : IDisposable private void ResetSessionPlayerPresentation() { - _playerModeAutoEntry?.Cancel(); - try - { - _cameraController?.ExitChaseMode(); - } - finally - { - _localPlayerMode.ResetSession(); - _playerController = null; - _playerHost = null; - _chaseCamera = null; - _retailChaseCamera = null; - _movementTruthDiagnostics.ResetSession(); - _localPlayerShadow.Clear(); - _spawnClaimRangeMemo = null; - } + _playerModeController?.ResetSession(); + _spawnClaimRangeMemo = null; } private void ResetSessionIdentity() @@ -2849,8 +2903,7 @@ public sealed class GameWindow : IDisposable _activeToonKey = "default"; _characterOptions1 = AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; - _lastSeenRunSkill = -1; - _lastSeenJumpSkill = -1; + _localPlayerSkills.ResetSession(); _liveEntityNetworkUpdates?.ResetSessionState(); _liveEntityHydration?.ResetSessionState(); Shortcuts = Array.Empty(); @@ -2960,7 +3013,9 @@ public sealed class GameWindow : IDisposable _liveEntityHydration!, parent, static (hydration, value) => hydration.OnParent(value)), TeleportStarted: teleport => _inboundEntityEvents.Run( - this, teleport, static (window, value) => window.OnTeleportStarted(value)), + _localPlayerTeleportSink, + teleport, + static (sink, value) => sink.OnTeleportStarted(value)), AppearanceUpdated: appearance => _inboundEntityEvents.Run( _liveEntityHydration!, appearance, static (hydration, value) => hydration.OnAppearance(value)), @@ -3019,20 +3074,12 @@ public sealed class GameWindow : IDisposable }, OnSkillsUpdated: (runSkill, jumpSkill) => { - if (runSkill >= 0) - _lastSeenRunSkill = runSkill; - if (jumpSkill >= 0) - _lastSeenJumpSkill = jumpSkill; - if (_playerController is not null - && _lastSeenRunSkill >= 0 - && _lastSeenJumpSkill >= 0) + _localPlayerSkills.Update(runSkill, jumpSkill, _playerController); + if (_localPlayerSkills.IsComplete && _playerController is not null) { - _playerController.SetCharacterSkills( - _lastSeenRunSkill, - _lastSeenJumpSkill); Console.WriteLine( - $"player: applied server skills run={_lastSeenRunSkill} " + - $"jump={_lastSeenJumpSkill}"); + $"player: applied server skills run={_localPlayerSkills.RunSkill} " + + $"jump={_localPlayerSkills.JumpSkill}"); } }, OnConfirmationRequest: request => @@ -3293,174 +3340,6 @@ public sealed class GameWindow : IDisposable _localPlayerShadow.Clear(); } - /// - /// R3-W4: one-time per-remote wiring of the animation-dispatch stack — - /// the persistent , - /// Motion.DefaultSink (so - /// apply_current_movement's interpreted branch dispatches cycles - /// — the retail mechanism behind the deleted K-fix18 forced-Falling), - /// and the RemoveLinkAnimations/InitializeMotionTables - /// seams (retail CPhysicsObj::RemoveLinkAnimations 0x0050fe20 / - /// InitializeMotionTables). Idempotent; safe from both the UM path and - /// the VectorUpdate path regardless of arrival order. - /// - private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new(); - private readonly TeleportViewPlaneController _teleportViewPlane = new(); - private readonly AcDream.App.Streaming.TeleportTransitCoordinator< - AcDream.Core.Net.WorldSession.EntityPositionUpdate> _teleportTransit = new(); - private AcDream.App.Streaming.StreamingOriginRecenterCoordinator? - _streamingOriginRecenter; - private System.Numerics.Vector3 _pendingTeleportPos; - private uint _pendingTeleportCell; - private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout) - private bool _teleportForced; // true when the safety-net timeout force-places - private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity; - - // Loud safety net for a destination that never streams (worker crash / corrupt dat / - // OOB coords). WALL-CLOCK, not a frame count: during a dungeon-exit hold the source is - // unloaded and the destination not yet loaded → empty world → ~1000 fps, so a 600-FRAME - // ceiling fired in ~0.6s and force-placed into the skybox before the expand finished. - // Now rarely fires because priority-apply makes residency fast. - private const float TeleportMaxHoldSeconds = 10f; - - // 2026-06-22: how many landblocks of the player's IMMEDIATE SURROUNDINGS to eager-apply - // (and to hold portal space for) on a teleport. radius 1 = the 3×3 around the destination — - // the player's own landblock + its 8 neighbours, ~576 m across — enough that they arrive - // standing on loaded ground with wall collision and a non-empty immediate view. The far - // ring (out to the streaming window) still drains at the per-frame budget after portal - // space exits. Bigger = a more complete arrival but a longer portal-space hold. - private const int TeleportNearRingRadius = - AcDream.App.Streaming.WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius; - - /// - /// Bind a sequence-correlated teleport Position to presentation and - /// asynchronous streaming. Canonical physics has already accepted this - /// packet; this method only establishes the destination viewport lifetime. - /// - private void AimTeleportDestination( - AcDream.Core.Net.WorldSession.EntityPositionUpdate update) - { - var playerController = _playerController - ?? throw new InvalidOperationException( - "A teleport destination was accepted before the local player controller existed."); - var p = update.Position; - int lbX = (int)((p.LandblockId >> 24) & 0xFFu); - int lbY = (int)((p.LandblockId >> 16) & 0xFFu); - uint streamingOriginLandblockId = - AcDream.App.Streaming.StreamingRegion.EncodeLandblockId( - _liveCenterX, _liveCenterY); - var origin = new System.Numerics.Vector3( - (lbX - _liveCenterX) * 192f, - (lbY - _liveCenterY) * 192f, - 0f); - var worldPos = new System.Numerics.Vector3( - p.PositionX, p.PositionY, p.PositionZ) + origin; - - // Retail preserves the full Position (objcell_id + Frame) through - // SmartBox::TeleportPlayer @ 0x00453910. Dungeon EnvCells can have - // negative local frame origins, so XYZ cannot identify the source - // landblock (#215). Player crossing and streaming recentering are - // separate while a prior destination is aimed but not yet placed. - var landblockTransition = - AcDream.App.Streaming.TeleportLandblockTransition.Classify( - playerController.CellId, - p.LandblockId, - streamingOriginLandblockId); - int oldLbX = (int)((landblockTransition.SourceLandblockId >> 24) & 0xFFu); - int oldLbY = (int)((landblockTransition.SourceLandblockId >> 16) & 0xFFu); - bool playerCrossesLandblock = landblockTransition.CrossesLandblock; - bool streamingCenterChanges = landblockTransition.ChangesStreamingCenter; - - Console.WriteLine( - $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + - $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, playerController.Position):F1}"); - - System.Numerics.Vector3 newWorldPos; - if (streamingCenterChanges) - { - // #145: retire the complete old streaming window while its shared - // origin is still current. Destination streaming remains blocked - // until every retained presentation ticket converges. This prevents - // old terrain and collision, which were baked in the prior frame, - // from overlapping the destination after the origin changes. - var recenter = _streamingOriginRecenter - ?? throw new InvalidOperationException( - "A teleport changed streaming center before the recenter coordinator was wired."); - recenter.Begin( - lbX, - lbY, - IsSealedDungeonCell(p.LandblockId)); - newWorldPos = new System.Numerics.Vector3( - p.PositionX, p.PositionY, p.PositionZ); - - } - else - { - newWorldPos = worldPos; - } - - // Do not snap here. The TAS holds portal space until the destination - // near ring is resident, then emits Place exactly once. - _pendingTeleportRot = new System.Numerics.Quaternion( - p.RotationX, p.RotationY, p.RotationZ, p.RotationW); - _pendingTeleportPos = newWorldPos; - _pendingTeleportCell = p.LandblockId; - _teleportHoldSeconds = 0f; - _teleportForced = false; - _worldReveal?.Begin(AcDream.App.Streaming.WorldRevealKind.Portal); - if (_streamingController is not null) - { - _streamingController.PriorityLandblockId = - AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY); - _streamingController.PriorityRadius = TeleportNearRingRadius; - } - - AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( - "AIM", p.LandblockId, - $"seq={update.TeleportSequence} lb={lbX},{lbY} " + - $"indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} " + - $"playerCross={playerCrossesLandblock} centerChange={streamingCenterChanges}"); - } - - /// - /// End one logical teleport lifetime. This is the single symmetry seam - /// used by successful completion, replacement by a newer sequence, and - /// session teardown, so presentation state and destination state cannot - /// leak independently. - /// - private void ResetTeleportTransitState(bool clearSession = false) - { - bool originRecenterConverged = - _streamingOriginRecenter?.Reset(sessionEnding: clearSession) ?? true; - if (clearSession) - { - _teleportTransit.ClearSession(); - _worldReveal?.Cancel(); - } - else - _teleportTransit.EndActive(); - _pendingTeleportPos = default; - _pendingTeleportCell = 0u; - _pendingTeleportRot = System.Numerics.Quaternion.Identity; - _teleportHoldSeconds = 0f; - _teleportForced = false; - - _teleportAnim.Reset(); - _teleportViewPlane.Reset(); - _portalTunnel?.Exit(); - - if (_streamingController is not null) - { - _streamingController.PriorityLandblockId = 0u; - _streamingController.PriorityRadius = 0; - } - - if (clearSession && !originRecenterConverged) - { - throw new InvalidOperationException( - "The ending session's streaming-origin retirement has not converged."); - } - } // #145: the LANDBLOCK-relative (cell-local) position used to SEED the player // body's cell-relative CellPosition. This is the ONE place the streaming center @@ -3478,19 +3357,6 @@ public sealed class GameWindow : IDisposable return worldPos - origin; } - /// - /// worldReady for the TAS transit: is the player's teleport destination BOTH rendered - /// and collidable so we can materialize? Retail crosses this edge after its blocking cell - /// load. acdream's async equivalent must join the render publication barrier - /// () with physics - /// residency. Indoor destinations require the center render landblock + EnvCell; outdoor - /// destinations require both domains for the priority near ring. An impossible claim - /// returns true so the TAS stops holding and the forced placement surfaces the failure - /// loudly rather than holding forever. - /// - private bool TeleportWorldReady(uint destCell) - => EvaluateWorldRevealReadiness(destCell).IsReady; - private bool LoginWorldReady() { return TryGetLoginWorldCell(out uint cell) @@ -3515,131 +3381,6 @@ public sealed class GameWindow : IDisposable return cell != 0; } - // The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only - // once the destination is ready (or force-run on impossible/timeout, logged loud). - private void PlaceTeleportArrival( - System.Numerics.Vector3 destPos, uint destCell, bool forced) - { - var resolved = _physicsEngine.Resolve( - destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight); - var snappedPos = new System.Numerics.Vector3( - resolved.Position.X, resolved.Position.Y, resolved.Position.Z); - - if (forced) - Console.WriteLine( - $"live: teleport HOLD gave up (impossible/timeout) — force-snapping " + - $"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}"); - - if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) - { - pe.SetPosition(snappedPos); - pe.ParentCellId = resolved.CellId; - pe.Rotation = _pendingTeleportRot; - } - _playerController.SetPosition(snappedPos, resolved.CellId, - CellLocalForSeed(snappedPos, resolved.CellId)); - // R5-V3 (#171, diff-review find): retail teleport_hook's TAIL - // (0x00514ed0 @0x00514f1b-0x00514f28) — after the manager teardown - // SetPosition just ran (moveto cancel + own UnStick), the teleporting - // object clears its OWN target subscription and tells every entity - // WATCHING IT that it teleported (NotifyVoyeurOfEvent(Teleported)). - // That notify is what tears down the mobs' sticks/target-tracking ON - // the player — without it, a melee pack stuck to the player keeps - // steering toward the post-teleport position at the 5× sticky follow - // speed for up to the 1 s lease (non-retail lurch on every recall). - _playerHost?.NotifyTeleported(); - // Face the server-specified destination heading (retail drops you facing a fixed - // direction). The render entity already got _pendingTeleportRot above; sync the - // controller yaw so the camera + movement frame match it instead of the stale - // pre-teleport facing. - _playerController.SetBodyOrientation(_pendingTeleportRot); - - _chaseCamera?.Update(snappedPos, _playerController.Yaw); - // SmartBox::PlayerPositionUpdated (0x00453870) calls - // set_viewer(player_pos, reset_sought=1): the viewer and sought - // position both restart at the player, then the ordinary per-frame - // camera path re-extends the boom. Updating directly from the stale - // source-world viewer made the destination bend across the reveal. - _retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw); - _liveSpatialReconciler.Reconcile(); - - AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( - "PLACED", resolved.CellId, $"forced={forced}"); - // Do NOT flip to InWorld or send LoginComplete here — the player materializes while - // the portal viewport is active and stays input-frozen (PortalSpace) until the TAS - // restores the destination projection and fires FireLoginComplete. This is the - // retail "pop out the other side" sequence. - Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); - } - - /// - /// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). Freeze movement - /// input (PortalSpace) and begin retail portal transit. The per-frame TAS tick holds - /// the player behind the portal viewport until the destination is resident, then materializes - /// them (Place) and, after the destination view-plane restores, regains control + acks the server - /// (FireLoginComplete). - /// - private void OnTeleportStarted(uint sequence) - { - ushort teleportSequence = (ushort)sequence; - if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, teleportSequence) - || !_teleportTransit.CanBegin(teleportSequence)) - return; - - _gameplayInputFrame?.EndMouseLook(); - // A fresh sequence is a new logical transit. Discard the prior - // destination and presentation before this sequence can observe them; - // its destination PositionUpdate may arrive on a later network tick. - ResetTeleportTransitState(); - _teleportTransit.QueueStart(teleportSequence); - TryActivatePendingTeleportPresentation(); - Console.WriteLine($"live: teleport queued (seq={sequence})"); - } - - /// - /// Converge a queued F751 notification with the ordinary live-player - /// projection lifecycle. Usually this activates synchronously. If a - /// session transition has not materialized the player projection yet, the - /// notification and any correlated Position remain queued until it does. - /// - private void TryActivatePendingTeleportPresentation() - { - if (!_teleportTransit.HasPendingStart) - return; - - // Retail has no detached fly/orbit gameplay mode. If acdream's live - // developer camera currently owns the view, restore the existing - // player-mode lifecycle before transit so placement still has the - // canonical local physics controller and chase-camera handoff. - var playerController = _playerController; - if (playerController is null) - { - if (!_entitiesByServerGuid.ContainsKey(_playerServerGuid)) - return; - - _playerMode = true; - if (!EnterPlayerModeNow(loggingTag: "teleport")) - { - _playerMode = false; - return; - } - playerController = _playerController; - } - if (playerController is null) - return; - playerController.State = AcDream.App.Input.PlayerState.PortalSpace; - _teleportHoldSeconds = 0f; - _teleportForced = false; - _teleportViewPlane.Begin( - _cameraController?.Active.Projection ?? System.Numerics.Matrix4x4.Identity); - _teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal); - bool hasBufferedDestination = _teleportTransit.Activate( - out var bufferedDestination); - if (hasBufferedDestination) - AimTeleportDestination(bufferedDestination); - Console.WriteLine($"live: teleport presentation started (seq={_teleportTransit.Sequence})"); - } - /// /// Server-sent direct PhysicsScript (F754). EntityEffectController owns /// GUID translation and pre-materialization queueing. @@ -3874,79 +3615,11 @@ public sealed class GameWindow : IDisposable _liveFrameCoordinator.Tick(frameDelta); _liveEntityLiveness?.Tick(ClientTimerNow()); - // Usually F751 activates immediately. This no-op convergence check - // covers the session edge where the canonical player exists before - // its normal live projection/controller has materialized. - TryActivatePendingTeleportPresentation(); - - // Retail teleport transit. Runs AFTER streaming (which priority-applies the - // destination landblock this frame) and the live-session drain, so a destination - // that became resident this frame materializes the same frame. The TAS holds at - // Tunnel until worldReady, fires Place (materialize, hidden), then after the min- - // continue + view-plane transitions fires FireLoginComplete (regain control + ack). - // PortalTunnelPresentation replaces the world viewport through the tunnel-family states; - // retail switches the two 3-D viewports directly, below the retained UI. - if (_teleportTransit.IsActive) - { - bool haveDest = _pendingTeleportCell != 0u; - bool originReady = _streamingOriginRecenter?.IsPending != true; - bool ready = haveDest - && originReady - && TeleportWorldReady(_pendingTeleportCell); - if (haveDest && originReady && !ready) - { - _teleportHoldSeconds += frameDelta; - if (_teleportHoldSeconds >= TeleportMaxHoldSeconds) - { - ready = true; - _teleportForced = true; - } - } - - int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0; - var (snap, evts) = _teleportAnim.Tick(frameDelta, ready, tunnelFrame); - _teleportViewPlane.Update(snap); - - foreach (var e in evts) - { - switch (e) - { - case AcDream.Core.World.TeleportAnimEvent.Place: - PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced); - _worldReveal?.ObserveMaterialized(); - if (_streamingController is not null) - { - _streamingController.PriorityLandblockId = 0u; - _streamingController.PriorityRadius = 0; - } - break; - case AcDream.Core.World.TeleportAnimEvent.EnterTunnel: - _portalTunnel?.Enter(); - break; - case AcDream.Core.World.TeleportAnimEvent.PlayExitSound: - _portalTunnel?.Exit(); - break; - case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete: - if (_playerController is not null) - _playerController.State = AcDream.App.Input.PlayerState.InWorld; - // holtburger client/messages.rs:434 — re-send LoginComplete after - // each portal transition. - LiveSession?.SendGameAction( - AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); - _worldReveal?.Complete(); - ResetTeleportTransitState(); - break; - default: - // The retained audio engine does not yet own the - // ClientUISystem sound-table enum path. Enter/exit - // visual edges are handled above. - break; - } - } - - _portalTunnel?.Tick(frameDelta); - } - + // Retail teleport transit runs after streaming and inbound state so a + // newly resident destination can materialize in this same frame. The + // focused owner also converges an F751 that arrived before the local + // player projection/controller became available. + _localPlayerTeleport!.Tick(frameDelta); // Phase K.1a — tick the input dispatcher so Hold-type bindings // re-fire while their chord is held. K.1b adds the subscribers // that actually consume the events. @@ -3958,7 +3631,7 @@ public sealed class GameWindow : IDisposable // returns true on the firing tick (one-shot); subsequent ticks // are no-ops. Skipped offline (no active session → IsLiveInWorld // predicate stays false). Cancelled by manual fly-toggle in - // OnInputAction (Ctrl+Tab → TogglePlayerMode) or DebugPanel. + // OnInputAction (Ctrl+Tab) or DebugPanel. _playerModeAutoEntry?.TryEnter(); if (_cameraController is null || _input is null) return; @@ -4138,7 +3811,8 @@ public sealed class GameWindow : IDisposable // gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the // normal SmartBox viewport and UIElement_Viewport's portal // CreatureMode; it does not redraw the world behind portal space. - bool portalViewportVisible = _portalTunnel?.IsVisible == true; + bool portalViewportVisible = + _localPlayerTeleport?.IsPortalViewportVisible == true; if (portalViewportVisible) _particleVisibility.Reset(); @@ -4211,8 +3885,9 @@ public sealed class GameWindow : IDisposable using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload)) { _wbMeshAdapter?.Tick(); - uint revealCell = _teleportTransit.IsActive - ? _pendingTeleportCell + uint revealCell = _localPlayerTeleport?.ActiveDestinationCell is uint teleportCell + && teleportCell != 0u + ? teleportCell : IsLiveModeWaitingForLogin && TryGetLoginWorldCell(out uint loginCell) ? loginCell : 0u; @@ -4264,7 +3939,8 @@ public sealed class GameWindow : IDisposable { _retailAlphaQueue.BeginFrame(); var activeCamera = _cameraController.Active; - var camera = _teleportViewPlane.ApplyTo(activeCamera); + var camera = _localPlayerTeleport?.ApplyViewPlane(activeCamera) + ?? activeCamera; var worldProjection = camera.Projection; var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection); _retailSelectionScene?.SetViewFrustum(frustum); @@ -5195,12 +4871,10 @@ public sealed class GameWindow : IDisposable // while this replacement scene is visible. This scene and // its projection transition draws BEFORE retained UI, so chat, windows, // cursor, and toolbar remain visible and interactive in transit. - var portalProjection = _teleportViewPlane.Apply( - _cameraController!.Active.Projection); - _portalTunnel?.Draw( + _localPlayerTeleport?.DrawPortalViewport( _window!.Size.X, _window.Size.Y, - portalProjection); + _cameraController!.Active.Projection); // Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the // viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window @@ -5289,7 +4963,7 @@ public sealed class GameWindow : IDisposable string flyLabel = _cameraController.IsFlyMode ? "Exit Free-Fly Mode" : "Enter Free-Fly Mode"; if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F")) - ToggleFlyOrChase(); + _playerModeController?.ToggleFlyOrChase(); } // A8.F: spring-arm camera collision (live A/B toggle). if (ImGuiNET.ImGui.MenuItem("Collide Camera (spring arm)", "", @@ -5480,54 +5154,6 @@ public sealed class GameWindow : IDisposable LastFrameProfile: _frameProfiler.LastReport); } - /// - /// Phase 6.4: advance every animated entity's frame counter by - /// * Framerate, wrapping around the cycle's - /// [LowFrame..HighFrame] interval, then rebuild that entity's - /// MeshRefs from the new frame's per-part transforms. Static - /// entities (no LiveEntityAnimationState record) are untouched. The static - /// renderer reads the new MeshRefs on the next Draw call. - /// - private void AdvanceLocalPlayerAnimationRoot( - float dt, - AcDream.Core.Physics.Motion.MotionDeltaFrame output) - { - ArgumentNullException.ThrowIfNull(output); - output.Reset(); - - if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) - || !_animatedEntities.TryGetValue(entity.Id, out var ae) - || ae.Sequencer is not { } sequencer - || _liveEntities?.ShouldAdvanceRootRuntime(_playerServerGuid) == false - || _liveEntities?.IsHidden(_playerServerGuid) == true) - { - return; - } - - if (_liveEntities?.TryGetRecord(_playerServerGuid, out LiveEntityRecord bindingRecord) == true) - _animationPresenter.PrepareAnimation(bindingRecord, ae); - - DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch; - rootFrame.Origin = System.Numerics.Vector3.Zero; - rootFrame.Orientation = System.Numerics.Quaternion.Identity; - ae.PreparedSequenceFrames = ae.CaptureSequenceFrames( - sequencer.Advance(dt, rootFrame)); - ae.SequenceAdvancedBeforeAnimationPass = true; - - output.Origin = rootFrame.Origin; - output.Orientation = rootFrame.Orientation; - } - - private void CaptureLocalPlayerAnimationHooks() - { - if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) - && _animatedEntities.TryGetValue(entity.Id, out var ae) - && ae.Sequencer is { } sequencer) - { - _animationHookFrames?.Capture(ae.Entity.Id, sequencer); - } - } - // IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass // narrowing that dropped settled-open doors / faded-out walls back onto the // stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the @@ -6829,6 +6455,7 @@ public sealed class GameWindow : IDisposable { if (newSize.X <= 0 || newSize.Y <= 0) return; _gl?.Viewport(0, 0, (uint)newSize.X, (uint)newSize.Y); + _viewportAspect.Update(newSize.X, newSize.Y); _cameraController?.SetAspect(newSize.X / (float)newSize.Y); // Resize is always a force-reset — the alternative ("clamp // existing positions") would require tracking each panel's @@ -7106,11 +6733,11 @@ public sealed class GameWindow : IDisposable // to land in Holtburg-orbit on toggle-back. With a chase // camera available, prefer Fly→Chase / Chase→Fly so the // user round-trips back to the same player view. - ToggleFlyOrChase(); + _playerModeController?.ToggleFlyOrChase(); break; case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode: - TogglePlayerMode(); + _playerModeController?.Toggle(); break; case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry: @@ -7141,14 +6768,7 @@ public sealed class GameWindow : IDisposable else if (_cameraController?.IsFlyMode == true) _cameraController.ToggleFly(); // exit fly, release cursor else if (_playerMode) - { - _gameplayInputFrame?.EndMouseLook(); - _playerMode = false; - _cameraController?.ExitChaseMode(); - _playerController = null; - _chaseCamera = null; - _retailChaseCamera = null; - } + _playerModeController?.Exit(); else _window!.Close(); break; @@ -7229,9 +6849,6 @@ public sealed class GameWindow : IDisposable private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement) => _selectionInteractions?.SendPickup(itemGuid, destinationContainerId, placement); - private void OnAutoWalkArrivedSendDeferredAction() - => _selectionInteractions?.OnNaturalMoveToComplete(); - private uint? SelectClosestCombatTarget(bool showToast) => _selectionInteractions?.SelectClosestCombatTarget(showToast); @@ -7246,7 +6863,8 @@ public sealed class GameWindow : IDisposable return (System.Numerics.Matrix4x4.Identity, System.Numerics.Matrix4x4.Identity, System.Numerics.Vector2.Zero); - var camera = _teleportViewPlane.ApplyTo(_cameraController.Active); + var camera = _localPlayerTeleport?.ApplyViewPlane(_cameraController.Active) + ?? _cameraController.Active; return (camera.View, camera.Projection, new System.Numerics.Vector2(_window.Size.X, _window.Size.Y)); } @@ -7254,41 +6872,6 @@ public sealed class GameWindow : IDisposable private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid) => _worldSelectionQuery?.ResolveVividTargetInfo(guid); - private void TogglePlayerMode() - { - // Phase B.2 guard: only active when a live session is in-world. - if (_liveSessionController?.IsInWorld != true) - return; - - // Manual toggle pre-empts the K.2 auto-entry trigger regardless - // of direction — entering means "I'm in player mode now"; exiting - // means "I want fly, don't snap me back". - _playerModeAutoEntry?.Cancel(); - - _playerMode = !_playerMode; - if (_playerMode) - { - if (!EnterPlayerModeNow(loggingTag: "Tab")) - _playerMode = false; - } - else - { - _gameplayInputFrame?.EndMouseLook(); - _cameraController?.ExitChaseMode(); - _playerController = null; - _chaseCamera = null; - _retailChaseCamera = null; - } - } - - /// - /// K.2: callback the - /// guard invokes once login + entity stream + controller readiness - /// have all converged. Sets _playerMode = true and runs the - /// same construction path the manual Tab handler uses. Predicates on - /// the guard already guarantee _entitiesByServerGuid contains - /// the player guid, so the inner TryGetValue is a fast-path success. - /// // #107 (2026-06-10): memoized "this indoor spawn claim can never hydrate" // check against the dat's LandBlockInfo.NumCells. Used by the auto-entry // hold so a garbage claim doesn't stall login forever; the Resolve-head @@ -7317,449 +6900,6 @@ public sealed class GameWindow : IDisposable return unhydratable; } - // #135: is this server-sent cell id a SEALED dungeon EnvCell — an indoor cell - // (low 16 bits >= 0x0100) whose EnvCell dat flags lack SeenOutside? Distinguishes - // a real dungeon (collapse streaming to its single landblock) from a building - // interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from - // an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as - // the hydration path (EnvCellLandblockBuildBuilder) and as the physics - // CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse - // decision matches the eventual gate decision exactly. Returns false when the dat - // lacks the cell (out-of-range index / missing record) so we never collapse on a - // guess. The dat read is reentrant-safe under _datLock (Monitor) — callers may - // already hold it (the login spawn handler does). - private bool IsSealedDungeonCell(uint cellId) - { - // Not an EnvCell: the sub-0x0100 outdoor sub-cells AND the 0xFFFE/0xFFFF - // structural shell ids (LandBlockInfo / LandBlock heightmap). A naive - // `< 0x0100` test MISSES 0xFFFF (65535 is not < 256), and Get on - // 0xXXYYFFFF would then type-confuse the LandBlock record living at that id as - // an EnvCell (its bytes unpack to a bogus Flags value). A real spawn/teleport - // position never carries a shell id, but exclude them so the read is sound. - uint low = cellId & 0xFFFFu; - if (low < 0x0100u || low >= 0xFFFEu) return false; - if (_dats is null) return false; - DatReaderWriter.DBObjs.EnvCell? envCell; - lock (_datLock) - envCell = _dats.Get(cellId); - return envCell is not null - && !envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside); - } - - private void EnterPlayerModeFromAutoEntry() - { - _playerMode = true; - if (!EnterPlayerModeNow(loggingTag: "auto-entry")) - { - // Defense in depth: if construction failed (e.g. entity - // disappeared between predicate eval and here) drop back - // out cleanly. Re-arm so a later Tab still works. - _playerMode = false; - } - else - { - Console.WriteLine($"live: auto-entered player mode for 0x{_playerServerGuid:X8}"); - } - } - - /// - /// K-fix3 (2026-04-26): the right "toggle free-fly mode" routine - /// when a chase camera is in play. - /// only knows Fly↔Orbit and would strand a player-mode user in the - /// orbit camera (Holtburg view) when they exit fly. This wrapper - /// gives the round-trip the user actually wants: - /// - /// Chase → Fly: cancel auto-entry (user's choice wins) and - /// switch to fly camera while keeping _playerMode = true + - /// the chase camera alive so we can return. - /// Fly → Chase: when _playerMode is still true and the - /// chase camera survived, re-enter chase via - /// . - /// Otherwise (no chase available): the original Fly↔Orbit - /// toggle for offline / pre-login flows. - /// - /// - private void ToggleFlyOrChase() - { - if (_cameraController is null) return; - _playerModeAutoEntry?.Cancel(); - - if (_cameraController.IsFlyMode - && _playerMode - && _chaseCamera is not null) - { - if (_retailChaseCamera is null) - { - _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera - { - Aspect = _chaseCamera.Aspect, - CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine), - }; - } - _cameraController.EnterChaseMode(_chaseCamera, _retailChaseCamera); - return; - } - _cameraController.ToggleFly(); - } - - /// - /// K.2: shared "construct controller + chase camera + enter chase - /// mode" body extracted from the on-enter branch of - /// . Returns false when the player - /// entity isn't in _entitiesByServerGuid yet — caller must - /// reset _playerMode in that case. - /// - private bool EnterPlayerModeNow(string loggingTag) - { - if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)) - { - Console.WriteLine($"live: {loggingTag} — player entity 0x{_playerServerGuid:X8} not found yet"); - return false; - } - - if (_liveEntities is not { } playerLiveEntities - || !playerLiveEntities.TryGetRecord( - _playerServerGuid, - out LiveEntityRecord playerRecord)) - { - Console.WriteLine( - $"live: {loggingTag} — player record 0x{_playerServerGuid:X8} not found yet"); - return false; - } - _playerController = new AcDream.App.Input.PlayerMovementController( - _physicsEngine, - playerRecord.ObjectClock); - _playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState); - - // R4-V5: the local player's verbatim MoveToManager — same seam - // wiring shape as EnsureRemoteMotionBindings, with three - // player-specific differences: (a) heading reads/writes use the - // controller's Yaw projection as the explicit Frame::get_heading / - // set_heading seam, while ordinary object ticks retain the complete - // authoritative body quaternion; (b) the contact seam reads the REAL Contact - // transient bit (retail UseTime gates transient_state & 1 — - // remotes force-assert Contact+OnWalkable every grounded tick, so - // OnWalkable was equivalent there); (c) isInterpolating is false — - // the local player has no InterpolationManager. Own radius/height - // are the real setup cylsphere values (R5-V3 — lazy reads because - // this method caches the player Setup a few paragraphs further - // down). The setHeading `send` argument remains unused because retail - // CPhysicsObj::set_heading (0x00514160) also ignores that parameter. - // Stationary heading changes reach the wire through the shared - // ShouldSendPositionEvent full-Frame comparison (0x006B45E0), including - // orientation, rather than through a MoveTo-specific send side channel. - var pcMoveTo = _playerController; - // R5-V2: forward-declared so the player MoveToManager's target seams - // route into the player's TargetManager (retail CPhysicsObj::set_target). - EntityPhysicsHost playerHost = null!; - // R5-V5: the construction is the player MovementManager's - // MoveToFactory (same facade shape as EnsureRemoteMotionBindings); - // MakeMoveToManager() below invokes it once, after playerHost exists - // for the sticky binds. - _playerController.Movement.MoveToFactory = () => - { - var playerMoveTo = new AcDream.Core.Physics.Motion.MoveToManager( - pcMoveTo.Motion, - stopCompletely: () => pcMoveTo.StopCompletelyAtPhysicsObjectBoundary(), - getPosition: () => new AcDream.Core.Physics.Position( - pcMoveTo.CellId, pcMoveTo.Position, pcMoveTo.BodyOrientation), - getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw), - setHeading: (h, _) => pcMoveTo.Yaw = - AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h), - getOwnRadius: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Radius, - getOwnHeight: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Height, - contact: () => pcMoveTo.BodyInContact, - isInterpolating: () => false, - getVelocity: () => pcMoveTo.BodyVelocity, - getSelfId: () => _playerServerGuid, - // R5-V2: retail CPhysicsObj::set_target/clear_target/quantum → the - // player's TargetManager (replaces the AP-79 _playerMoveToTarget* poll). - setTarget: (ctx, tlid, radius, q) => playerHost.SetTarget(ctx, tlid, radius, q), - clearTarget: () => playerHost.ClearTarget(), - getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(), - setTargetQuantum: q => playerHost.TargetManager.SetTargetQuantum(q), - curTime: () => pcMoveTo.SimTimeSeconds); - - // AD-27 re-anchored (was the deleted AutoWalkArrived event): fire - // the deferred close-range Use/PickUp action when the moveto - // completes NATURALLY (MoveToComplete is the documented client - // seam; it never fires on CancelMoveTo, so a user-input cancel - // doesn't send the action — same contract the old "arrived"-only - // event had). - playerMoveTo.MoveToComplete = err => - { - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[autowalk-end] reason=complete err={err}"); - if (err == AcDream.Core.Physics.WeenieError.None) - OnAutoWalkArrivedSendDeferredAction(); - else - _selectionInteractions?.OnMoveToCancelled(err); - }; - playerMoveTo.MoveToCancelled = err => - _selectionInteractions?.OnMoveToCancelled(err); - - // R5-V3 (#171, retires TS-39 — player side): bind the sticky - // seams to the player host's PositionManager (same trio as the - // remote bind: BeginNextNode arrival StickTo @0x00529d3a, - // PerformMovement-head Unstick). - playerMoveTo.StickTo = (tlid, radius, height) => - playerHost.PositionManager.StickTo(tlid, radius, height); - playerMoveTo.Unstick = playerHost.PositionManager.UnStick; - return playerMoveTo; - }; - - // R5-V2: the player's CPhysicsObj stand-in + TargetManager. Position is - // the player's WORLD position (WorldEntity.Position — what the AP-79 - // poll used), so an NPC watching the player receives the identical - // position. Stored on the exact live record so those NPCs' GetObjectA - // resolves the player; HandleTargetting is ticked in the player - // pre-Update block. - var exactPlayerMovement = pcMoveTo.Movement; - var configuredPlayerHost = new EntityPhysicsHost( - _playerServerGuid, - getPosition: () => new AcDream.Core.Physics.Position( - playerRecord.FullCellId, - playerRecord.WorldEntity?.Position ?? pcMoveTo.Position, - pcMoveTo.BodyOrientation), - getVelocity: () => pcMoveTo.BodyVelocity, - getRadius: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Radius, - inContact: () => pcMoveTo.BodyInContact, - minterpMaxSpeed: () => pcMoveTo.Motion.GetMaxSpeed(), - curTime: () => pcMoveTo.SimTimeSeconds, - physicsTimerTime: () => pcMoveTo.SimTimeSeconds, - getObjectA: _liveEntityMotionBindings.ResolvePhysicsHost, - // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head: - // MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade - // relay); the host chains the PositionManager leg after it. - handleUpdateTarget: info => - { - if (AcDream.Core.Physics.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})"); - } - exactPlayerMovement.HandleUpdateTarget(info); - }, - interruptCurrentMovement: () => exactPlayerMovement.CancelMoveTo( - AcDream.Core.Physics.WeenieError.ActionCancelled)); - playerHost = EntityPhysicsHost.InstallOrRebind( - playerLiveEntities, - playerRecord, - configuredPlayerHost); - _playerHost = playerHost; - - // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the - // player MoveToManager via the factory above (playerHost now exists - // for the sticky binds inside it). The UM-funnel-head unstick - // (CPhysicsObj::unstick_from_object 0x0050eaea) binds on the interp - // beside it, and the controller takes the PositionManager handoff it - // drives at the retail UpdatePositionInternal/UpdateObjectInternal - // points (AdjustOffset/UseTime). - var playerMovement = _playerController.Movement; - playerMovement.MakeMoveToManager(); - _playerController.Motion.UnstickFromObject = playerHost.PositionManager.UnStick; - _playerController.PositionManager = playerHost.PositionManager; - - // TS-36 RETIRED: the interp's interrupt seam is retail's - // interrupt_current_movement → MovementManager::CancelMoveTo(0x36) - // chain (raw 278189-278200) — since R5-V5 the literal facade relay - // (0x005241b0). Every DoMotion/StopMotion/StopCompletely/jump/ - // set_hold_run cancel site now genuinely cancels a running moveto - // (V2's reentrancy tests prove the chain is inert-safe). Captures - // THIS controller's facade (not the _playerController field) so a - // Tab-toggle's stale interp keeps cancelling its own manager. - _playerController.Motion.InterruptCurrentMovement = () => - { - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled - && playerMovement.IsMovingTo()) - Console.WriteLine("[autowalk-end] reason=interrupt"); - playerMovement.CancelMoveTo(AcDream.Core.Physics.WeenieError.ActionCancelled); - }; - - // K-fix7 (2026-04-26): if PlayerDescription already arrived, the - // server's Run / Jump skill values are cached here — push them - // into the freshly-constructed controller so the runRate / - // jump-arc formulas use real character data instead of the - // hardcoded ACDREAM_*_SKILL defaults. PD always arrives at - // login before auto-entry fires, so this branch normally hits. - if (_lastSeenRunSkill >= 0 && _lastSeenJumpSkill >= 0) - { - _playerController.SetCharacterSkills(_lastSeenRunSkill, _lastSeenJumpSkill); - Console.WriteLine($"live: {loggingTag} — applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}"); - } - // Read the real step heights from the player's Setup dat. - // L.2.3a (2026-04-29): retail's Setup.StepUpHeight for humans is - // ~0.4 m, NOT 2 m. With 2 m fallback the step-up scan reached - // small-building roofs and teleported the player onto them. Same - // for StepDownHeight — was hardcoded 0.04 m, causing stair-top - // contact-plane gaps. Both now come from Setup with retail-realistic - // 0.4 m fallbacks. - if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) - { - // A.5 T10: lock around _dats.Get — worker thread may be - // building a landblock mesh concurrently. - DatReaderWriter.DBObjs.Setup? playerSetup; - lock (_datLock) { playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId); } - if (playerSetup is not null) - _physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup); - _playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f) - ? playerSetup.StepUpHeight - : 0.4f; - _playerController.StepDownHeight = (playerSetup is not null && playerSetup.StepDownHeight > 0f) - ? playerSetup.StepDownHeight - : 0.4f; - // L.2.3f (2026-04-29): diagnostic — confirm what the actual - // values from the player's Setup dat are. Retail's spec says ~0.4 m - // for humans, but we want to verify rather than guess. If the - // dat-derived value is large (e.g. 1.5 m+) it explains why the - // player can mount steep roofs via the step-up scan reach. - Console.WriteLine( - $"physics: player step heights — StepUp={_playerController.StepUpHeight:F3} m " + - $"(Setup.StepUpHeight={(playerSetup?.StepUpHeight ?? 0f):F3}), " + - $"StepDown={_playerController.StepDownHeight:F3} m " + - $"(Setup.StepDownHeight={(playerSetup?.StepDownHeight ?? 0f):F3})"); - } - else - { - _playerController.StepUpHeight = 0.4f; - _playerController.StepDownHeight = 0.4f; - Console.WriteLine($"physics: player step heights — defaulting to 0.4 m (no setup dat)"); - } - // Issue #92 (2026-05-20): seed the resolver with the SERVER's - // authoritative cell id from the spawn message instead of a - // hardcoded outdoor sentinel (`landblockPrefix | 0x0001`). When - // the player logs in INSIDE a building (server reports an indoor - // cell like `0xA9B4015A`), the old sentinel forced the resolver - // into the outdoor seed branch — for the first several ticks - // CheckBuildingTransit hadn't yet picked up the interior cell, - // so the player was classified outdoor, indoor BSP queries - // didn't run, and exterior walls were passable until the player - // moved far enough INWARD that the sphere overlap eventually - // promoted them. Visible symptom: "logged in inside the inn, - // ran out through the exterior wall." - // - // Fall back to the old sentinel when no spawn record is cached - // (defensive — should never fire in live play because the - // The inbound-state snapshot was accepted before render hydration. - // before EnterPlayerModeNow could possibly be reached). - uint pinitCellId; - if (LastSpawns.TryGetValue(_playerServerGuid, out var playerSpawn) - && playerSpawn.Position is { } spawnPos - && spawnPos.LandblockId != 0) - { - pinitCellId = spawnPos.LandblockId; - } - else - { - int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f); - int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f); - pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u; - } - // R4-V5 moveto-stall fix #2 (2026-07-03, the [autowalk-gate] probe's - // one-immortal-node finding): the sequencer/sink bind block MUST run - // BEFORE the initial SetPosition. SetPosition → StopCompletely - // enqueues the A9 pending_motions node whose completable partner is - // the DefaultSink's type-5 motion-table entry — with the sink still - // null at login, the node was ORPHANED, and pending_motions never - // reached empty again (head-pop-any just relabels a backlog), so - // the MoveToManager's wait-for-anims gate never opened: every - // server MoveTo armed but the body never moved. - if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE) - && playerAE.Sequencer is { } playerSeq) - { - _playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity); - _playerController.ObjectScale = playerAE.Scale; - _playerController.AttachAnimationRootMotionSource( - AdvanceLocalPlayerAnimationRoot, - CaptureLocalPlayerAnimationHooks); - // R3-W4: bind the player interp's retail seams to the player - // sequencer — LeaveGround/HitGround strip transition links here - // (the K-fix18 replacement). - // #174 (2026-07-05): the seam is HandleEnterWorld (strip + FULL - // queue drain), not the bare sequence strip — see the remote - // bind site's comment (retail 0x0050fe20 → 0x00517d70 → - // 0x0051bdd0). The bare strip orphaned every pending manager - // node on each jump's LeaveGround; the local player's - // pending_motions then never drained and every armed moveto - // starved (#174: doors unusable after the first jump/run). - _playerController.Motion.RemoveLinkAnimations = () => playerSeq.Manager.HandleEnterWorld(); - _playerController.Motion.InitializeMotionTables = - () => playerSeq.Manager.InitializeState(); - _playerController.Motion.CheckForCompletedMotions = - playerSeq.Manager.CheckForCompletedMotions; - // R3-W6: the player's cycles are driven through the same dispatch - // sink as remotes; both consume CSequence's complete root Frame. - _playerController.Motion.DefaultSink = - new AcDream.Core.Physics.Motion.MotionTableDispatchSink(playerSeq); - } - - var initResult = _physicsEngine.Resolve( - playerEntity.Position, pinitCellId, - System.Numerics.Vector3.Zero, 100f); - - // Retail enter_world -> SetPosition(flags 0x11) runs - // CTransition::find_placement_pos after the initial cell/environment - // placement. That second phase is what seats a relogging character - // beside a monster that moved onto the saved logout position. The - // legacy Resolve above supplies the already-ported AdjustPosition + - // floor snap; this call supplies the missing object-aware ring search. - var (placementRadius, placementHeight) = - _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity); - if (placementRadius < 0.05f) - { - placementRadius = 0.48f; - placementHeight = 1.835f; - } - var placementResult = _physicsEngine.ResolvePlacement( - initResult.Position, - initResult.CellId, - placementRadius, - placementHeight, - _playerController.StepUpHeight, - _playerController.StepDownHeight, - AcDream.Core.Physics.ObjectInfoState.IsPlayer - | AcDream.Core.Physics.ObjectInfoState.EdgeSlide, - playerEntity.Id); - if (placementResult.Ok) - initResult = placementResult; - - _playerController.SetPosition(initResult.Position, initResult.CellId, - CellLocalForSeed(initResult.Position, initResult.CellId)); - // #111 (2026-06-10): snap the ENTITY too — parity with the - // teleport-arrival path (entity.SetPosition + ParentCellId at - // GameWindow.cs:4914). Without this, the renderer keeps drawing the - // character at the server-restored position (ACE restored z=99.475; - // physics grounded to the 94.0 floor; the user saw the char floating - // 2 m up against the window while physics stood on the floor). - playerEntity.SetPosition(initResult.Position); - playerEntity.ParentCellId = initResult.CellId; - SyncLocalPlayerShadow(playerEntity, initResult.CellId, force: true); - - _playerController.SetBodyOrientation(playerEntity.Rotation); - - _chaseCamera = new AcDream.App.Rendering.ChaseCamera - { - Aspect = _window!.Size.X / (float)_window.Size.Y, - }; - _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera - { - Aspect = _window!.Size.X / (float)_window.Size.Y, - CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine), - }; - _cameraController?.EnterChaseMode(_chaseCamera, _retailChaseCamera); - // K-fix1 (2026-04-26): latch the "we have entered chase at least - // once" flag so the live-mode pre-login render gate stops - // suppressing the scene. From here on, the orbit camera (if the - // user ever returns to it via Escape) shows whatever's loaded — - // we don't re-blank the world. - _chaseModeEverEntered = true; - return true; - } - /// /// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies /// the currently-active mode's sensitivity (chase / fly / orbit) by the @@ -8098,6 +7238,8 @@ public sealed class GameWindow : IDisposable [ new("portal tunnel", () => { + _localPlayerTeleport?.Dispose(); + _localPlayerTeleport = null; _portalTunnel?.Dispose(); _portalTunnel = null; }), diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs new file mode 100644 index 00000000..e287f5dc --- /dev/null +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -0,0 +1,761 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Update; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +internal interface ILocalPlayerTeleportNetworkSink +{ + void OnTeleportStarted(uint sequence); + + void OfferDestination( + WorldSession.EntityPositionUpdate update, + bool teleportTimestampAdvanced); + + void ResetSession(); +} + +/// +/// Construction-order bridge for the inbound session. It carries no teleport +/// state: after binding, every packet and reset is forwarded to the one +/// owner. +/// +internal sealed class DeferredLocalPlayerTeleportNetworkSink + : ILocalPlayerTeleportNetworkSink +{ + private ILocalPlayerTeleportNetworkSink? _inner; + + public void Bind(ILocalPlayerTeleportNetworkSink inner) + { + ArgumentNullException.ThrowIfNull(inner); + if (Interlocked.CompareExchange(ref _inner, inner, null) is not null) + throw new InvalidOperationException("The local teleport sink is already bound."); + } + + public void OnTeleportStarted(uint sequence) => Required().OnTeleportStarted(sequence); + + public void OfferDestination( + WorldSession.EntityPositionUpdate update, + bool teleportTimestampAdvanced) => + Required().OfferDestination(update, teleportTimestampAdvanced); + + public void ResetSession() => Required().ResetSession(); + + private ILocalPlayerTeleportNetworkSink Required() => + _inner ?? throw new InvalidOperationException( + "The local teleport sink was used before composition completed."); +} + +internal interface ILocalPlayerTeleportInputLifetime +{ + void EndMouseLook(); +} + +internal interface ILocalPlayerTeleportModeOperations +{ + PlayerMovementController? Controller { get; } + Matrix4x4 Projection { get; } + bool TryEnterPortalSpace(); + void EnterWorld(); +} + +internal interface ILocalPlayerTeleportAuthority +{ + bool IsFreshStart(ushort sequence); +} + +internal sealed class LiveLocalPlayerTeleportAuthority + : ILocalPlayerTeleportAuthority +{ + private readonly LiveEntityRuntime _liveEntities; + private readonly ILocalPlayerIdentitySource _identity; + + public LiveLocalPlayerTeleportAuthority( + LiveEntityRuntime liveEntities, + ILocalPlayerIdentitySource identity) + { + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + } + + public bool IsFreshStart(ushort sequence) => + _liveEntities.IsFreshTeleportStart(_identity.ServerGuid, sequence); +} + +internal interface ILocalPlayerTeleportStreamingOperations +{ + int CenterX { get; } + int CenterY { get; } + bool IsRecenterPending { get; } + bool BeginRecenter(int x, int y, bool isSealedDungeon); + bool ResetRecenter(bool sessionEnding); + bool IsSealedDungeon(uint cellId); + void SetPriority(uint landblockId, int radius); + void ClearPriority(); +} + +internal sealed class LocalPlayerTeleportStreamingOperations + : ILocalPlayerTeleportStreamingOperations +{ + private readonly LiveWorldOriginState _origin; + private readonly StreamingOriginRecenterCoordinator _recenter; + private readonly StreamingController _streaming; + private readonly ISealedDungeonCellClassifier _sealedDungeonCells; + + public LocalPlayerTeleportStreamingOperations( + LiveWorldOriginState origin, + StreamingOriginRecenterCoordinator recenter, + StreamingController streaming, + ISealedDungeonCellClassifier sealedDungeonCells) + { + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _recenter = recenter ?? throw new ArgumentNullException(nameof(recenter)); + _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming)); + _sealedDungeonCells = sealedDungeonCells + ?? throw new ArgumentNullException(nameof(sealedDungeonCells)); + } + + public int CenterX => _origin.CenterX; + public int CenterY => _origin.CenterY; + public bool IsRecenterPending => _recenter.IsPending; + + public bool BeginRecenter(int x, int y, bool isSealedDungeon) => + _recenter.Begin(x, y, isSealedDungeon); + + public bool ResetRecenter(bool sessionEnding) => + _recenter.Reset(sessionEnding); + + public bool IsSealedDungeon(uint cellId) => + _sealedDungeonCells.IsSealedDungeon(cellId); + + public void SetPriority(uint landblockId, int radius) + { + _streaming.PriorityLandblockId = landblockId; + _streaming.PriorityRadius = radius; + } + + public void ClearPriority() + { + _streaming.PriorityLandblockId = 0u; + _streaming.PriorityRadius = 0; + } +} + +internal interface ILocalPlayerTeleportPlacement +{ + void Place(Vector3 position, uint cellId, Quaternion rotation, bool forced); +} + +/// +/// Commits the local player's deferred portal arrival. It owns the exact +/// Place -> root/controller/camera mutation -> spatial reconcile edge. +/// +internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlacement +{ + private readonly PhysicsEngine _physics; + private readonly LiveEntityRuntime _liveEntities; + private readonly ILocalPlayerIdentitySource _identity; + private readonly ILocalPlayerControllerSource _controller; + private readonly ILocalPlayerPhysicsHostSource _host; + private readonly ChaseCameraInputState _cameras; + private readonly LiveWorldOriginState _origin; + private readonly ILiveSpatialReconcilePhase _spatial; + + public LocalPlayerTeleportPlacement( + PhysicsEngine physics, + LiveEntityRuntime liveEntities, + ILocalPlayerIdentitySource identity, + ILocalPlayerControllerSource controller, + ILocalPlayerPhysicsHostSource host, + ChaseCameraInputState cameras, + LiveWorldOriginState origin, + ILiveSpatialReconcilePhase spatial) + { + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + _host = host ?? throw new ArgumentNullException(nameof(host)); + _cameras = cameras ?? throw new ArgumentNullException(nameof(cameras)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _spatial = spatial ?? throw new ArgumentNullException(nameof(spatial)); + } + + public void Place(Vector3 position, uint cellId, Quaternion rotation, bool forced) + { + PlayerMovementController controller = _controller.Controller + ?? throw new InvalidOperationException( + "Teleport Place ran without the local player controller."); + var resolved = _physics.Resolve( + position, + cellId, + Vector3.Zero, + controller.StepUpHeight); + var snapped = new Vector3( + resolved.Position.X, + resolved.Position.Y, + resolved.Position.Z); + + if (forced) + { + Console.WriteLine( + $"live: teleport HOLD gave up (impossible/timeout) - force-snapping " + + $"cell=0x{cellId:X8} pos={position} -> 0x{resolved.CellId:X8} {snapped}"); + } + + uint playerGuid = _identity.ServerGuid; + controller.SetPosition( + snapped, + resolved.CellId, + CellLocalForSeed(snapped, resolved.CellId)); + + // SnapToCell owns the retail Position frame and may normalize an + // outdoor land-cell index from the cell-local origin. Publish that + // canonical result, not the pre-snap resolver hint, to rendering. + if (_liveEntities.MaterializedWorldEntities.TryGetValue( + playerGuid, + out WorldEntity? entity)) + { + entity.SetPosition(controller.Position); + entity.ParentCellId = controller.CellId; + entity.Rotation = rotation; + } + + // Retail teleport_hook tail @ 0x00514ED0 clears the local target and + // notifies every watcher that this object teleported. + _host.Host?.NotifyTeleported(); + controller.SetBodyOrientation(rotation); + + _cameras.Legacy?.Update(controller.Position, controller.Yaw); + _cameras.Retail?.ResetViewerToPlayer(controller.Position, controller.Yaw); + _spatial.Reconcile(); + + PhysicsDiagnostics.LogTeleport( + "PLACED", + controller.CellId, + $"forced={forced}"); + Console.WriteLine( + $"live: teleport materialized - snapped to {controller.Position} " + + $"cell=0x{controller.CellId:X8}"); + } + + 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; + } +} + +internal interface ILocalPlayerTeleportSession +{ + void SendLoginComplete(); +} + +internal sealed class LocalPlayerTeleportSession : ILocalPlayerTeleportSession +{ + private readonly LiveSessionController _session; + + public LocalPlayerTeleportSession(LiveSessionController session) => + _session = session ?? throw new ArgumentNullException(nameof(session)); + + public void SendLoginComplete() => + _session.CurrentSession?.SendGameAction( + GameActionLoginComplete.Build()); +} + +internal interface ILocalPlayerTeleportPresentation : IDisposable +{ + bool IsPortalViewportVisible { get; } + int CurrentTunnelFrame { get; } + void Begin(Matrix4x4 projection); + (TeleportAnimSnapshot Snapshot, IReadOnlyList Events) + Tick(float deltaSeconds, bool worldReady); + void TickTunnel(float deltaSeconds); + void EnterTunnel(); + void ExitTunnel(); + void Reset(); + Matrix4x4 ApplyViewPlane(Matrix4x4 projection); + ICamera ApplyViewPlane(ICamera camera); + void DrawPortalViewport(int width, int height, Matrix4x4 projection); +} + +internal sealed class LocalPlayerTeleportPresentation + : ILocalPlayerTeleportPresentation +{ + private readonly TeleportAnimSequencer _animation = new(); + private readonly TeleportViewPlaneController _viewPlane = new(); + private readonly PortalTunnelPresentation _tunnel; + + public LocalPlayerTeleportPresentation(PortalTunnelPresentation tunnel) => + _tunnel = tunnel ?? throw new ArgumentNullException(nameof(tunnel)); + + public bool IsPortalViewportVisible => _tunnel.IsVisible; + public int CurrentTunnelFrame => _tunnel.CurrentAnimationFrame; + + public void Begin(Matrix4x4 projection) + { + _viewPlane.Begin(projection); + _animation.Begin(TeleportEntryKind.Portal); + } + + public (TeleportAnimSnapshot Snapshot, IReadOnlyList Events) + Tick(float deltaSeconds, bool worldReady) + { + var (snapshot, events) = _animation.Tick( + deltaSeconds, + worldReady, + CurrentTunnelFrame); + _viewPlane.Update(snapshot); + return (snapshot, events); + } + + public void TickTunnel(float deltaSeconds) => _tunnel.Tick(deltaSeconds); + public void EnterTunnel() => _tunnel.Enter(); + public void ExitTunnel() => _tunnel.Exit(); + + public void Reset() + { + _animation.Reset(); + _viewPlane.Reset(); + _tunnel.Exit(); + } + + public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) => + _viewPlane.Apply(projection); + + public ICamera ApplyViewPlane(ICamera camera) => _viewPlane.ApplyTo(camera); + + public void DrawPortalViewport(int width, int height, Matrix4x4 projection) => + _tunnel.Draw(width, height, ApplyViewPlane(projection)); + + public void Dispose() => _tunnel.Dispose(); +} + +/// +/// Single update-thread owner of the local F751/Position-correlated portal +/// lifetime: activation, destination aim, readiness hold, Place, viewport +/// transitions, LoginComplete, and session/reset symmetry. +/// +internal sealed class LocalPlayerTeleportController + : ILocalPlayerTeleportFramePhase, + ILocalPlayerTeleportNetworkSink, + IDisposable +{ + internal const float MaximumWorldHoldSeconds = 10f; + internal const int NearRingRadius = + WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius; + + private readonly TeleportTransitCoordinator _transit = new(); + private readonly ILocalPlayerTeleportAuthority _authority; + private readonly ILocalPlayerTeleportInputLifetime _input; + private readonly ILocalPlayerTeleportModeOperations _mode; + private readonly ILocalPlayerTeleportStreamingOperations _streaming; + private readonly WorldRevealCoordinator _worldReveal; + private readonly ILocalPlayerTeleportPlacement _placement; + private readonly ILocalPlayerTeleportSession _session; + private readonly ILocalPlayerTeleportPresentation _presentation; + + private Vector3 _pendingPosition; + private uint _pendingCell; + private Quaternion _pendingRotation = Quaternion.Identity; + private bool _hasAcceptedDestination; + private WorldSession.EntityPositionUpdate _acceptedDestination; + private float _holdSeconds; + private bool _forced; + private long _lifetimeGeneration; + private bool _disposed; + + public LocalPlayerTeleportController( + ILocalPlayerTeleportAuthority authority, + ILocalPlayerTeleportInputLifetime input, + ILocalPlayerTeleportModeOperations mode, + ILocalPlayerTeleportStreamingOperations streaming, + WorldRevealCoordinator worldReveal, + ILocalPlayerTeleportPlacement placement, + ILocalPlayerTeleportSession session, + ILocalPlayerTeleportPresentation presentation) + { + _authority = authority ?? throw new ArgumentNullException(nameof(authority)); + _input = input ?? throw new ArgumentNullException(nameof(input)); + _mode = mode ?? throw new ArgumentNullException(nameof(mode)); + _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming)); + _worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)); + _placement = placement ?? throw new ArgumentNullException(nameof(placement)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _presentation = presentation ?? throw new ArgumentNullException(nameof(presentation)); + } + + public bool IsActive => _transit.IsActive; + public bool IsPortalViewportVisible => _presentation.IsPortalViewportVisible; + public uint ActiveDestinationCell => _transit.IsActive ? _pendingCell : 0u; + + public void OnTeleportStarted(uint sequence) + { + ThrowIfDisposed(); + ushort teleportSequence = (ushort)sequence; + if (!_authority.IsFreshStart(teleportSequence) + || !_transit.CanBegin(teleportSequence)) + { + return; + } + + long observedGeneration = _lifetimeGeneration; + _input.EndMouseLook(); + if (_lifetimeGeneration != observedGeneration) + return; + + long resetGeneration = ResetTransit(clearSession: false); + if (_lifetimeGeneration != resetGeneration) + return; + + _transit.QueueStart(teleportSequence); + TryActivatePendingPresentation(); + Console.WriteLine($"live: teleport queued (seq={sequence})"); + } + + public void OfferDestination( + WorldSession.EntityPositionUpdate update, + bool teleportTimestampAdvanced) + { + ThrowIfDisposed(); + if (_transit.OfferDestination( + update.TeleportSequence, + teleportTimestampAdvanced, + update, + out WorldSession.EntityPositionUpdate destination)) + { + AcceptDestination(destination); + } + } + + public void Tick(float deltaSeconds) + { + ThrowIfDisposed(); + TryActivatePendingPresentation(); + TryAimAcceptedDestination(); + if (!_transit.IsActive) + return; + + long generation = _lifetimeGeneration; + ushort sequence = _transit.Sequence; + if (!_mode.TryEnterPortalSpace() + || !IsCurrentLifetime(generation, sequence) + || _mode.Controller is null) + { + return; + } + + bool haveDestination = _pendingCell != 0u; + bool originReady = !_streaming.IsRecenterPending; + bool ready = haveDestination + && originReady + && _worldReveal.Evaluate(_pendingCell).IsReady; + if (!IsCurrentLifetime(generation, sequence)) + return; + + if (haveDestination && originReady && !ready) + { + _holdSeconds += deltaSeconds; + if (_holdSeconds >= MaximumWorldHoldSeconds) + { + ready = true; + _forced = true; + } + } + + var (_, events) = _presentation.Tick(deltaSeconds, ready); + if (!IsCurrentLifetime(generation, sequence)) + return; + + foreach (TeleportAnimEvent teleportEvent in events) + { + switch (teleportEvent) + { + case TeleportAnimEvent.Place: + _placement.Place( + _pendingPosition, + _pendingCell, + _pendingRotation, + _forced); + if (!IsCurrentLifetime(generation, sequence)) + return; + _worldReveal.ObserveMaterialized(); + if (!IsCurrentLifetime(generation, sequence)) + return; + _streaming.ClearPriority(); + if (!IsCurrentLifetime(generation, sequence)) + return; + break; + case TeleportAnimEvent.EnterTunnel: + _presentation.EnterTunnel(); + if (!IsCurrentLifetime(generation, sequence)) + return; + break; + case TeleportAnimEvent.PlayExitSound: + _presentation.ExitTunnel(); + if (!IsCurrentLifetime(generation, sequence)) + return; + break; + case TeleportAnimEvent.FireLoginComplete: + _mode.EnterWorld(); + if (!IsCurrentLifetime(generation, sequence)) + return; + _session.SendLoginComplete(); + if (!IsCurrentLifetime(generation, sequence)) + return; + _worldReveal.Complete(); + if (!IsCurrentLifetime(generation, sequence)) + return; + ResetTransit(clearSession: false); + return; + default: + // ClientUISystem sound-table enum playback remains outside + // this already-accepted presentation extraction. + break; + } + } + + _presentation.TickTunnel(deltaSeconds); + } + + public void ResetSession() + { + ThrowIfDisposed(); + ResetTransit(clearSession: true); + } + + public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) => + _presentation.ApplyViewPlane(projection); + + public ICamera ApplyViewPlane(ICamera camera) => + _presentation.ApplyViewPlane(camera); + + public void DrawPortalViewport(int width, int height, Matrix4x4 projection) => + _presentation.DrawPortalViewport(width, height, projection); + + private void TryActivatePendingPresentation() + { + if (!_transit.HasPendingStart) + return; + + long generation = _lifetimeGeneration; + if (!_mode.TryEnterPortalSpace() + || _lifetimeGeneration != generation + || !_transit.HasPendingStart) + { + return; + } + + _holdSeconds = 0f; + _forced = false; + _presentation.Begin(_mode.Projection); + if (_lifetimeGeneration != generation || !_transit.HasPendingStart) + return; + + bool hasBufferedDestination = _transit.Activate(out var destination); + if (hasBufferedDestination) + AcceptDestination(destination); + Console.WriteLine( + $"live: teleport presentation started (seq={_transit.Sequence})"); + } + + private void AcceptDestination(WorldSession.EntityPositionUpdate destination) + { + _acceptedDestination = destination; + _hasAcceptedDestination = true; + TryAimAcceptedDestination(); + } + + private void TryAimAcceptedDestination() + { + if (!_hasAcceptedDestination || !_transit.IsActive) + return; + + long generation = _lifetimeGeneration; + ushort sequence = _transit.Sequence; + PlayerMovementController? controller = _mode.Controller; + if (controller is null) + { + if (!_mode.TryEnterPortalSpace() + || !IsCurrentLifetime(generation, sequence)) + { + return; + } + + controller = _mode.Controller; + if (controller is null) + return; + } + + WorldSession.EntityPositionUpdate destination = _acceptedDestination; + if (!AimDestination(destination, controller, generation, sequence)) + return; + + if (IsCurrentLifetime(generation, sequence)) + { + _hasAcceptedDestination = false; + _acceptedDestination = default; + } + } + + private bool AimDestination( + WorldSession.EntityPositionUpdate update, + PlayerMovementController controller, + long generation, + ushort sequence) + { + var position = update.Position; + int landblockX = (int)((position.LandblockId >> 24) & 0xFFu); + int landblockY = (int)((position.LandblockId >> 16) & 0xFFu); + uint streamingOriginLandblockId = StreamingRegion.EncodeLandblockId( + _streaming.CenterX, + _streaming.CenterY); + var origin = new Vector3( + (landblockX - _streaming.CenterX) * 192f, + (landblockY - _streaming.CenterY) * 192f, + 0f); + var translated = new Vector3( + position.PositionX, + position.PositionY, + position.PositionZ) + origin; + + TeleportLandblockTransition transition = TeleportLandblockTransition.Classify( + controller.CellId, + position.LandblockId, + streamingOriginLandblockId); + int oldX = (int)((transition.SourceLandblockId >> 24) & 0xFFu); + int oldY = (int)((transition.SourceLandblockId >> 16) & 0xFFu); + + Console.WriteLine( + $"live: teleport arrival - old lb=({oldX},{oldY}) " + + $"new lb=({landblockX},{landblockY}) " + + $"dist={Vector3.Distance(translated, controller.Position):F1}"); + + Vector3 worldPosition; + if (transition.ChangesStreamingCenter) + { + bool isSealedDungeon = _streaming.IsSealedDungeon( + position.LandblockId); + if (!IsCurrentLifetime(generation, sequence)) + return false; + + _streaming.BeginRecenter( + landblockX, + landblockY, + isSealedDungeon); + if (!IsCurrentLifetime(generation, sequence)) + return false; + worldPosition = new Vector3( + position.PositionX, + position.PositionY, + position.PositionZ); + } + else + { + worldPosition = translated; + } + + _pendingRotation = new Quaternion( + position.RotationX, + position.RotationY, + position.RotationZ, + position.RotationW); + _pendingPosition = worldPosition; + _pendingCell = position.LandblockId; + _holdSeconds = 0f; + _forced = false; + _worldReveal.Begin(WorldRevealKind.Portal); + if (!IsCurrentLifetime(generation, sequence)) + return false; + _streaming.SetPriority( + StreamingRegion.EncodeLandblockId(landblockX, landblockY), + NearRingRadius); + if (!IsCurrentLifetime(generation, sequence)) + return false; + + PhysicsDiagnostics.LogTeleport( + "AIM", + position.LandblockId, + $"seq={update.TeleportSequence} lb={landblockX},{landblockY} " + + $"indoor={((position.LandblockId & 0xFFFFu) >= 0x0100u)} " + + $"playerCross={transition.CrossesLandblock} " + + $"centerChange={transition.ChangesStreamingCenter}"); + return true; + } + + private long ResetTransit(bool clearSession) + { + long generation = checked(++_lifetimeGeneration); + if (clearSession) + { + _transit.ClearSession(); + } + else + { + _transit.EndActive(); + } + + _pendingPosition = default; + _pendingCell = 0u; + _pendingRotation = Quaternion.Identity; + _hasAcceptedDestination = false; + _acceptedDestination = default; + _holdSeconds = 0f; + _forced = false; + + bool originConverged = _streaming.ResetRecenter(clearSession); + if (_lifetimeGeneration != generation) + return generation; + + if (clearSession) + { + _worldReveal.Cancel(); + if (_lifetimeGeneration != generation) + return generation; + } + + _presentation.Reset(); + if (_lifetimeGeneration != generation) + return generation; + + _streaming.ClearPriority(); + + if (clearSession && !originConverged) + { + throw new InvalidOperationException( + "The ending session's streaming-origin retirement has not converged."); + } + + return generation; + } + + private bool IsCurrentLifetime(long generation, ushort sequence) => + _lifetimeGeneration == generation + && _transit.IsActive + && _transit.Sequence == sequence; + + private void ThrowIfDisposed() => + ObjectDisposedException.ThrowIf(_disposed, this); + + public void Dispose() + { + if (_disposed) + return; + _presentation.Dispose(); + _disposed = true; + } +} diff --git a/src/AcDream.App/Streaming/SealedDungeonCellClassifier.cs b/src/AcDream.App/Streaming/SealedDungeonCellClassifier.cs new file mode 100644 index 00000000..f8b33215 --- /dev/null +++ b/src/AcDream.App/Streaming/SealedDungeonCellClassifier.cs @@ -0,0 +1,44 @@ +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using AcDream.Content; + +namespace AcDream.App.Streaming; + +internal interface ISealedDungeonCellClassifier +{ + bool IsSealedDungeon(uint cellId); +} + +/// +/// Classifies a server-supplied full cell ID using the EnvCell's retail +/// bit. Missing, outdoor, and structural +/// shell IDs are never guessed to be sealed. +/// +internal sealed class DatSealedDungeonCellClassifier + : ISealedDungeonCellClassifier +{ + private readonly IDatReaderWriter _dats; + private readonly object _datLock; + + public DatSealedDungeonCellClassifier( + IDatReaderWriter dats, + object datLock) + { + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); + } + + public bool IsSealedDungeon(uint cellId) + { + uint low = cellId & 0xFFFFu; + if (low < 0x0100u || low >= 0xFFFEu) + return false; + + EnvCell? envCell; + lock (_datLock) + envCell = _dats.Get(cellId); + return envCell is not null + && !envCell.Flags.HasFlag(EnvCellFlags.SeenOutside); + } +} diff --git a/src/AcDream.App/World/LiveEntityHydrationPorts.cs b/src/AcDream.App/World/LiveEntityHydrationPorts.cs index b6584cbc..b10dfcb6 100644 --- a/src/AcDream.App/World/LiveEntityHydrationPorts.cs +++ b/src/AcDream.App/World/LiveEntityHydrationPorts.cs @@ -114,7 +114,7 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC private readonly GpuWorldState _worldState; private readonly WorldRevealCoordinator _worldReveal; private readonly Func _playerGuid; - private readonly Func _isSealedDungeonCell; + private readonly ISealedDungeonCellClassifier _sealedDungeonCells; private readonly Action? _diagnostic; public LiveEntityWorldOriginCoordinator( @@ -123,7 +123,7 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC GpuWorldState worldState, WorldRevealCoordinator worldReveal, Func playerGuid, - Func isSealedDungeonCell, + ISealedDungeonCellClassifier sealedDungeonCells, Action? diagnostic = null) { _origin = origin ?? throw new ArgumentNullException(nameof(origin)); @@ -131,8 +131,8 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); _worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); - _isSealedDungeonCell = isSealedDungeonCell - ?? throw new ArgumentNullException(nameof(isSealedDungeonCell)); + _sealedDungeonCells = sealedDungeonCells + ?? throw new ArgumentNullException(nameof(sealedDungeonCells)); _diagnostic = diagnostic; } @@ -164,7 +164,8 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC _streaming.InitializeKnownLoginCenter( lbX, lbY, - isSealedDungeon: _isSealedDungeonCell(position.LandblockId)); + isSealedDungeon: _sealedDungeonCells.IsSealedDungeon( + position.LandblockId)); _worldReveal.Begin(WorldRevealKind.Login); return new( diff --git a/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs b/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs new file mode 100644 index 00000000..e61c0e50 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs @@ -0,0 +1,93 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Physics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using DatReaderWriter.Types; +using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; + +namespace AcDream.App.Tests.Input; + +public sealed class PlayerMovementPlacementTransactionTests +{ + private const uint PlayerGuid = 0x5000_0001u; + private const uint TargetGuid = 0x7000_0001u; + private const uint PriorCell = 0x0101_0101u; + private const uint DestinationCell = 0x0101_0102u; + + [Fact] + public void PreparedPosition_DoesNotPublishRenderRootOrUnstickUntilCommit() + { + PhysicsEngine physics = PhysicsWithCells(PriorCell, DestinationCell); + physics.UpdatePlayerCurrCell(PriorCell); + var controller = new PlayerMovementController(physics); + var hosts = new Dictionary(); + IPhysicsObjHost? Resolve(uint id) => hosts.GetValueOrDefault(id); + EntityPhysicsHost player = Host(PlayerGuid, Resolve); + EntityPhysicsHost target = Host(TargetGuid, Resolve); + hosts.Add(PlayerGuid, player); + hosts.Add(TargetGuid, target); + controller.PositionManager = player.PositionManager; + player.PositionManager.StickTo(TargetGuid, 0.5f, 1.8f); + + controller.PreparePositionForCommit( + new Vector3(2f, 3f, 4f), + DestinationCell, + new Vector3(2f, 3f, 4f)); + + Assert.Equal(DestinationCell, controller.CellId); + Assert.Equal(PriorCell, physics.DataCache!.CellGraph.CurrCell!.Id); + Assert.Equal(TargetGuid, player.PositionManager.GetStickyObjectId()); + + controller.CommitPreparedPosition(); + + Assert.Equal(DestinationCell, physics.DataCache.CellGraph.CurrCell!.Id); + Assert.Equal(0u, player.PositionManager.GetStickyObjectId()); + } + + private static PhysicsEngine PhysicsWithCells(params uint[] cellIds) + { + var physics = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + foreach (uint cellId in cellIds) + { + var cellStruct = new CellStruct + { + VertexArray = new VertexArray + { + Vertices = new Dictionary(), + }, + Polygons = new Dictionary(), + }; + var envCell = new DatEnvCell + { + CellPortals = [], + VisibleCells = [], + }; + physics.DataCache.CacheCellStruct( + cellId, + envCell, + cellStruct, + Matrix4x4.Identity); + } + return physics; + } + + private static EntityPhysicsHost Host( + uint guid, + Func resolve) => + new( + guid, + getPosition: () => new AcDream.Core.Physics.Position( + PriorCell, + Vector3.Zero, + Quaternion.Identity), + getVelocity: () => Vector3.Zero, + getRadius: () => 0.5f, + inContact: () => true, + minterpMaxSpeed: () => 1f, + curTime: () => 0d, + physicsTimerTime: () => 0d, + getObjectA: resolve, + handleUpdateTarget: _ => { }, + interruptCurrentMovement: () => { }); +} diff --git a/tests/AcDream.App.Tests/Interaction/PlayerApproachCompletionStateTests.cs b/tests/AcDream.App.Tests/Interaction/PlayerApproachCompletionStateTests.cs new file mode 100644 index 00000000..5933e74e --- /dev/null +++ b/tests/AcDream.App.Tests/Interaction/PlayerApproachCompletionStateTests.cs @@ -0,0 +1,79 @@ +using AcDream.App.Interaction; +using AcDream.Core.Physics; + +namespace AcDream.App.Tests.Interaction; + +public sealed class PlayerApproachCompletionStateTests +{ + [Fact] + public void CompletionMailbox_PreservesPublicationOrder() + { + var state = new PlayerApproachCompletionState(); + IPlayerApproachCompletionSink lifetime = state.BeginControllerLifetime(); + + Assert.True(state.TryBeginApproach(out PlayerApproachToken token)); + lifetime.PublishNaturalCompletion(); + lifetime.PublishCancellation(WeenieError.NoObject); + + Assert.True(state.TryTake(out PlayerApproachCompletion natural)); + Assert.Equal(token, natural.Token); + Assert.True(natural.IsNatural); + Assert.Equal(WeenieError.None, natural.Error); + Assert.True(state.TryTake(out PlayerApproachCompletion cancellation)); + Assert.False(cancellation.IsNatural); + Assert.Equal(WeenieError.NoObject, cancellation.Error); + Assert.False(state.TryTake(out _)); + } + + [Fact] + public void Clear_DiscardsEveryPendingCompletion() + { + var state = new PlayerApproachCompletionState(); + IPlayerApproachCompletionSink lifetime = state.BeginControllerLifetime(); + Assert.True(state.TryBeginApproach(out _)); + lifetime.PublishNaturalCompletion(); + lifetime.PublishCancellation(WeenieError.ActionCancelled); + + state.Clear(); + + Assert.False(state.TryTake(out _)); + } + + [Fact] + public void RetiredLifetime_CannotPublishIntoReplacementLifetime() + { + var state = new PlayerApproachCompletionState(); + IPlayerApproachCompletionSink stale = state.BeginControllerLifetime(); + Assert.True(state.TryBeginApproach(out PlayerApproachToken staleToken)); + state.RetireControllerLifetime(stale); + Assert.True(state.TryTake(out PlayerApproachCompletion retirement)); + Assert.Equal(staleToken, retirement.Token); + Assert.False(retirement.IsNatural); + IPlayerApproachCompletionSink current = state.BeginControllerLifetime(); + Assert.True(state.TryBeginApproach(out PlayerApproachToken currentToken)); + + stale.PublishNaturalCompletion(); + current.PublishNaturalCompletion(); + + Assert.True(state.TryTake(out PlayerApproachCompletion completion)); + Assert.Equal(currentToken, completion.Token); + Assert.False(state.TryTake(out _)); + } + + [Fact] + public void Retirement_ReplacesQueuedSuccessWithMatchingCancellation() + { + var state = new PlayerApproachCompletionState(); + IPlayerApproachCompletionSink lifetime = state.BeginControllerLifetime(); + Assert.True(state.TryBeginApproach(out PlayerApproachToken token)); + lifetime.PublishNaturalCompletion(); + + state.RetireControllerLifetime(lifetime); + + Assert.True(state.TryTake(out PlayerApproachCompletion completion)); + Assert.Equal(token, completion.Token); + Assert.False(completion.IsNatural); + Assert.Equal(WeenieError.ActionCancelled, completion.Error); + Assert.False(state.TryTake(out _)); + } +} diff --git a/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs b/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs index 8cc44107..8914ed91 100644 --- a/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs +++ b/tests/AcDream.App.Tests/Interaction/PlayerInteractionMovementSinkTests.cs @@ -16,10 +16,11 @@ public sealed class PlayerInteractionMovementSinkTests [Fact] public void MissingPlayerDoesNotArmTheIntent() { - var sink = new PlayerInteractionMovementSink(() => null); + var completions = new PlayerApproachCompletionState(); + var sink = new PlayerInteractionMovementSink(() => null, completions); bool armed = false; - Assert.False(sink.BeginApproach(Approach(closeRange: true), () => armed = true)); + Assert.False(sink.BeginApproach(Approach(closeRange: true), _ => armed = true)); Assert.False(armed); } @@ -66,11 +67,15 @@ public sealed class PlayerInteractionMovementSinkTests new Position(Cell, new Vector3(2f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false }); bool armedAfterCancellation = false; - var sink = new PlayerInteractionMovementSink(() => controller); + var completions = new PlayerApproachCompletionState(); + _ = completions.BeginControllerLifetime(); + var sink = new PlayerInteractionMovementSink( + () => controller, + completions); Assert.True(sink.BeginApproach( Approach(closeRange), - () => armedAfterCancellation = cancellations == 1 && !moveTo.IsMovingTo())); + _ => armedAfterCancellation = cancellations == 1 && !moveTo.IsMovingTo())); Assert.True(armedAfterCancellation); Assert.True(nonAutonomousAtTargetInstall); diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 7c64a8e8..75963f82 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -104,17 +104,20 @@ public sealed class SelectionInteractionControllerTests } } - private sealed class Movement : IPlayerInteractionMovementSink + private sealed class Movement(IPlayerApproachTokenSource approachTokens) + : IPlayerInteractionMovementSink { public List Approaches { get; } = new(); public bool Starts { get; set; } = true; public Action? AfterArm { get; set; } - public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null) + public bool BeginApproach( + InteractionApproach approach, + Action? armAfterCancel = null) { Approaches.Add(approach); - if (!Starts) + if (!Starts || !approachTokens.TryBeginApproach(out PlayerApproachToken token)) return false; - armAfterCancel?.Invoke(); + armAfterCancel?.Invoke(token); AfterArm?.Invoke(); return true; } @@ -124,7 +127,9 @@ public sealed class SelectionInteractionControllerTests { public readonly Query Query = new(); public readonly Transport Transport = new(); - public readonly Movement Movement = new(); + public readonly PlayerApproachCompletionState Completions = new(); + public readonly IPlayerApproachCompletionSink CompletionLifetime; + public readonly Movement Movement; public readonly SelectionState Selection = new(); public readonly ClientObjectTable Objects = new(); public readonly List Toasts = new(); @@ -136,6 +141,8 @@ public sealed class SelectionInteractionControllerTests public Harness() { + CompletionLifetime = Completions.BeginControllerLifetime(); + Movement = new Movement(Completions); SelectionInteractionController? controller = null; Objects.AddOrUpdate(new ClientObject { @@ -168,7 +175,8 @@ public sealed class SelectionInteractionControllerTests Items, Transport, Movement, - Toasts.Add); + Toasts.Add, + Completions); Items.PendingBackpackPlacementRequested += PendingPlacements.Add; Items.PendingBackpackPlacementCancelled += CancelledPlacements.Add; } @@ -386,6 +394,47 @@ public sealed class SelectionInteractionControllerTests Assert.Empty(h.Transport.Uses); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DeferredCompletionFromPriorApproachCannotAffectReplacement( + bool priorCompletedNaturally) + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Controller.SendUse(Target); + if (priorCompletedNaturally) + h.CompletionLifetime.PublishNaturalCompletion(); + else + h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled); + + h.Controller.SendUse(Target); + h.Controller.DrainOutbound(); + + Assert.Empty(h.Transport.Uses); + + h.CompletionLifetime.PublishNaturalCompletion(); + h.Controller.DrainOutbound(); + + Assert.Equal(new[] { Target }, h.Transport.Uses); + } + + [Fact] + public void RetiringControllerLifetimeInvalidatesQueuedArrival() + { + var h = new Harness(); + h.SetApproach(closeRange: true); + h.Controller.SendUse(Target); + h.CompletionLifetime.PublishNaturalCompletion(); + + h.Completions.RetireControllerLifetime(h.CompletionLifetime); + h.Controller.DrainOutbound(); + + Assert.Empty(h.Transport.Uses); + h.Controller.OnNaturalMoveToComplete(); + Assert.Empty(h.Transport.Uses); + } + [Fact] public void FailedClosePickupStartWithdrawsPresentation() { diff --git a/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs index 124a5375..e84b20be 100644 --- a/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs @@ -121,4 +121,30 @@ public class CameraControllerTests Assert.Null(ctl.Chase); Assert.Null(ctl.RetailChase); } + + [Fact] + public void RestoreState_ReestablishesPriorCameraAfterNotificationFailure() + { + var orbit = new OrbitCamera(); + var ctl = new CameraController(orbit, new FlyCamera()); + CameraController.CameraState prior = ctl.CaptureState(); + int notifications = 0; + ctl.ModeChanged += _ => + { + if (++notifications == 1) + throw new InvalidOperationException("injected camera observer failure"); + }; + + Assert.Throws(() => + ctl.EnterChaseMode(new ChaseCamera(), new RetailChaseCamera())); + + ctl.RestoreState(prior); + + Assert.Same(orbit, ctl.Active); + Assert.False(ctl.IsFlyMode); + Assert.False(ctl.IsChaseMode); + Assert.Null(ctl.Chase); + Assert.Null(ctl.RetailChase); + Assert.Equal(2, notifications); + } } diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs new file mode 100644 index 00000000..59878bc4 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -0,0 +1,747 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.Update; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Streaming; + +public sealed class LocalPlayerTeleportControllerTests +{ + [Fact] + public void DestinationBeforeStart_IsReplayedOnceAfterPresentationActivates() + { + var harness = new Harness(); + WorldSession.EntityPositionUpdate destination = Position( + cellId: 0x20210001u, + teleportSequence: 7, + x: 12f, + y: 34f, + z: 5f); + + harness.Controller.OfferDestination( + destination, + teleportTimestampAdvanced: true); + Assert.Empty(harness.Streaming.PriorityWrites); + + harness.Controller.OnTeleportStarted(7); + + Assert.Equal(1, harness.Input.EndCount); + Assert.Equal(1, harness.Mode.EnterPortalCount); + Assert.Equal(Matrix4x4.Identity, harness.Presentation.BeginProjection); + Assert.Equal((0x2021FFFFu, LocalPlayerTeleportController.NearRingRadius), + Assert.Single(harness.Streaming.PriorityWrites)); + Assert.True(harness.Reveal.Snapshot.IsActive); + Assert.Equal(WorldRevealKind.Portal, harness.Reveal.Snapshot.Kind); + + harness.Controller.OfferDestination( + destination, + teleportTimestampAdvanced: false); + Assert.Single(harness.Streaming.PriorityWrites); + } + + [Fact] + public void MissingPlayerController_KeepsStartAndDestinationPendingUntilProjectionExists() + { + var harness = new Harness(); + harness.Mode.Controller = null; + + harness.Controller.OnTeleportStarted(2); + harness.Controller.OfferDestination( + Position(0x20210001u, 2, 7f, 8f, 9f), + teleportTimestampAdvanced: true); + + Assert.False(harness.Controller.IsActive); + Assert.Equal(0u, harness.Controller.ActiveDestinationCell); + Assert.Null(harness.Presentation.BeginProjection); + + harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine()); + harness.Mode.Controller.SetPosition( + Vector3.Zero, + 0x20210001u, + Vector3.Zero); + harness.Controller.Tick(0.016f); + + Assert.True(harness.Controller.IsActive); + Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell); + Assert.NotNull(harness.Presentation.BeginProjection); + Assert.Single(harness.Streaming.PriorityWrites); + } + + [Fact] + public void ControllerWithdrawnAfterActivation_KeepsAcceptedDestinationUntilItReturns() + { + var harness = new Harness(); + harness.Controller.OnTeleportStarted(14); + Assert.True(harness.Controller.IsActive); + + harness.Mode.Controller = null; + harness.Controller.OfferDestination( + Position(0x20210001u, 14, 7f, 8f, 9f), + teleportTimestampAdvanced: true); + + Assert.Equal(0u, harness.Controller.ActiveDestinationCell); + Assert.Empty(harness.Streaming.PriorityWrites); + + harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine()); + harness.Mode.Controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero); + harness.Controller.Tick(0.016f); + + Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell); + Assert.Single(harness.Streaming.PriorityWrites); + } + + [Fact] + public void ControllerWithdrawnAfterAim_HoldsPresentationUntilModeRebuildsIt() + { + var harness = new Harness(); + harness.Controller.OnTeleportStarted(15); + harness.Controller.OfferDestination( + Position(0x20210001u, 15, 7f, 8f, 9f), + teleportTimestampAdvanced: true); + harness.Mode.Controller = null; + harness.Presentation.Enqueue(TeleportAnimEvent.Place); + + harness.Controller.Tick(0.016f); + + Assert.Equal(default, harness.Placement.Position); + Assert.Empty(harness.Presentation.WorldReadyValues); + + harness.Mode.RebuildOnEnter = () => + { + var controller = new PlayerMovementController(new PhysicsEngine()); + controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero); + return controller; + }; + harness.Controller.Tick(0.016f); + + Assert.NotNull(harness.Mode.Controller); + Assert.Equal(new Vector3(7f, 8f, 9f), harness.Placement.Position); + } + + [Fact] + public void SameLandblockDestination_DoesNotRecenterAndKeepsTranslatedPosition() + { + var harness = new Harness(centerX: 0x20, centerY: 0x21); + harness.Controller.OnTeleportStarted(3); + harness.Controller.OfferDestination( + Position(0x20210123u, 3, 20f, 30f, 4f), + teleportTimestampAdvanced: true); + harness.Presentation.EmitPlaceWhenReady = true; + + harness.Controller.Tick(0.016f); + + Assert.Empty(harness.Streaming.Recenters); + Assert.Equal(new Vector3(20f, 30f, 4f), harness.Placement.Position); + Assert.Equal(0x20210123u, harness.Placement.CellId); + Assert.False(harness.Placement.Forced); + } + + [Fact] + public void CrossLandblockDestination_RecentersBeforeItCanBecomeReady() + { + var harness = new Harness(centerX: 0x20, centerY: 0x21); + harness.Mode.Controller!.SetPosition( + Vector3.Zero, + 0x20210001u, + Vector3.Zero); + harness.Controller.OnTeleportStarted(4); + harness.Streaming.RecenterPending = true; + harness.Controller.OfferDestination( + Position(0x30310100u, 4, -2f, 8f, 9f), + teleportTimestampAdvanced: true); + harness.Presentation.EmitPlaceWhenReady = true; + + harness.Controller.Tick(1f); + + Assert.Equal((0x30, 0x31, true), Assert.Single(harness.Streaming.Recenters)); + Assert.False(Assert.Single(harness.Presentation.WorldReadyValues)); + Assert.Equal(default, harness.Placement.Position); + + harness.Streaming.RecenterPending = false; + harness.Controller.Tick(0.016f); + Assert.Equal(new Vector3(-2f, 8f, 9f), harness.Placement.Position); + } + + [Fact] + public void ReadinessHold_UsesWallClockAndForcesExactlyAtTenSeconds() + { + var harness = new Harness(worldReady: false); + harness.Controller.OnTeleportStarted(5); + harness.Controller.OfferDestination( + Position(0x20210001u, 5, 1f, 2f, 3f), + teleportTimestampAdvanced: true); + harness.Presentation.EmitPlaceWhenReady = true; + + harness.Controller.Tick(9.5f); + Assert.False(Assert.Single(harness.Presentation.WorldReadyValues)); + Assert.Equal(default, harness.Placement.Position); + + harness.Controller.Tick(0.5f); + + Assert.True(harness.Presentation.WorldReadyValues[^1]); + Assert.True(harness.Placement.Forced); + Assert.Equal(new Vector3(1f, 2f, 3f), harness.Placement.Position); + } + + [Fact] + public void Place_ReconcilesInsidePlacementBeforeRevealMaterialized() + { + var order = new List(); + var harness = new Harness(order: order); + harness.Controller.OnTeleportStarted(8); + harness.Controller.OfferDestination( + Position(0x20210001u, 8, 4f, 5f, 6f), + teleportTimestampAdvanced: true); + order.Clear(); + harness.Presentation.Enqueue(TeleportAnimEvent.Place); + + harness.Controller.Tick(0.016f); + + Assert.True(Index(order, "placement") < Index(order, "[world-reveal] event=materialized")); + Assert.True(Index(order, "[world-reveal] event=materialized") < Index(order, "priority-clear")); + Assert.Equal(1, harness.Reveal.PortalMaterializationCount); + } + + [Fact] + public void LoginComplete_EntersWorldThenSendsThenCompletesAndResets() + { + var order = new List(); + var harness = new Harness(order: order); + harness.Controller.OnTeleportStarted(9); + harness.Controller.OfferDestination( + Position(0x20210001u, 9, 4f, 5f, 6f), + teleportTimestampAdvanced: true); + harness.Presentation.Enqueue(TeleportAnimEvent.Place); + harness.Controller.Tick(0.016f); + order.Clear(); + harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete); + + harness.Controller.Tick(0.016f); + + Assert.True(Index(order, "enter-world") < Index(order, "login-complete")); + Assert.True(Index(order, "login-complete") < Index(order, "[world-reveal] event=complete")); + Assert.True(Index(order, "[world-reveal] event=complete") < Index(order, "presentation-reset")); + Assert.False(harness.Controller.IsActive); + Assert.Equal(0u, harness.Controller.ActiveDestinationCell); + Assert.Equal(1, harness.Session.LoginCompleteCount); + } + + [Fact] + public void NewerStart_ReplacesOldDestinationWithoutReusingIt() + { + var harness = new Harness(); + harness.Controller.OnTeleportStarted(10); + harness.Controller.OfferDestination( + Position(0x20210001u, 10, 1f, 1f, 1f), + teleportTimestampAdvanced: true); + + harness.Controller.OnTeleportStarted(11); + harness.Controller.Tick(LocalPlayerTeleportController.MaximumWorldHoldSeconds); + + Assert.Equal(default, harness.Placement.Position); + Assert.Equal(0u, harness.Controller.ActiveDestinationCell); + + harness.Controller.OfferDestination( + Position(0x20210001u, 11, 2f, 2f, 2f), + teleportTimestampAdvanced: true); + harness.Presentation.Enqueue(TeleportAnimEvent.Place); + harness.Controller.Tick(0.016f); + Assert.Equal(new Vector3(2f), harness.Placement.Position); + } + + [Fact] + public void SessionReset_ClearsTransitCancelsRevealAndRequiresRecenterConvergence() + { + var harness = new Harness(); + harness.Controller.OnTeleportStarted(12); + harness.Controller.OfferDestination( + Position(0x20210001u, 12, 1f, 2f, 3f), + teleportTimestampAdvanced: true); + + harness.Controller.ResetSession(); + + Assert.False(harness.Controller.IsActive); + Assert.True(harness.Reveal.Snapshot.Cancelled); + Assert.True(harness.Streaming.LastResetWasSessionEnding); + + var failed = new Harness(); + failed.Streaming.ResetResult = false; + Assert.Throws(() => failed.Controller.ResetSession()); + } + + [Fact] + public void StaleStart_DoesNotMutateInputModeOrPresentation() + { + var harness = new Harness(); + harness.Authority.IsFresh = false; + + harness.Controller.OnTeleportStarted(13); + + Assert.Equal(0, harness.Input.EndCount); + Assert.Equal(0, harness.Mode.EnterPortalCount); + Assert.Null(harness.Presentation.BeginProjection); + Assert.False(harness.Controller.IsActive); + } + + [Fact] + public void ReentrantNewStartDuringPlacement_CannotBeCompletedByOldTail() + { + var harness = new Harness(); + harness.Controller.OnTeleportStarted(20); + harness.Controller.OfferDestination( + Position(0x20210001u, 20, 1f, 2f, 3f), + teleportTimestampAdvanced: true); + harness.Streaming.PriorityClearCount = 0; + harness.Placement.OnPlace = () => harness.Controller.OnTeleportStarted(21); + harness.Presentation.Enqueue(TeleportAnimEvent.Place); + + harness.Controller.Tick(0.016f); + + Assert.True(harness.Controller.IsActive); + Assert.Equal(0u, harness.Controller.ActiveDestinationCell); + Assert.Equal(0, harness.Reveal.PortalMaterializationCount); + Assert.Equal(1, harness.Streaming.PriorityClearCount); + } + + [Fact] + public void ReentrantNewStartDuringLoginComplete_PreservesReplacementLifetime() + { + var harness = new Harness(); + harness.Controller.OnTeleportStarted(30); + harness.Controller.OfferDestination( + Position(0x20210001u, 30, 1f, 2f, 3f), + teleportTimestampAdvanced: true); + harness.Session.OnSend = () => harness.Controller.OnTeleportStarted(31); + harness.Presentation.Enqueue(TeleportAnimEvent.FireLoginComplete); + + harness.Controller.Tick(0.016f); + + Assert.True(harness.Controller.IsActive); + Assert.Equal(0u, harness.Controller.ActiveDestinationCell); + Assert.False(harness.Reveal.Snapshot.Completed); + Assert.Equal(1, harness.Session.LoginCompleteCount); + } + + [Fact] + public void DisposeFailure_CanBeRetriedWithoutDoubleDisposingAfterSuccess() + { + var harness = new Harness(); + harness.Presentation.DisposeFailuresRemaining = 1; + + Assert.Throws(() => harness.Controller.Dispose()); + harness.Controller.Dispose(); + harness.Controller.Dispose(); + + Assert.Equal(2, harness.Presentation.DisposeCount); + } + + [Fact] + public void ConcretePlacement_CommitsEntityControllerAndOneSpatialReconcile() + { + const uint guid = 0x50000001u; + const uint cell = 0x20210001u; + var world = new GpuWorldState(); + world.AddLandblock(new LoadedLandblock( + 0x2021FFFFu, + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty())); + var runtime = new LiveEntityRuntime(world, new NullResources()); + runtime.RegisterLiveEntity(Spawn(guid, cell)); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, + cell, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + })!; + var identity = new LocalPlayerIdentityState { ServerGuid = guid }; + var controllerSlot = new LocalPlayerControllerSlot + { + Controller = new PlayerMovementController(new PhysicsEngine()), + }; + controllerSlot.Controller.SetPosition(Vector3.Zero, cell, Vector3.Zero); + var cameras = new ChaseCameraInputState + { + Legacy = new ChaseCamera(), + Retail = new RetailChaseCamera(), + }; + var origin = new LiveWorldOriginState(); + origin.SetPlaceholder(0x20, 0x21); + var spatial = new FakeSpatialReconcile(() => new PlacementSnapshot( + entity.Position, + entity.ParentCellId ?? 0u, + entity.Rotation, + controllerSlot.Controller.Position, + controllerSlot.Controller.CellId, + controllerSlot.Controller.BodyOrientation)); + var placement = new LocalPlayerTeleportPlacement( + new PhysicsEngine(), + runtime, + identity, + controllerSlot, + new LocalPlayerPhysicsHostSlot(), + cameras, + origin, + spatial); + var position = new Vector3(12f, 24f, 6f); + Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f); + + placement.Place(position, cell, rotation, forced: false); + + Assert.Equal(entity.Position, controllerSlot.Controller.Position); + Assert.Equal(entity.ParentCellId, controllerSlot.Controller.CellId); + Assert.Equal(cell & 0xFFFF0000u, entity.ParentCellId & 0xFFFF0000u); + Assert.Equal(rotation, entity.Rotation); + Assert.Equal(rotation, controllerSlot.Controller.BodyOrientation); + Assert.Equal(1, spatial.Count); + Assert.Equal(entity.Position, spatial.Snapshot.EntityPosition); + Assert.Equal(entity.ParentCellId, spatial.Snapshot.EntityCell); + Assert.Equal(entity.Rotation, spatial.Snapshot.EntityRotation); + Assert.Equal( + controllerSlot.Controller.Position, + spatial.Snapshot.ControllerPosition); + Assert.Equal( + controllerSlot.Controller.CellId, + spatial.Snapshot.ControllerCell); + Assert.Equal( + controllerSlot.Controller.BodyOrientation, + spatial.Snapshot.ControllerRotation); + } + + private static int Index(IReadOnlyList events, string prefix) + { + for (int i = 0; i < events.Count; i++) + { + if (events[i].StartsWith(prefix, StringComparison.Ordinal)) + return i; + } + + return int.MaxValue; + } + + private static WorldSession.EntityPositionUpdate Position( + uint cellId, + ushort teleportSequence, + float x, + float y, + float z) => new( + 0x50000001u, + new CreateObject.ServerPosition( + cellId, + x, + y, + z, + 1f, + 0f, + 0f, + 0f), + null, + null, + true, + 1, + 1, + teleportSequence, + 1); + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new( + Guid: guid, + Position: new CreateObject.ServerPosition( + cell, + 0f, + 0f, + 0f, + 1f, + 0f, + 0f, + 0f), + SetupTableId: 0x02000001u, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: "player", + ItemType: null, + MotionState: null, + MotionTableId: null); + + private sealed class NullResources : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) { } + public void Unregister(WorldEntity entity) { } + } + + private sealed class FakeSpatialReconcile : ILiveSpatialReconcilePhase + { + private readonly Func _capture; + + public FakeSpatialReconcile(Func capture) => + _capture = capture; + + public int Count; + public PlacementSnapshot Snapshot; + + public void Reconcile() + { + Snapshot = _capture(); + Count++; + } + } + + private readonly record struct PlacementSnapshot( + Vector3 EntityPosition, + uint EntityCell, + Quaternion EntityRotation, + Vector3 ControllerPosition, + uint ControllerCell, + Quaternion ControllerRotation); + + private sealed class Harness + { + public readonly FakeAuthority Authority = new(); + public readonly FakeInput Input = new(); + public readonly FakeMode Mode; + public readonly FakeStreaming Streaming; + public readonly FakePlacement Placement; + public readonly FakeSession Session; + public readonly FakePresentation Presentation; + public readonly WorldRevealCoordinator Reveal; + public readonly LocalPlayerTeleportController Controller; + + public Harness( + int centerX = 0x20, + int centerY = 0x21, + bool worldReady = true, + List? order = null) + { + order ??= new List(); + Mode = new FakeMode(order); + Streaming = new FakeStreaming(centerX, centerY, order); + Placement = new FakePlacement(order); + Session = new FakeSession(order); + Presentation = new FakePresentation(order); + Reveal = new WorldRevealCoordinator( + isRenderNeighborhoodReady: (_, _) => worldReady, + isSpawnCellReady: _ => worldReady, + isTerrainNeighborhoodReady: (_, _) => worldReady, + areCompositeTexturesReady: () => worldReady, + prepareCompositeTextures: (_, _) => { }, + invalidateCompositeTextures: () => { }, + isSpawnClaimUnhydratable: _ => false, + log: order.Add); + Controller = new LocalPlayerTeleportController( + Authority, + Input, + Mode, + Streaming, + Reveal, + Placement, + Session, + Presentation); + } + } + + private sealed class FakeAuthority : ILocalPlayerTeleportAuthority + { + public bool IsFresh = true; + public bool IsFreshStart(ushort sequence) => IsFresh; + } + + private sealed class FakeInput : ILocalPlayerTeleportInputLifetime + { + public int EndCount; + public void EndMouseLook() => EndCount++; + } + + private sealed class FakeMode : ILocalPlayerTeleportModeOperations + { + private readonly List _order; + + public FakeMode(List order) + { + _order = order; + Controller = new PlayerMovementController(new PhysicsEngine()); + Controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero); + } + + public PlayerMovementController? Controller { get; set; } + public Matrix4x4 Projection => Matrix4x4.Identity; + public int EnterPortalCount; + public Func? RebuildOnEnter; + + public bool TryEnterPortalSpace() + { + EnterPortalCount++; + Controller ??= RebuildOnEnter?.Invoke(); + if (Controller is null) + return false; + Controller.State = PlayerState.PortalSpace; + return true; + } + + public void EnterWorld() + { + _order.Add("enter-world"); + if (Controller is { } controller) + controller.State = PlayerState.InWorld; + } + } + + private sealed class FakeStreaming : ILocalPlayerTeleportStreamingOperations + { + private readonly List _order; + + public FakeStreaming(int centerX, int centerY, List order) + { + CenterX = centerX; + CenterY = centerY; + _order = order; + } + + public int CenterX { get; } + public int CenterY { get; } + public bool IsRecenterPending => RecenterPending; + public bool RecenterPending; + public bool ResetResult = true; + public bool LastResetWasSessionEnding; + public int PriorityClearCount; + public readonly List<(int X, int Y, bool Sealed)> Recenters = new(); + public readonly List<(uint Landblock, int Radius)> PriorityWrites = new(); + + public bool BeginRecenter(int x, int y, bool isSealedDungeon) + { + Recenters.Add((x, y, isSealedDungeon)); + return !RecenterPending; + } + + public bool ResetRecenter(bool sessionEnding) + { + LastResetWasSessionEnding = sessionEnding; + return ResetResult; + } + + public bool IsSealedDungeon(uint cellId) => + (cellId & 0xFFFFu) >= 0x0100u; + + public void SetPriority(uint landblockId, int radius) => + PriorityWrites.Add((landblockId, radius)); + + public void ClearPriority() + { + PriorityClearCount++; + _order.Add("priority-clear"); + } + } + + private sealed class FakePlacement : ILocalPlayerTeleportPlacement + { + private readonly List _order; + + public FakePlacement(List order) => _order = order; + + public Vector3 Position; + public uint CellId; + public Quaternion Rotation; + public bool Forced; + public Action? OnPlace; + + public void Place(Vector3 position, uint cellId, Quaternion rotation, bool forced) + { + _order.Add("placement"); + Position = position; + CellId = cellId; + Rotation = rotation; + Forced = forced; + OnPlace?.Invoke(); + } + } + + private sealed class FakeSession : ILocalPlayerTeleportSession + { + private readonly List _order; + + public FakeSession(List order) => _order = order; + + public int LoginCompleteCount; + public Action? OnSend; + + public void SendLoginComplete() + { + _order.Add("login-complete"); + LoginCompleteCount++; + OnSend?.Invoke(); + } + } + + private sealed class FakePresentation : ILocalPlayerTeleportPresentation + { + private readonly List _order; + private readonly Queue> _events = new(); + + public FakePresentation(List order) => _order = order; + + public bool IsPortalViewportVisible { get; private set; } + public int CurrentTunnelFrame => 72; + public Matrix4x4? BeginProjection; + public bool EmitPlaceWhenReady; + public readonly List WorldReadyValues = new(); + public int DisposeFailuresRemaining; + public int DisposeCount; + + public void Enqueue(params TeleportAnimEvent[] events) => _events.Enqueue(events); + + public void Begin(Matrix4x4 projection) + { + BeginProjection = projection; + _order.Add("presentation-begin"); + } + + public (TeleportAnimSnapshot Snapshot, IReadOnlyList Events) + Tick(float deltaSeconds, bool worldReady) + { + WorldReadyValues.Add(worldReady); + if (_events.Count > 0) + return (default, _events.Dequeue()); + if (EmitPlaceWhenReady && worldReady) + { + EmitPlaceWhenReady = false; + return (default, new[] { TeleportAnimEvent.Place }); + } + + return (default, Array.Empty()); + } + + public void TickTunnel(float deltaSeconds) => _order.Add("tunnel-tick"); + public void EnterTunnel() => IsPortalViewportVisible = true; + public void ExitTunnel() => IsPortalViewportVisible = false; + + public void Reset() + { + _order.Add("presentation-reset"); + IsPortalViewportVisible = false; + BeginProjection = null; + _events.Clear(); + } + + public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) => projection; + public ICamera ApplyViewPlane(ICamera camera) => camera; + public void DrawPortalViewport(int width, int height, Matrix4x4 projection) { } + public void Dispose() + { + DisposeCount++; + if (DisposeFailuresRemaining-- > 0) + throw new InvalidOperationException("injected disposal failure"); + } + } +} diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs index 498d6694..1c2f605f 100644 --- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -175,7 +175,16 @@ public sealed class GameWindowLiveEntityCompositionTests string source = File.ReadAllText(Path.Combine( FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs")); - Assert.Contains("_localPlayerMode.ResetSession();", source, StringComparison.Ordinal); + Assert.Contains("_playerModeController?.ResetSession();", source, + StringComparison.Ordinal); + string playerModeSource = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Input", + "PlayerModeController.cs")); + Assert.Contains("_mode.ResetSession();", playerModeSource, + StringComparison.Ordinal); Assert.Contains( "_liveEntityNetworkUpdates?.ResetSessionState();", source, diff --git a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs index c2a35f44..dac86aa2 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs @@ -124,6 +124,40 @@ public sealed class LiveEntityPhysicsHostOwnershipTests Assert.Equal(12f, watcher.Position.Frame.Origin.X); } + [Fact] + public void PreparedRebind_DoesNotPublishDelegatesUntilCommit() + { + const uint guid = 0x70000025u; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + EntityPhysicsHost original = Host( + guid, + position: new Vector3(1f, 0f, 0f)); + runtime.InstallPhysicsHost(record, original); + EntityPhysicsHost configuration = Host( + guid, + position: new Vector3(9f, 0f, 0f)); + + EntityPhysicsHost stable = EntityPhysicsHost.SelectStableHostWithoutRebind( + runtime, + record, + configuration); + + // A late DAT/physics/camera preparation failure occurs here in the + // player-mode transaction. The live record must still expose its + // previous delegates and manager identity. + Assert.Same(original, stable); + Assert.Same(original, record.PhysicsHost); + Assert.Equal(1f, original.Position.Frame.Origin.X); + + EntityPhysicsHost committed = EntityPhysicsHost.InstallOrRebind( + runtime, + record, + configuration); + Assert.Same(original, committed); + Assert.Equal(9f, committed.Position.Frame.Origin.X); + } + [Fact] public void SameGuidReplacement_HostCallbacksRemainIncarnationLocal() { diff --git a/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs b/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs index aadcd00e..eb4b7261 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs @@ -23,7 +23,7 @@ public sealed class LiveEntityWorldOriginCoordinatorTests world, Reveal(), () => playerGuid, - _ => false); + new FakeSealedDungeonCellClassifier()); LiveEntityOriginInitialization ignored = coordinator.TryInitialize( Spawn(0x50000002u, 0x09090001u)); @@ -59,7 +59,7 @@ public sealed class LiveEntityWorldOriginCoordinatorTests world, Reveal(), () => playerGuid, - _ => false); + new FakeSealedDungeonCellClassifier()); Assert.True(coordinator.TryInitialize(Spawn(playerGuid, 0x03040001u)).IsKnown); origin.Reset(); @@ -114,4 +114,10 @@ public sealed class LiveEntityWorldOriginCoordinatorTests ItemType: null, MotionState: null, MotionTableId: null); + + private sealed class FakeSealedDungeonCellClassifier + : ISealedDungeonCellClassifier + { + public bool IsSealedDungeon(uint cellId) => false; + } } diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index dd1a69eb..ee92d1e7 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -414,18 +414,32 @@ public sealed class UpdateFrameOrchestratorTests StringComparison.Ordinal); Assert.DoesNotContain("wantCaptureMouse: ()", source, StringComparison.Ordinal); - Assert.Equal(5, CountOccurrences( + Assert.Equal(2, CountOccurrences( source, "_gameplayInputFrame?.EndMouseLook();")); Assert.Contains( "new(\"mouse capture\", () => _gameplayInputFrame?.EndMouseLook())", source, StringComparison.Ordinal); + string teleportSource = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Streaming", + "LocalPlayerTeleportController.cs")); AssertAppearsInOrder( - source, - "_teleportTransit.CanBegin(teleportSequence)", - "_gameplayInputFrame?.EndMouseLook();", - "_teleportTransit.QueueStart(teleportSequence)"); + teleportSource, + "_transit.CanBegin(teleportSequence)", + "_input.EndMouseLook();", + "_transit.QueueStart(teleportSequence)"); + string playerModeSource = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Input", + "PlayerModeController.cs")); + Assert.Contains("_input.EndMouseLook();", playerModeSource, + StringComparison.Ordinal); AssertAppearsInOrder( source, "private void OnFocusChanged(bool focused)", @@ -433,6 +447,50 @@ public sealed class UpdateFrameOrchestratorTests "_gameplayInputFrame?.EndMouseLook();"); } + [Fact] + public void GameWindow_DelegatesTheCompleteLocalTeleportLifetime() + { + string root = FindRepoRoot(); + string source = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + string networkSource = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Physics", + "LiveEntityNetworkUpdateController.cs")); + + Assert.Contains( + "new AcDream.App.Streaming.LocalPlayerTeleportController(", + source, + StringComparison.Ordinal); + Assert.Equal(1, CountOccurrences( + source, + "_localPlayerTeleport!.Tick(frameDelta);")); + Assert.DoesNotContain("_teleportTransit", source, StringComparison.Ordinal); + Assert.DoesNotContain("_teleportAnim", source, StringComparison.Ordinal); + Assert.DoesNotContain("_teleportViewPlane", source, StringComparison.Ordinal); + Assert.DoesNotContain("_pendingTeleport", source, StringComparison.Ordinal); + Assert.DoesNotContain("AimTeleportDestination", source, StringComparison.Ordinal); + Assert.DoesNotContain("ResetTeleportTransitState", source, StringComparison.Ordinal); + Assert.DoesNotContain("PlaceTeleportArrival", source, StringComparison.Ordinal); + Assert.DoesNotContain("TryActivatePendingTeleportPresentation", source, + StringComparison.Ordinal); + Assert.DoesNotContain("Action", networkSource, + StringComparison.Ordinal); + Assert.Contains("ILocalPlayerTeleportNetworkSink", networkSource, + StringComparison.Ordinal); + AssertAppearsInOrder( + source, + "_liveEntityLiveness?.Tick(ClientTimerNow());", + "_localPlayerTeleport!.Tick(frameDelta);", + "_playerModeAutoEntry?.TryEnter();"); + } + [Fact] public void GameplayInputOwnersUseTypedSeamsWithoutGameWindowBackReferences() { @@ -498,6 +556,45 @@ public sealed class UpdateFrameOrchestratorTests BindingFlags.Instance | BindingFlags.NonPublic), field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + FieldInfo approachCompletions = Assert.Single( + typeof(AcDream.App.Input.PlayerModeController).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_approachCompletions"); + Assert.Equal( + typeof(AcDream.App.Interaction.IPlayerApproachCompletionLifetimeOwner), + approachCompletions.FieldType); + Assert.DoesNotContain( + typeof(AcDream.App.Input.PlayerModeController).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.FieldType + == typeof(AcDream.App.Interaction.SelectionInteractionController)); + Assert.DoesNotContain( + typeof(AcDream.App.Interaction.PlayerApproachCompletionState).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + + string playerModeSource = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Input", + "PlayerModeController.cs")); + AssertAppearsInOrder( + playerModeSource, + "controller.PreparePositionForCommit(", + "_camera.EnterChaseMode(legacyCamera, retailCamera);", + "EntityPhysicsHost.SelectStableHostWithoutRebind(", + "_shadow.SyncPose(", + "EntityPhysicsHost.InstallOrRebind(", + "playerEntity.SetPosition(initial.Position);", + "controller.CommitPreparedPosition();", + "_controllerSlot.Controller = controller;", + "_mode.IsPlayerMode = true;"); + Assert.Contains("_shadow.Restore(playerEntity, priorShadow);", playerModeSource, + StringComparison.Ordinal); + Assert.Contains("_camera.RestoreState(priorCamera);", playerModeSource, + StringComparison.Ordinal); + string mouseLookSource = File.ReadAllText(Path.Combine( FindRepoRoot(), "src", diff --git a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs index 4ba0fea6..bc63c7f1 100644 --- a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs +++ b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs @@ -27,6 +27,7 @@ public sealed class AutoEnterPlayerModeTests // Defaults TRUE: the world-reveal hold is not under test in the // original K.2 cases (see WorldNotReady test for it). public bool WorldReady = true; + public bool PlayerModeActive; public int EnteredCount; public PlayerModeAutoEntry Build() => @@ -35,7 +36,8 @@ public sealed class AutoEnterPlayerModeTests isPlayerEntityPresent: () => PlayerEntityPresent, isPlayerControllerReady: () => PlayerControllerReady, isWorldReady: () => WorldReady, - enterPlayerMode: () => EnteredCount++); + enterPlayerMode: () => EnteredCount++, + isPlayerModeActive: () => PlayerModeActive); } [Fact] @@ -180,4 +182,25 @@ public sealed class AutoEnterPlayerModeTests Assert.True(guard.TryEnter()); Assert.Equal(1, s.EnteredCount); } + + [Fact] + public void PortalEntryThatAlreadyActivatedPlayerMode_RetiresArmedGuard() + { + var s = new State + { + LiveInWorld = true, + PlayerEntityPresent = true, + PlayerControllerReady = true, + }; + var guard = s.Build(); + guard.Arm(); + + // F751/player-mode activation occurs before the auto-entry phase in + // the update graph. The guard must not rebuild that PortalSpace owner. + s.PlayerModeActive = true; + + Assert.False(guard.TryEnter()); + Assert.False(guard.IsArmed); + Assert.Equal(0, s.EnteredCount); + } } From 947c61d2d7c285a98161b0ecec52c9aa75e83451 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 03:08:58 +0200 Subject: [PATCH 78/98] refactor(camera): extract the update frame Move fly/chase publication, combat target tracking, and local player projection behind typed runtime owners. Preserve the inbound-created projection/reconcile barrier while removing GameWindow callbacks and duplicate shadow helpers. --- ...ndow-slice-6-update-frame-orchestration.md | 18 +- .../Combat/CombatCameraTargetSource.cs | 53 +++ .../Combat/LiveCombatAttackOperations.cs | 2 + .../Input/DispatcherCameraInputSource.cs | 72 +++++ .../Input/DispatcherMovementInputSource.cs | 1 + .../Input/LocalPlayerFrameRuntime.cs | 122 +++++++ .../Input/LocalPlayerProjectionController.cs | 96 ++++-- src/AcDream.App/Input/MouseLookController.cs | 8 +- .../Input/RetailLocalPlayerFrameController.cs | 88 ++--- .../Interaction/WorldSelectionQuery.cs | 1 + .../Rendering/CameraFrameController.cs | 114 +++++++ src/AcDream.App/Rendering/GameWindow.cs | 303 ++++-------------- .../Combat/CombatCameraTargetSourceTests.cs | 69 ++++ .../Combat/LiveCombatAttackOperationsTests.cs | 1 + .../LocalPlayerProjectionControllerTests.cs | 33 +- .../RetailLocalPlayerFrameControllerTests.cs | 73 ++++- .../SelectionInteractionControllerTests.cs | 1 + .../Rendering/CameraFrameControllerTests.cs | 231 +++++++++++++ .../World/UpdateFrameOrchestratorTests.cs | 58 ++++ 19 files changed, 988 insertions(+), 356 deletions(-) create mode 100644 src/AcDream.App/Combat/CombatCameraTargetSource.cs create mode 100644 src/AcDream.App/Input/DispatcherCameraInputSource.cs create mode 100644 src/AcDream.App/Input/LocalPlayerFrameRuntime.cs create mode 100644 src/AcDream.App/Rendering/CameraFrameController.cs create mode 100644 tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md index 4b2af346..7cef3403 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md @@ -22,9 +22,9 @@ never hidden behind a retry, delay, suppression flag, or reordered callback. collapse, streaming tick, and rescued-entity rebucketing. - [x] D — extract dispatcher/raw-mouse/combat input sampling without changing movement-command or UI-capture semantics. -- [ ] E — extract the complete local-player teleport owner and wire it between +- [x] E — extract the complete local-player teleport owner and wire it between the existing post-network liveness and player-mode auto-entry phases. -- [ ] F — extract the shared player-mode lifecycle plus fly/chase/player +- [x] F — extract the shared player-mode lifecycle plus fly/chase/player camera presentation and cut production over to one `UpdateFrameOrchestrator`. - [ ] G — delete the old `OnUpdate` bodies, callback facades, and obsolete @@ -455,6 +455,20 @@ lines at this checkpoint, down from the slice baseline of 15,723. - measure line/field/method count. The target is below 8,000 lines, but the ownership/ordering exit criteria outrank the count. +**Checkpoint F completed 2026-07-22.** `CameraFrameController` now owns fly +held input, retail chase adjustment, the inbound-created-player projection +and second reconcile, both chase-camera publications, and combat-target +tracking. `RetailLocalPlayerFrameController` and +`LocalPlayerProjectionController` resolve current identity, session, world, +shadow, and player slots through focused typed runtimes instead of callbacks +into `GameWindow`; the camera receives only the two-read player-presentation +seam. Projection and frame owners remain composition locals, so the window +retains no duplicate lifecycle state. Focused Release tests are green at +37/37, the App suite at 2,820 passed / 3 skipped, and the full suite at 7,179 +passed / 5 skipped. All three corrected-diff reviews are clean. +`GameWindow.cs` is 7,160 lines, down 8,563 lines (54.5%) from the 15,723-line +slice baseline. + ### H — release and connected gates After all three independent corrected-diff reviews are clean: diff --git a/src/AcDream.App/Combat/CombatCameraTargetSource.cs b/src/AcDream.App/Combat/CombatCameraTargetSource.cs new file mode 100644 index 00000000..2f2756ed --- /dev/null +++ b/src/AcDream.App/Combat/CombatCameraTargetSource.cs @@ -0,0 +1,53 @@ +using System.Numerics; +using AcDream.App.Interaction; +using AcDream.Core.Combat; +using AcDream.Core.Selection; + +namespace AcDream.App.Combat; + +internal interface ICombatCameraTargetSource +{ + Vector3? GetTrackedTargetPoint(); +} + +/// +/// Resolves retail's combat-camera target from canonical combat, selection, +/// gameplay-option, and world-selection owners. +/// +/// +/// ClientCombatSystem::UpdateTargetTracking @ 0x0056A950 enables +/// CameraSet::TrackTarget only for melee/missile combat with the option +/// enabled and a valid attack target. TrackTarget applies target-local offset +/// (0, 0, 0.5). +/// +internal sealed class CombatCameraTargetSource : ICombatCameraTargetSource +{ + private readonly ICombatGameplaySettingsSource _settings; + private readonly CombatState _combat; + private readonly SelectionState _selection; + private readonly IWorldSelectionQuery _world; + + public CombatCameraTargetSource( + ICombatGameplaySettingsSource settings, + CombatState combat, + SelectionState selection, + IWorldSelectionQuery world) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + _world = world ?? throw new ArgumentNullException(nameof(world)); + } + + public Vector3? GetTrackedTargetPoint() + { + if (!_settings.ViewCombatTarget + || !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode) + || _selection.SelectedObjectId is not uint selected) + { + return null; + } + + return _world.GetCombatCameraTargetPoint(selected); + } +} diff --git a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs index 0d5ee7ee..c37f4328 100644 --- a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs +++ b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs @@ -15,6 +15,7 @@ internal interface ICombatGameplaySettingsSource { bool AutoTarget { get; } bool AutoRepeatAttack { get; } + bool ViewCombatTarget { get; } } internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource @@ -22,6 +23,7 @@ internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource public GameplaySettings Value { get; set; } = GameplaySettings.Default; public bool AutoTarget => Value.AutoTarget; public bool AutoRepeatAttack => Value.AutoRepeatAttack; + public bool ViewCombatTarget => Value.ViewCombatTarget; } internal interface ICombatFeedbackSink diff --git a/src/AcDream.App/Input/DispatcherCameraInputSource.cs b/src/AcDream.App/Input/DispatcherCameraInputSource.cs new file mode 100644 index 00000000..4b4e982d --- /dev/null +++ b/src/AcDream.App/Input/DispatcherCameraInputSource.cs @@ -0,0 +1,72 @@ +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Input; + +internal readonly record struct FlyCameraInput( + bool Forward, + bool Left, + bool Backward, + bool Right, + bool Up, + bool Down, + bool Boost); + +internal readonly record struct ChaseCameraAdjustmentInput( + bool ZoomIn, + bool ZoomOut, + bool Raise, + bool Lower); + +internal interface ICameraFrameInputSource +{ + bool IsAvailable { get; } + FlyCameraInput CaptureFly(); + ChaseCameraAdjustmentInput CaptureChaseAdjustment(); +} + +/// +/// Samples held camera actions from the same semantic dispatcher used by +/// gameplay movement. Binding is a one-time startup edge. +/// +internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource +{ + private InputDispatcher? _dispatcher; + + public bool IsAvailable => _dispatcher is not null; + + public void Bind(InputDispatcher dispatcher) + { + ArgumentNullException.ThrowIfNull(dispatcher); + if (_dispatcher is not null && !ReferenceEquals(_dispatcher, dispatcher)) + throw new InvalidOperationException( + "The camera input source is already bound to another dispatcher."); + _dispatcher = dispatcher; + } + + public FlyCameraInput CaptureFly() + { + if (_dispatcher is not { } dispatcher) + return default; + + return new FlyCameraInput( + dispatcher.IsActionHeld(InputAction.MovementForward), + dispatcher.IsActionHeld(InputAction.MovementTurnLeft), + dispatcher.IsActionHeld(InputAction.MovementBackup), + dispatcher.IsActionHeld(InputAction.MovementTurnRight), + dispatcher.IsActionHeld(InputAction.MovementJump), + dispatcher.IsActionHeld(InputAction.AcdreamFlyDown), + dispatcher.IsActionHeld(InputAction.MovementRunLock)); + } + + public ChaseCameraAdjustmentInput CaptureChaseAdjustment() + { + if (_dispatcher is not { } dispatcher) + return default; + + return new ChaseCameraAdjustmentInput( + dispatcher.IsActionHeld(InputAction.CameraZoomIn), + dispatcher.IsActionHeld(InputAction.CameraZoomOut), + dispatcher.IsActionHeld(InputAction.CameraRaise), + dispatcher.IsActionHeld(InputAction.CameraLower)); + } +} diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs index c5207ab7..b8a2e20f 100644 --- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs +++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs @@ -20,6 +20,7 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource _capture = capture; public bool AutoRunActive { get; private set; } + public bool IsAvailable => _dispatcher is not null; public void Bind(InputDispatcher dispatcher) { diff --git a/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs b/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs new file mode 100644 index 00000000..f659ca88 --- /dev/null +++ b/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs @@ -0,0 +1,122 @@ +using AcDream.App.Net; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.World; +using AcDream.Core.Physics; + +namespace AcDream.App.Input; + +/// +/// Focused runtime seam for the local player's retail object and command +/// phases. The frame controller owns ordering; this seam resolves the current +/// session-scoped owners without retaining callbacks into the window host. +/// +internal interface ILocalPlayerPresentationRuntime +{ + bool CanPresentPlayer { get; } + PlayerMovementController? Controller { get; } +} + +internal interface ILocalPlayerFrameRuntime : ILocalPlayerPresentationRuntime +{ + uint ResolveLocalEntityId(); + void HandleTargeting(); + bool IsHidden { get; } + RetailObjectClockDisposition ObjectClockDisposition { get; } + void Project(PlayerMovementController controller, MovementResult movement, bool hidden); + void SendPreNetwork(PlayerMovementController controller, MovementResult movement, bool hidden); + void SendPostNetwork(PlayerMovementController controller, bool hidden); +} + +/// +/// Production local-player frame runtime composed from canonical typed owners. +/// +internal sealed class LiveLocalPlayerFrameRuntime : ILocalPlayerFrameRuntime +{ + private readonly CameraController _camera; + private readonly ILocalPlayerModeSource _mode; + private readonly ILocalPlayerControllerSource _controller; + private readonly IChaseCameraSource _chase; + private readonly DispatcherMovementInputSource _input; + private readonly IInputCaptureSource _capture; + private readonly LiveEntityRuntime _liveEntities; + private readonly ILocalPlayerIdentitySource _identity; + private readonly ILocalPlayerPhysicsHostSource _physicsHost; + private readonly LocalPlayerProjectionController _projection; + private readonly LocalPlayerOutboundController _outbound; + private readonly ILiveWorldSessionSource _session; + + public LiveLocalPlayerFrameRuntime( + CameraController camera, + ILocalPlayerModeSource mode, + ILocalPlayerControllerSource controller, + IChaseCameraSource chase, + DispatcherMovementInputSource input, + IInputCaptureSource capture, + LiveEntityRuntime liveEntities, + ILocalPlayerIdentitySource identity, + ILocalPlayerPhysicsHostSource physicsHost, + LocalPlayerProjectionController projection, + LocalPlayerOutboundController outbound, + ILiveWorldSessionSource session) + { + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _mode = mode ?? throw new ArgumentNullException(nameof(mode)); + _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + _chase = chase ?? throw new ArgumentNullException(nameof(chase)); + _input = input ?? throw new ArgumentNullException(nameof(input)); + _capture = capture ?? throw new ArgumentNullException(nameof(capture)); + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _physicsHost = physicsHost ?? throw new ArgumentNullException(nameof(physicsHost)); + _projection = projection ?? throw new ArgumentNullException(nameof(projection)); + _outbound = outbound ?? throw new ArgumentNullException(nameof(outbound)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + } + + public bool CanPresentPlayer => + !_camera.IsFlyMode + && _mode.IsPlayerMode + && _controller.Controller is not null + && _chase.Legacy is not null + && _input.IsAvailable + && !_capture.DevToolsWantCaptureKeyboard; + + public PlayerMovementController? Controller => _controller.Controller; + + public uint ResolveLocalEntityId() => + _liveEntities.TryGetWorldEntity(_identity.ServerGuid, out var entity) + ? entity.Id + : 0u; + + public void HandleTargeting() => _physicsHost.Host?.HandleTargetting(); + + public bool IsHidden => _liveEntities.IsHidden(_identity.ServerGuid); + + public RetailObjectClockDisposition ObjectClockDisposition => + _liveEntities.GetRootObjectClockDisposition(_identity.ServerGuid); + + public void Project( + PlayerMovementController controller, + MovementResult movement, + bool hidden) => + _projection.Project(controller, movement, hidden); + + public void SendPreNetwork( + PlayerMovementController controller, + MovementResult movement, + bool hidden) => + _outbound.SendPreNetworkActions( + _session.CurrentSession, + controller, + movement, + hidden); + + public void SendPostNetwork( + PlayerMovementController controller, + bool hidden) => + _outbound.SendPostNetworkPosition( + _session.CurrentSession, + controller, + hidden); +} diff --git a/src/AcDream.App/Input/LocalPlayerProjectionController.cs b/src/AcDream.App/Input/LocalPlayerProjectionController.cs index 0d5697bc..8a32c4d7 100644 --- a/src/AcDream.App/Input/LocalPlayerProjectionController.cs +++ b/src/AcDream.App/Input/LocalPlayerProjectionController.cs @@ -1,47 +1,77 @@ using AcDream.Core.World; +using AcDream.App.Physics; +using AcDream.App.World; namespace AcDream.App.Input; +internal interface ILocalPlayerProjectionRuntime +{ + WorldEntity? ResolveEntity(); + int LiveCenterX { get; } + int LiveCenterY { get; } + void SyncShadow(WorldEntity entity, uint cellId); + void Rebucket(uint serverGuid, uint landblockId); + bool IsCurrentVisibleProjection(WorldEntity entity); + void SuspendShadow(WorldEntity entity); +} + +internal sealed class LiveLocalPlayerProjectionRuntime + : ILocalPlayerProjectionRuntime +{ + private readonly LiveEntityRuntime _liveEntities; + private readonly ILocalPlayerIdentitySource _identity; + private readonly LiveWorldOriginState _origin; + private readonly LocalPlayerShadowSynchronizer _shadow; + + public LiveLocalPlayerProjectionRuntime( + LiveEntityRuntime liveEntities, + ILocalPlayerIdentitySource identity, + LiveWorldOriginState origin, + LocalPlayerShadowSynchronizer shadow) + { + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _shadow = shadow ?? throw new ArgumentNullException(nameof(shadow)); + } + + public WorldEntity? ResolveEntity() => + _liveEntities.TryGetWorldEntity(_identity.ServerGuid, out var entity) + ? entity + : null; + + public int LiveCenterX => _origin.CenterX; + public int LiveCenterY => _origin.CenterY; + + public void SyncShadow(WorldEntity entity, uint cellId) => + _shadow.Sync(entity, cellId); + + public void Rebucket(uint serverGuid, uint landblockId) => + _liveEntities.RebucketLiveEntity(serverGuid, landblockId); + + public bool IsCurrentVisibleProjection(WorldEntity entity) => + _shadow.IsCurrentVisibleProjection(entity); + + public void SuspendShadow(WorldEntity entity) => _shadow.Suspend(entity); +} + /// /// Projects the canonical local physics body into world rendering and spatial /// buckets without advancing it. /// public sealed class LocalPlayerProjectionController { - private readonly Func _resolveEntity; - private readonly Func _liveCenterX; - private readonly Func _liveCenterY; - private readonly Action _syncShadow; - private readonly Action _rebucket; - private readonly Func _isCurrentVisibleProjection; - private readonly Action _suspendShadow; + private readonly ILocalPlayerProjectionRuntime _runtime; - public LocalPlayerProjectionController( - Func resolveEntity, - Func liveCenterX, - Func liveCenterY, - Action syncShadow, - Action rebucket, - Func isCurrentVisibleProjection, - Action suspendShadow) - { - _resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity)); - _liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX)); - _liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY)); - _syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow)); - _rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket)); - _isCurrentVisibleProjection = isCurrentVisibleProjection - ?? throw new ArgumentNullException(nameof(isCurrentVisibleProjection)); - _suspendShadow = suspendShadow - ?? throw new ArgumentNullException(nameof(suspendShadow)); - } + internal LocalPlayerProjectionController(ILocalPlayerProjectionRuntime runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); public void Project( PlayerMovementController controller, MovementResult movement, bool hidden) { - WorldEntity? entity = _resolveEntity(); + WorldEntity? entity = _runtime.ResolveEntity(); if (entity is null) return; @@ -62,8 +92,8 @@ public sealed class LocalPlayerProjectionController else { System.Numerics.Vector3 position = controller.Position; - int landblockX = _liveCenterX() + (int)Math.Floor(position.X / 192f); - int landblockY = _liveCenterY() + (int)Math.Floor(position.Y / 192f); + int landblockX = _runtime.LiveCenterX + (int)Math.Floor(position.X / 192f); + int landblockY = _runtime.LiveCenterY + (int)Math.Floor(position.Y / 192f); currentLandblock = (uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF); } @@ -76,15 +106,15 @@ public sealed class LocalPlayerProjectionController // membership, then replaces collision rows only while the object still // owns a loaded cell. Rebucket is a synchronous callback boundary: it // can move this incarnation to pending or replace the GUID entirely. - _rebucket(entity.ServerGuid, currentLandblock); + _runtime.Rebucket(entity.ServerGuid, currentLandblock); if (hidden || movement.CellId == 0 - || !_isCurrentVisibleProjection(entity)) + || !_runtime.IsCurrentVisibleProjection(entity)) { - _suspendShadow(entity); + _runtime.SuspendShadow(entity); return; } - _syncShadow(entity, movement.CellId); + _runtime.SyncShadow(entity, movement.CellId); } } diff --git a/src/AcDream.App/Input/MouseLookController.cs b/src/AcDream.App/Input/MouseLookController.cs index 69c9efbe..c8966f67 100644 --- a/src/AcDream.App/Input/MouseLookController.cs +++ b/src/AcDream.App/Input/MouseLookController.cs @@ -73,7 +73,13 @@ internal sealed class SilkMouseLookCursor : IMouseLookCursor /// The current chase-camera input targets. GameWindow keeps compatibility /// properties over this one slot until checkpoint F moves camera ownership. /// -internal sealed class ChaseCameraInputState +internal interface IChaseCameraSource +{ + ChaseCamera? Legacy { get; } + RetailChaseCamera? Retail { get; } +} + +internal sealed class ChaseCameraInputState : IChaseCameraSource { public ChaseCamera? Legacy { get; set; } public RetailChaseCamera? Retail { get; set; } diff --git a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs index a5599bdc..0d8d1356 100644 --- a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs +++ b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs @@ -14,17 +14,8 @@ namespace AcDream.App.Input; /// internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFramePhase { - private readonly Func _canPresentPlayer; - private readonly Func _getController; + private readonly ILocalPlayerFrameRuntime _runtime; private readonly IMovementInputSource _movementInput; - private readonly Func _resolveLocalEntityId; - private readonly Action _handleTargeting; - private readonly Func _isHidden; - private readonly Action _project; - private readonly Action _sendPreNetwork; - private readonly Action _sendPostNetwork; - private readonly Func - _objectClockDisposition; private AdvancedFrame? _advancedFrame; @@ -51,38 +42,12 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram }; public RetailLocalPlayerFrameController( - Func canPresentPlayer, - Func getController, - IMovementInputSource movementInput, - Func resolveLocalEntityId, - Action handleTargeting, - Func isHidden, - Action project, - Action sendPreNetwork, - Action sendPostNetwork, - Func? - objectClockDisposition = null) + ILocalPlayerFrameRuntime runtime, + IMovementInputSource movementInput) { - _canPresentPlayer = canPresentPlayer - ?? throw new ArgumentNullException(nameof(canPresentPlayer)); - _getController = getController - ?? throw new ArgumentNullException(nameof(getController)); + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _movementInput = movementInput ?? throw new ArgumentNullException(nameof(movementInput)); - _resolveLocalEntityId = resolveLocalEntityId - ?? throw new ArgumentNullException(nameof(resolveLocalEntityId)); - _handleTargeting = handleTargeting - ?? throw new ArgumentNullException(nameof(handleTargeting)); - _isHidden = isHidden - ?? throw new ArgumentNullException(nameof(isHidden)); - _project = project - ?? throw new ArgumentNullException(nameof(project)); - _sendPreNetwork = sendPreNetwork - ?? throw new ArgumentNullException(nameof(sendPreNetwork)); - _sendPostNetwork = sendPostNetwork - ?? throw new ArgumentNullException(nameof(sendPostNetwork)); - _objectClockDisposition = objectClockDisposition - ?? (() => AcDream.Core.Physics.RetailObjectClockDisposition.Advance); } /// @@ -92,15 +57,15 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram public void AdvanceBeforeNetwork(float deltaSeconds) { _advancedFrame = null; - PlayerMovementController? controller = _getController(); - if (!_canPresentPlayer() || controller is null) + PlayerMovementController? controller = _runtime.Controller; + if (!_runtime.CanPresentPlayer || controller is null) return; if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f) { - bool rejectedHidden = _isHidden(); + bool rejectedHidden = _runtime.IsHidden; MovementResult rejected = controller.CapturePresentationResult(); - _project(controller, rejected, rejectedHidden); + _runtime.Project(controller, rejected, rejectedHidden); _advancedFrame = new AdvancedFrame( controller, rejected, @@ -110,10 +75,10 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram return; } - controller.LocalEntityId = _resolveLocalEntityId(); + controller.LocalEntityId = _runtime.ResolveLocalEntityId(); - bool hidden = _isHidden(); - if (_objectClockDisposition() + bool hidden = _runtime.IsHidden; + if (_runtime.ObjectClockDisposition is AcDream.Core.Physics.RetailObjectClockDisposition.Suspend) { // CPhysicsObj::update_object returns before advancing time for a @@ -122,7 +87,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram // physics tick that retail did not run. controller.SuspendObjectUpdate(deltaSeconds); MovementResult paused = controller.CapturePresentationResult(); - _project(controller, paused, hidden); + _runtime.Project(controller, paused, hidden); _advancedFrame = new AdvancedFrame( controller, paused, @@ -133,11 +98,14 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram } MovementResult movement = hidden - ? controller.TickHidden(deltaSeconds, _handleTargeting) - : controller.Update(deltaSeconds, _movementInput.Capture(), _handleTargeting); + ? controller.TickHidden(deltaSeconds, _runtime.HandleTargeting) + : controller.Update( + deltaSeconds, + _movementInput.Capture(), + _runtime.HandleTargeting); - _project(controller, movement, hidden); - _sendPreNetwork(controller, movement, hidden); + _runtime.Project(controller, movement, hidden); + _runtime.SendPreNetwork(controller, movement, hidden); _advancedFrame = new AdvancedFrame( controller, movement, @@ -153,18 +121,18 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram /// public void RunPostNetworkCommandPhase() { - PlayerMovementController? controller = _getController(); - if (!_canPresentPlayer() || controller is null) + PlayerMovementController? controller = _runtime.Controller; + if (!_runtime.CanPresentPlayer || controller is null) return; - bool hidden = _isHidden(); + bool hidden = _runtime.IsHidden; if (_advancedFrame is { } advanced && ReferenceEquals(advanced.Controller, controller)) { MovementResult movement = RefreshSpatialFields( advanced.Movement, controller); - _project(controller, movement, hidden); + _runtime.Project(controller, movement, hidden); _advancedFrame = new AdvancedFrame( controller, movement, @@ -176,7 +144,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram return; } - _sendPostNetwork(controller, hidden); + _runtime.SendPostNetwork(controller, hidden); } /// @@ -187,11 +155,11 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram public bool TryGetPresentationAfterNetwork(out PresentationFrame frame) { frame = default; - PlayerMovementController? controller = _getController(); - if (!_canPresentPlayer() || controller is null) + PlayerMovementController? controller = _runtime.Controller; + if (!_runtime.CanPresentPlayer || controller is null) return false; - bool hidden = _isHidden(); + bool hidden = _runtime.IsHidden; if (_advancedFrame is { } advanced && ReferenceEquals(advanced.Controller, controller)) { @@ -206,7 +174,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram } MovementResult initial = controller.CapturePresentationResult(); - _project(controller, initial, hidden); + _runtime.Project(controller, initial, hidden); frame = new PresentationFrame(initial, hidden, AdvancedBeforeNetwork: false); return true; } diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index 05df1a4d..cf0e24ae 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -48,6 +48,7 @@ internal interface IWorldSelectionQuery bool IsUseable(uint serverGuid); bool IsPickupable(uint serverGuid); bool TryGetApproach(uint serverGuid, out InteractionApproach approach); + Vector3? GetCombatCameraTargetPoint(uint serverGuid); } /// diff --git a/src/AcDream.App/Rendering/CameraFrameController.cs b/src/AcDream.App/Rendering/CameraFrameController.cs new file mode 100644 index 00000000..ff56239d --- /dev/null +++ b/src/AcDream.App/Rendering/CameraFrameController.cs @@ -0,0 +1,114 @@ +using AcDream.App.Combat; +using AcDream.App.Input; +using AcDream.App.Update; +using AcDream.Core.Rendering; + +namespace AcDream.App.Rendering; + +/// +/// Owns the per-update fly/chase camera publication tail. +/// +/// +/// The local object phase normally publishes the player before inbound +/// traffic. When the player is first created by that inbound pass, the local +/// frame publishes its initial root here and the spatial reconciler refreshes +/// child/effect anchors before either chase camera samples it. +/// +internal sealed class CameraFrameController : ICameraFramePhase +{ + private readonly CameraController _camera; + private readonly IInputCaptureSource _capture; + private readonly ICameraFrameInputSource _input; + private readonly ILocalPlayerPresentationRuntime _player; + private readonly IChaseCameraSource _chase; + private readonly RetailLocalPlayerFrameController _localFrame; + private readonly ILiveSpatialReconcilePhase _spatialReconciler; + private readonly ICombatCameraTargetSource _combatTarget; + + public CameraFrameController( + CameraController camera, + IInputCaptureSource capture, + ICameraFrameInputSource input, + ILocalPlayerPresentationRuntime player, + IChaseCameraSource chase, + RetailLocalPlayerFrameController localFrame, + ILiveSpatialReconcilePhase spatialReconciler, + ICombatCameraTargetSource combatTarget) + { + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _capture = capture ?? throw new ArgumentNullException(nameof(capture)); + _input = input ?? throw new ArgumentNullException(nameof(input)); + _player = player ?? throw new ArgumentNullException(nameof(player)); + _chase = chase ?? throw new ArgumentNullException(nameof(chase)); + _localFrame = localFrame ?? throw new ArgumentNullException(nameof(localFrame)); + _spatialReconciler = spatialReconciler + ?? throw new ArgumentNullException(nameof(spatialReconciler)); + _combatTarget = combatTarget ?? throw new ArgumentNullException(nameof(combatTarget)); + } + + public void Tick(UpdateFrameTiming timing) + { + if (_capture.DevToolsWantCaptureKeyboard || !_input.IsAvailable) + return; + + if (_camera.IsFlyMode) + { + FlyCameraInput input = _input.CaptureFly(); + _camera.Fly.Update( + timing.SimulationDeltaSeconds, + input.Forward, + input.Left, + input.Backward, + input.Right, + input.Up, + input.Down, + input.Boost); + return; + } + + PlayerMovementController? controller = _player.Controller; + ChaseCamera? legacy = _chase.Legacy; + RetailChaseCamera? retail = _chase.Retail; + if (!_player.CanPresentPlayer || controller is null || legacy is null) + return; + + if (CameraDiagnostics.UseRetailChaseCamera && retail is not null) + { + ChaseCameraAdjustmentInput input = _input.CaptureChaseAdjustment(); + float adjustment = CameraDiagnostics.CameraAdjustmentSpeed + * timing.SimulationDeltaSecondsSingle; + if (input.ZoomIn) + retail.AdjustDistance(-adjustment); + if (input.ZoomOut) + retail.AdjustDistance(+adjustment); + if (input.Raise) + retail.AdjustPitch(+adjustment * 0.02f); + if (input.Lower) + retail.AdjustPitch(-adjustment * 0.02f); + } + + if (!_localFrame.TryGetPresentationAfterNetwork(out var playerFrame)) + return; + + if (!playerFrame.AdvancedBeforeNetwork) + _spatialReconciler.Reconcile(); + + MovementResult result = playerFrame.Movement; + legacy.Update( + result.RenderPosition, + controller.Yaw, + isOnGround: result.IsOnGround, + dt: timing.SimulationDeltaSecondsSingle); + + retail?.Update( + result.RenderPosition, + controller.Yaw, + playerVelocity: controller.BodyVelocity, + isOnGround: result.IsOnGround, + contactPlaneNormal: controller.ContactPlane.Normal, + dt: timing.SimulationDeltaSecondsSingle, + cellId: controller.CellId, + selfEntityId: controller.LocalEntityId, + trackedTargetPoint: _combatTarget.GetTrackedTargetPoint()); + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7d85fe39..3e8f2ded 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -170,11 +170,10 @@ public sealed class GameWindow : IDisposable _inboundEntityEvents = new(); private LiveEntityAnimationScheduler _liveAnimationScheduler = null!; private LiveEntityAnimationPresenter _animationPresenter = null!; - private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; private readonly AcDream.App.Input.MovementTruthDiagnosticController _movementTruthDiagnostics; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; - private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; + private AcDream.App.Update.ICameraFramePhase _cameraFrame = null!; private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!; private AcDream.App.Update.LiveSpatialPresentationReconciler _liveSpatialReconciler = null!; @@ -619,6 +618,7 @@ public sealed class GameWindow : IDisposable private readonly AcDream.App.Input.RetainedUiInputCaptureSlot _retainedInputCapture; private readonly AcDream.App.Input.CompositeInputCaptureSource _inputCapture; private readonly AcDream.App.Input.DispatcherMovementInputSource _movementInput; + private readonly AcDream.App.Input.DispatcherCameraInputSource _cameraInput = new(); private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor; private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame; // K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json, @@ -759,18 +759,6 @@ public sealed class GameWindow : IDisposable movement, cellId, update), (host, targetGuid) => _liveEntityMotionBindings.StickToObjectFromWire(host, targetGuid)); - _localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController( - resolveEntity: () => - _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) - ? entity - : null, - liveCenterX: () => _liveCenterX, - liveCenterY: () => _liveCenterY, - syncShadow: (entity, cellId) => SyncLocalPlayerShadow(entity, cellId), - rebucket: (serverGuid, landblockId) => - _liveEntities?.RebucketLiveEntity(serverGuid, landblockId), - isCurrentVisibleProjection: IsCurrentVisibleLocalPlayerProjection, - suspendShadow: SuspendLocalPlayerShadow); _movementTruthDiagnostics = new AcDream.App.Input.MovementTruthDiagnosticController( options.DumpMoveTruth, @@ -778,32 +766,6 @@ public sealed class GameWindow : IDisposable _localPlayerIdentity); _localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController( _movementTruthDiagnostics); - _localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController( - canPresentPlayer: CanAdvanceLocalPlayer, - getController: () => _playerController, - movementInput: _movementInput, - resolveLocalEntityId: () => - _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) - ? entity.Id - : 0u, - handleTargeting: () => _playerHost?.HandleTargetting(), - isHidden: () => _liveEntities?.IsHidden(_playerServerGuid) == true, - project: (controller, movement, hidden) => - _localPlayerProjection.Project(controller, movement, hidden), - sendPreNetwork: (controller, movement, hidden) => - _localPlayerOutbound.SendPreNetworkActions( - LiveSession, - controller, - movement, - hidden), - sendPostNetwork: (controller, hidden) => - _localPlayerOutbound.SendPostNetworkPosition( - LiveSession, - controller, - hidden), - objectClockDisposition: () => - _liveEntities?.GetRootObjectClockDisposition(_playerServerGuid) - ?? AcDream.Core.Physics.RetailObjectClockDisposition.Advance); } public void Run() @@ -896,6 +858,7 @@ public sealed class GameWindow : IDisposable _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( _kbSource, _mouseSource, _keyBindings); _movementInput.Bind(_inputDispatcher); + _cameraInput.Bind(_inputDispatcher); _mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse); _inputDispatcher.Fired += OnInputAction; Combat.CombatModeChanged += SetInputCombatScope; @@ -2675,19 +2638,6 @@ public sealed class GameWindow : IDisposable new AcDream.App.Update.SettingsParticleRangeSource( _settingsVm, _persistedDisplay.ParticleRange)); - var liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController( - _inboundEntityEvents, - _localPlayerFrame, - _selectionInteractions, - _liveEntities, - _localPlayerIdentity, - _liveWorldOrigin, - _liveAnimationScheduler, - _staticAnimationScheduler, - _animationPresenter, - _animatedEntities, - _equippedChildRenderer!, - liveEffectFrame); _liveSpatialReconciler = new AcDream.App.Update.LiveSpatialPresentationReconciler( _entityEffects!, @@ -2708,6 +2658,44 @@ public sealed class GameWindow : IDisposable _localPlayerIdentity, _liveWorldOrigin, _localPlayerShadow); + var localPlayerProjection = + new AcDream.App.Input.LocalPlayerProjectionController( + new AcDream.App.Input.LiveLocalPlayerProjectionRuntime( + _liveEntities, + _localPlayerIdentity, + _liveWorldOrigin, + _localPlayerShadowSynchronizer)); + var localPlayerFrameRuntime = + new AcDream.App.Input.LiveLocalPlayerFrameRuntime( + _cameraController!, + _localPlayerMode, + _playerControllerSlot, + _chaseCameraInput, + _movementInput, + _inputCapture, + _liveEntities, + _localPlayerIdentity, + _playerHostSlot, + localPlayerProjection, + _localPlayerOutbound, + _liveSessionController); + var localPlayerFrame = + new AcDream.App.Input.RetailLocalPlayerFrameController( + localPlayerFrameRuntime, + _movementInput); + var liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController( + _inboundEntityEvents, + localPlayerFrame, + _selectionInteractions, + _liveEntities, + _localPlayerIdentity, + _liveWorldOrigin, + _liveAnimationScheduler, + _staticAnimationScheduler, + _animationPresenter, + _animatedEntities, + _equippedChildRenderer!, + liveEffectFrame); _viewportAspect.Update(_window!.Size.X, _window.Size.Y); _playerModeController = new AcDream.App.Input.PlayerModeController( _localPlayerMode, @@ -2783,8 +2771,21 @@ public sealed class GameWindow : IDisposable liveObjectFrame, _worldState, _liveSessionController, - _localPlayerFrame, + localPlayerFrame, _liveSpatialReconciler); + _cameraFrame = new AcDream.App.Rendering.CameraFrameController( + _cameraController!, + _inputCapture, + _cameraInput, + localPlayerFrameRuntime, + _chaseCameraInput, + localPlayerFrame, + _liveSpatialReconciler, + new AcDream.App.Combat.CombatCameraTargetSource( + _gameplaySettings, + Combat, + _selection, + _worldSelectionQuery!)); AcDream.App.Net.LiveSessionStartResult liveStart = _liveSessionController.Start( _options, @@ -3288,59 +3289,6 @@ public sealed class GameWindow : IDisposable timestamps.ForcePosition); } - private void SyncLocalPlayerShadow( - AcDream.Core.World.WorldEntity playerEntity, - uint cellId, - bool force = false) - { - if (_liveEntities?.IsHidden(_playerServerGuid) == true - || cellId == 0 - || !IsCurrentVisibleLocalPlayerProjection(playerEntity)) - { - SuspendLocalPlayerShadow(playerEntity); - return; - } - - if (!force - && _localPlayerShadow.Current is { } last - && last.CellId == cellId - && System.Numerics.Vector3.DistanceSquared( - last.Position, playerEntity.Position) <= 1e-4f - && MathF.Abs(System.Numerics.Quaternion.Dot( - last.Orientation, playerEntity.Rotation)) >= 0.99999f) - { - return; - } - - AcDream.App.Physics.ShadowPositionSynchronizer.Sync( - _physicsEngine.ShadowObjects, - playerEntity.Id, - playerEntity.Position, - playerEntity.Rotation, - cellId, - _liveCenterX, - _liveCenterY); - _localPlayerShadow.Set( - playerEntity.Position, - playerEntity.Rotation, - cellId); - } - - private bool IsCurrentVisibleLocalPlayerProjection( - AcDream.Core.World.WorldEntity playerEntity) => - _liveEntities is { } runtime - && runtime.TryGetRecord(playerEntity.ServerGuid, out var record) - && ReferenceEquals(record.WorldEntity, playerEntity) - && runtime.IsCurrentSpatialRootObject(record); - - private void SuspendLocalPlayerShadow( - AcDream.Core.World.WorldEntity playerEntity) - { - _physicsEngine.ShadowObjects.Suspend(playerEntity.Id); - _localPlayerShadow.Clear(); - } - - // #145: the LANDBLOCK-relative (cell-local) position used to SEED the player // body's cell-relative CellPosition. This is the ONE place the streaming center // (_liveCenter) is allowed to touch the physics frame — at the placement seam, @@ -3584,7 +3532,6 @@ public sealed class GameWindow : IDisposable AcDream.App.Update.UpdateFrameTiming frameTiming = _updateFrameClock.Advance( new AcDream.App.Update.UpdateFrameInput(dt)); - double frameSeconds = frameTiming.SimulationDeltaSeconds; float frameDelta = frameTiming.SimulationDeltaSecondsSingle; // Retail ScriptManager::AddScriptInternal (0x0051B310) stamps @@ -3634,131 +3581,9 @@ public sealed class GameWindow : IDisposable // OnInputAction (Ctrl+Tab) or DebugPanel. _playerModeAutoEntry?.TryEnter(); - if (_cameraController is null || _input is null) return; - - // Phase D.2a / K.1b — suppress game-side input polling when ImGui - // has keyboard focus (e.g. a text field is active). The InputDispatcher - // already gates KeyDown/MouseDown via WantCaptureKeyboard internally; - // this guard adds defense-in-depth for the per-frame IsActionHeld - // movement poll below (typing "walk" into a chat field shouldn't - // walk). - // ImGui dev-tools text fields fully pause game input (incl. autorun) — fine, it's a - // debug overlay. The RETAIL chat "write mode" does NOT early-return here: the block - // below still runs so AUTORUN keeps driving the character while you type. Held WASD - // is silenced at the source instead — InputDispatcher.IsActionHeld returns false - // while WantCaptureKeyboard (which includes a focused chat input) is set. - bool suppressGameInput = - DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard; - if (suppressGameInput) return; - - if (_cameraController.IsFlyMode) - { - // K.1b: fly-camera input flows through the dispatcher. Reuses - // movement actions (Forward/Backup/TurnLeft/TurnRight/Jump/ - // RunLock) — in fly mode "TurnLeft" semantically maps to - // strafe-left because A/D in fly is strafe, not turn (mouse - // delta drives fly heading instead). - if (_inputDispatcher is null) return; - _cameraController.Fly.Update( - frameSeconds, - w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward), - a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft), - s: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup), - d: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight), - up: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump), - down: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.AcdreamFlyDown), - boost: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementRunLock)); - } - else if (_playerMode && _playerController is not null && _chaseCamera is not null) - { - // Phase B.2 / K.1b: player movement mode — every input flows - // through the dispatcher. WASD walks/runs, A/D turns, Z/X - // strafes, Shift runs, Space jumps. Mouse delta NEVER drives - // character yaw (regression-prevention per K.1b plan §D); - // MouseDeltaX is hardcoded 0f. RMB held still pans the chase - // camera (handled in the mouse-move handler via _rmbHeld). - if (_inputDispatcher is null) return; - - // Retail-style held-key offset integration. Only active when - // retail chase is selected; legacy camera ignores these. - if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null) - { - float adj = AcDream.Core.Rendering.CameraDiagnostics.CameraAdjustmentSpeed * frameDelta; - if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomIn)) - _retailChaseCamera.AdjustDistance(-adj); - if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomOut)) - _retailChaseCamera.AdjustDistance(+adj); - if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraRaise)) - _retailChaseCamera.AdjustPitch(+adj * 0.02f); - if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraLower)) - _retailChaseCamera.AdjustPitch(-adj * 0.02f); - } - - // K-fix1 (2026-04-26): retail-faithful movement semantics. - // * Default speed = RUN. Forward / backward / strafe all run - // by default; holding Shift (MovementWalkMode) drops to - // walk speed. - // * Q = AUTORUN TOGGLE: pressing Q latches forward-running - // until Q is pressed again. Handled in OnInputAction; here - // DispatcherMovementInputSource owns the autorun latch. - // * Mouse never drives character yaw (K.1b regression-prevention). - if (!_localPlayerFrame.TryGetPresentationAfterNetwork(out var playerFrame)) - return; - - AcDream.App.Input.MovementResult result = playerFrame.Movement; - bool localPlayerHidden = playerFrame.Hidden; - if (!playerFrame.AdvancedBeforeNetwork) - { - // The player was materialized by this frame's inbound pass. - // Its non-advancing initial projection must publish child and - // effect anchors before the first draw too. - _liveSpatialReconciler.Reconcile(); - } - - // Update chase camera(s). The CameraController exposes whichever - // is currently selected via CameraDiagnostics.UseRetailChaseCamera; - // both update every frame so toggling the flag swaps instantly - // with the new camera already warm. - // - // Legacy ChaseCamera: pre-K-fix12 args (isOnGround pins Z during - // jumps as a workaround for the visual feel retail gets from - // low-stiffness damping). - _chaseCamera.Update(result.RenderPosition, _playerController.Yaw, - isOnGround: result.IsOnGround, - dt: frameDelta); - // RetailChaseCamera: heading is the player's facing direction - // projected onto the contact plane when grounded, or the - // world XY plane when airborne. The contact plane normal - // tilts the camera basis with terrain; the airborne - // fallback keeps the basis horizontal during jumps so the - // player visibly rises in frame without the camera - // swinging vertically (was the symptom of using raw - // velocity-vector heading). - System.Numerics.Vector3? trackedCombatTarget = GetCombatCameraTargetPoint(); - _retailChaseCamera!.Update(result.RenderPosition, _playerController.Yaw, - playerVelocity: _playerController.BodyVelocity, - isOnGround: result.IsOnGround, - contactPlaneNormal: _playerController.ContactPlane.Normal, - dt: frameDelta, - cellId: _playerController.CellId, - selfEntityId: _playerController.LocalEntityId, - trackedTargetPoint: trackedCombatTarget); - - - // Update the player entity's animation cycle to match current motion. - } + _cameraFrame.Tick(frameTiming); } - private bool CanAdvanceLocalPlayer() => - _cameraController is not null - && _input is not null - && !_cameraController.IsFlyMode - && _playerMode - && _playerController is not null - && _chaseCamera is not null - && _inputDispatcher is not null - && !(DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); - private void OnCameraModeChanged(bool _modeBool) { if (_gameplayInputFrame?.MouseLookActive == true @@ -6800,22 +6625,6 @@ public sealed class GameWindow : IDisposable AcDream.App.Input.MovementResult result) => AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result); - /// - /// Resolves retail's combat-camera target. ClientCombatSystem:: - /// UpdateTargetTracking (0x0056A950) enables CameraSet::TrackTarget only - /// for melee/missile combat with the option enabled and a valid attack - /// target. TrackTarget applies target-local offset (0,0,0.5). - /// - private System.Numerics.Vector3? GetCombatCameraTargetPoint() - { - if (!_persistedGameplay.ViewCombatTarget - || !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode) - || _selection.SelectedObjectId is not uint selected) - return null; - - return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected); - } - /// /// Item-target-mode world pick at the current cursor. The renderer supplies the /// exact visible CPhysicsPart equivalents and RetailWorldPicker performs diff --git a/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs b/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs new file mode 100644 index 00000000..40932669 --- /dev/null +++ b/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs @@ -0,0 +1,69 @@ +using System.Numerics; +using AcDream.App.Combat; +using AcDream.App.Interaction; +using AcDream.Core.Combat; +using AcDream.Core.Selection; + +namespace AcDream.App.Tests.Combat; + +public sealed class CombatCameraTargetSourceTests +{ + [Fact] + public void GetTrackedTargetPoint_RequiresOptionTargetedModeAndSelection() + { + const uint target = 0x70000001u; + Vector3 point = new(10f, 20f, 30f); + var settings = new GameplaySettingsState(); + var combat = new CombatState(); + var selection = new SelectionState(); + var world = new TargetQuery(point); + var source = new CombatCameraTargetSource( + settings, + combat, + selection, + world); + + Assert.Null(source.GetTrackedTargetPoint()); + + settings.Value = settings.Value with { ViewCombatTarget = true }; + combat.SetCombatMode(CombatMode.Melee); + selection.Select(target, SelectionChangeSource.World); + + Assert.Equal(point, source.GetTrackedTargetPoint()); + Assert.Equal(target, world.LastGuid); + + combat.SetCombatMode(CombatMode.Magic); + Assert.Null(source.GetTrackedTargetPoint()); + } + + private sealed class TargetQuery(Vector3 point) : IWorldSelectionQuery + { + public uint LastGuid { get; private set; } + public Vector3? GetCombatCameraTargetPoint(uint serverGuid) + { + LastGuid = serverGuid; + return point; + } + + public uint? PickAtCursor(bool includeSelf) => null; + public uint? PickAt(float mouseX, float mouseY, bool includeSelf) => null; + public void BeginLightingPulse(uint serverGuid) { } + public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId) + { + localEntityId = 0; + return false; + } + public bool IsCurrent(uint serverGuid, uint localEntityId) => false; + public string Describe(uint serverGuid) => string.Empty; + public bool IsCreature(uint serverGuid) => false; + public bool IsHostileMonster(uint serverGuid) => false; + public ClosestCombatTarget? FindClosestHostileMonster() => null; + public bool IsUseable(uint serverGuid) => false; + public bool IsPickupable(uint serverGuid) => false; + public bool TryGetApproach(uint serverGuid, out InteractionApproach approach) + { + approach = default; + return false; + } + } +} diff --git a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs index 41109816..16532049 100644 --- a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs +++ b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs @@ -120,6 +120,7 @@ public sealed class LiveCombatAttackOperationsTests { public bool AutoTarget { get; set; } public bool AutoRepeatAttack { get; set; } + public bool ViewCombatTarget { get; set; } } private sealed class FakeLiveSource : ILiveInWorldSource, ILiveWorldSessionSource diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs b/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs index 20575243..b712d4ec 100644 --- a/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs @@ -26,14 +26,14 @@ public sealed class LocalPlayerProjectionControllerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), }; - var projection = new LocalPlayerProjectionController( + var projection = new LocalPlayerProjectionController(new TestProjectionRuntime( () => entity, () => 1, () => 1, (_, _) => { }, (_, _) => { }, _ => true, - _ => { }); + _ => { })); projection.Project( movement, @@ -64,14 +64,14 @@ public sealed class LocalPlayerProjectionControllerTests MeshRefs = Array.Empty(), }; var order = new List(); - var projection = new LocalPlayerProjectionController( + var projection = new LocalPlayerProjectionController(new TestProjectionRuntime( () => entity, () => 2, () => 2, (_, _) => order.Add("shadow"), (_, _) => order.Add("rebucket"), _ => false, - _ => order.Add("suspend")); + _ => order.Add("suspend"))); projection.Project( movement, @@ -100,7 +100,7 @@ public sealed class LocalPlayerProjectionControllerTests }; bool visible = false; var order = new List(); - var projection = new LocalPlayerProjectionController( + var projection = new LocalPlayerProjectionController(new TestProjectionRuntime( () => entity, () => 2, () => 2, @@ -111,7 +111,7 @@ public sealed class LocalPlayerProjectionControllerTests visible = true; }, _ => visible, - _ => order.Add("suspend")); + _ => order.Add("suspend"))); projection.Project( movement, @@ -120,4 +120,25 @@ public sealed class LocalPlayerProjectionControllerTests Assert.Equal(["rebucket", "shadow"], order); } + + private sealed class TestProjectionRuntime( + Func resolveEntity, + Func liveCenterX, + Func liveCenterY, + Action syncShadow, + Action rebucket, + Func isCurrentVisibleProjection, + Action suspendShadow) : ILocalPlayerProjectionRuntime + { + public WorldEntity? ResolveEntity() => resolveEntity(); + public int LiveCenterX => liveCenterX(); + public int LiveCenterY => liveCenterY(); + public void SyncShadow(WorldEntity entity, uint cellId) => + syncShadow(entity, cellId); + public void Rebucket(uint serverGuid, uint landblockId) => + rebucket(serverGuid, landblockId); + public bool IsCurrentVisibleProjection(WorldEntity entity) => + isCurrentVisibleProjection(entity); + public void SuspendShadow(WorldEntity entity) => suspendShadow(entity); + } } diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs index 794f40e1..01ce901c 100644 --- a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs @@ -15,7 +15,7 @@ public sealed class RetailLocalPlayerFrameControllerTests var calls = new List(); var preNetwork = new List<(PlayerState State, MovementResult Movement)>(); var postNetwork = new List(); - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => true, getController: () => controller, movementInput: Input(() => new MovementInput(Forward: true)), @@ -79,7 +79,7 @@ public sealed class RetailLocalPlayerFrameControllerTests int projections = 0; int preNetwork = 0; int postNetwork = 0; - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => controller is not null, getController: () => controller, movementInput: Input(() => new MovementInput(Forward: true)), @@ -109,7 +109,7 @@ public sealed class RetailLocalPlayerFrameControllerTests { PlayerMovementController controller = CreateController(); Vector3 projectedRoot = default; - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => true, getController: () => controller, movementInput: Input(() => new MovementInput()), @@ -140,7 +140,7 @@ public sealed class RetailLocalPlayerFrameControllerTests PlayerMovementController controller = CreateController(); Vector3 initial = controller.Position; Vector3 positionSeenByTargetManager = new(float.NaN); - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => true, getController: () => controller, movementInput: Input(() => new MovementInput(Forward: true)), @@ -166,7 +166,7 @@ public sealed class RetailLocalPlayerFrameControllerTests int projections = 0; int preNetwork = 0; int postNetwork = 0; - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => true, getController: () => controller, movementInput: Input(() => @@ -201,7 +201,7 @@ public sealed class RetailLocalPlayerFrameControllerTests public void HiddenPoseIsDirtyOnlyWhenACompleteObjectQuantumRuns() { PlayerMovementController controller = CreateController(); - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => true, getController: () => controller, movementInput: Input(() => new MovementInput()), @@ -228,7 +228,7 @@ public sealed class RetailLocalPlayerFrameControllerTests int projections = 0; int preNetwork = 0; int postNetwork = 0; - var local = new RetailLocalPlayerFrameController( + var local = CreateFrame( canPresentPlayer: () => true, getController: () => controller, movementInput: Input(() => @@ -297,6 +297,65 @@ public sealed class RetailLocalPlayerFrameControllerTests private static IMovementInputSource Input(Func capture) => new TestMovementInputSource(capture); + private static RetailLocalPlayerFrameController CreateFrame( + Func canPresentPlayer, + Func getController, + IMovementInputSource movementInput, + Func resolveLocalEntityId, + Action handleTargeting, + Func isHidden, + Action project, + Action sendPreNetwork, + Action sendPostNetwork, + Func? objectClockDisposition = null) => + new( + new TestLocalPlayerFrameRuntime( + canPresentPlayer, + getController, + resolveLocalEntityId, + handleTargeting, + isHidden, + project, + sendPreNetwork, + sendPostNetwork, + objectClockDisposition), + movementInput); + + private sealed class TestLocalPlayerFrameRuntime( + Func canPresentPlayer, + Func getController, + Func resolveLocalEntityId, + Action handleTargeting, + Func isHidden, + Action project, + Action sendPreNetwork, + Action sendPostNetwork, + Func? objectClockDisposition) + : ILocalPlayerFrameRuntime + { + public bool CanPresentPlayer => canPresentPlayer(); + public PlayerMovementController? Controller => getController(); + public uint ResolveLocalEntityId() => resolveLocalEntityId(); + public void HandleTargeting() => handleTargeting(); + public bool IsHidden => isHidden(); + public RetailObjectClockDisposition ObjectClockDisposition => + objectClockDisposition?.Invoke() ?? RetailObjectClockDisposition.Advance; + + public void Project( + PlayerMovementController controller, + MovementResult movement, + bool hidden) => project(controller, movement, hidden); + + public void SendPreNetwork( + PlayerMovementController controller, + MovementResult movement, + bool hidden) => sendPreNetwork(controller, movement, hidden); + + public void SendPostNetwork( + PlayerMovementController controller, + bool hidden) => sendPostNetwork(controller, hidden); + } + private sealed class TestMovementInputSource(Func capture) : IMovementInputSource { diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 75963f82..18d0379b 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -55,6 +55,7 @@ public sealed class SelectionInteractionControllerTests public ClosestCombatTarget? FindClosestHostileMonster() => Closest; public bool IsUseable(uint serverGuid) => Useable; public bool IsPickupable(uint serverGuid) => Pickupable; + public Vector3? GetCombatCameraTargetPoint(uint serverGuid) => null; public bool TryGetApproach(uint serverGuid, out InteractionApproach approach) { diff --git a/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs new file mode 100644 index 00000000..732906b3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs @@ -0,0 +1,231 @@ +using System.Numerics; +using AcDream.App.Combat; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.Update; +using AcDream.Core.Physics; + +namespace AcDream.App.Tests.Rendering; + +public sealed class CameraFrameControllerTests +{ + [Fact] + public void FlyMode_UsesOneSemanticHeldInputSnapshot() + { + CameraController camera = CreateCamera(); + camera.ToggleFly(); + Vector3 start = camera.Fly.Position; + var input = new InputSource + { + Fly = new FlyCameraInput( + Forward: true, + Left: false, + Backward: false, + Right: false, + Up: true, + Down: false, + Boost: false), + }; + CameraFrameController frame = CreateFrame(camera, input: input); + + frame.Tick(new UpdateFrameTiming(0.5, 0.5f, 0.5)); + + Assert.Equal(1, input.FlyCaptures); + Assert.InRange(camera.Fly.Position.Y - start.Y, 5.999f, 6.001f); + Assert.InRange(camera.Fly.Position.Z - start.Z, 5.999f, 6.001f); + } + + [Fact] + public void DevToolsKeyboardCapture_PausesFlyCamera() + { + CameraController camera = CreateCamera(); + camera.ToggleFly(); + Vector3 start = camera.Fly.Position; + var input = new InputSource + { + Fly = new FlyCameraInput(true, false, false, false, false, false, false), + }; + CameraFrameController frame = CreateFrame( + camera, + capture: new CaptureSource { DevToolsKeyboard = true }, + input: input); + + frame.Tick(new UpdateFrameTiming(1.0, 1f, 1.0)); + + Assert.Equal(start, camera.Fly.Position); + Assert.Equal(0, input.FlyCaptures); + } + + [Fact] + public void InboundCreatedPlayer_ProjectsThenReconcilesBeforeCameraPublication() + { + PlayerMovementController controller = CreatePlayer(); + var calls = new List(); + var runtime = new PlayerRuntime(controller, calls); + var localFrame = new RetailLocalPlayerFrameController( + runtime, + new StillMovementInput()); + CameraController camera = CreateCamera(); + var legacy = new ChaseCamera(); + var retail = new RetailChaseCamera(); + camera.EnterChaseMode(legacy, retail); + var chase = new ChaseSource(legacy, retail); + var reconciler = new Reconciler(calls); + var frame = new CameraFrameController( + camera, + new CaptureSource(), + new InputSource(), + runtime, + chase, + localFrame, + reconciler, + new CombatTargetSource()); + + frame.Tick(new UpdateFrameTiming(1.0 / 60.0, 1f / 60f, 1.0)); + + Assert.Equal(["project", "reconcile"], calls); + Assert.NotEqual(Vector3.Zero, legacy.Position); + Assert.NotEqual(Vector3.Zero, retail.Position); + } + + [Fact] + public void PreNetworkAdvancedPlayer_DoesNotRunTheInboundCreationReconcile() + { + PlayerMovementController controller = CreatePlayer(); + var calls = new List(); + var runtime = new PlayerRuntime(controller, calls); + var localFrame = new RetailLocalPlayerFrameController( + runtime, + new StillMovementInput()); + localFrame.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum); + calls.Clear(); + CameraController camera = CreateCamera(); + var legacy = new ChaseCamera(); + var retail = new RetailChaseCamera(); + camera.EnterChaseMode(legacy, retail); + var frame = new CameraFrameController( + camera, + new CaptureSource(), + new InputSource(), + runtime, + new ChaseSource(legacy, retail), + localFrame, + new Reconciler(calls), + new CombatTargetSource()); + + frame.Tick(new UpdateFrameTiming(1.0 / 60.0, 1f / 60f, 1.0)); + + Assert.Empty(calls); + } + + private static CameraFrameController CreateFrame( + CameraController camera, + CaptureSource? capture = null, + InputSource? input = null) + { + var runtime = new PlayerRuntime(null, []); + var localFrame = new RetailLocalPlayerFrameController( + runtime, + new StillMovementInput()); + return new CameraFrameController( + camera, + capture ?? new CaptureSource(), + input ?? new InputSource(), + runtime, + new ChaseSource(null, null), + localFrame, + new Reconciler([]), + new CombatTargetSource()); + } + + private static CameraController CreateCamera() => + new(new OrbitCamera(), new FlyCamera()); + + private static PlayerMovementController CreatePlayer() + { + var engine = new PhysicsEngine(); + var heights = new byte[81]; + Array.Fill(heights, (byte)50); + var heightTable = new float[256]; + for (int i = 0; i < heightTable.Length; i++) + heightTable[i] = i; + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u); + return controller; + } + + private sealed class CaptureSource : IInputCaptureSource + { + public bool DevToolsKeyboard { get; set; } + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => DevToolsKeyboard; + public bool DevToolsWantCaptureKeyboard => DevToolsKeyboard; + } + + private sealed class InputSource : ICameraFrameInputSource + { + public bool IsAvailable { get; set; } = true; + public FlyCameraInput Fly { get; set; } + public ChaseCameraAdjustmentInput Chase { get; set; } + public int FlyCaptures { get; private set; } + public FlyCameraInput CaptureFly() + { + FlyCaptures++; + return Fly; + } + public ChaseCameraAdjustmentInput CaptureChaseAdjustment() => Chase; + } + + private sealed class ChaseSource( + ChaseCamera? legacy, + RetailChaseCamera? retail) : IChaseCameraSource + { + public ChaseCamera? Legacy => legacy; + public RetailChaseCamera? Retail => retail; + } + + private sealed class StillMovementInput : IMovementInputSource + { + public MovementInput Capture() => default; + } + + private sealed class PlayerRuntime( + PlayerMovementController? controller, + List calls) : ILocalPlayerFrameRuntime + { + public bool CanPresentPlayer { get; set; } = controller is not null; + public PlayerMovementController? Controller => controller; + public uint ResolveLocalEntityId() => 7u; + public void HandleTargeting() { } + public bool IsHidden => false; + public RetailObjectClockDisposition ObjectClockDisposition => + RetailObjectClockDisposition.Advance; + public void Project( + PlayerMovementController owner, + MovementResult movement, + bool hidden) => calls.Add("project"); + public void SendPreNetwork( + PlayerMovementController owner, + MovementResult movement, + bool hidden) { } + public void SendPostNetwork(PlayerMovementController owner, bool hidden) { } + } + + private sealed class Reconciler(List calls) + : ILiveSpatialReconcilePhase + { + public void Reconcile() => calls.Add("reconcile"); + } + + private sealed class CombatTargetSource : ICombatCameraTargetSource + { + public Vector3? GetTrackedTargetPoint() => null; + } +} diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index ee92d1e7..12c1f907 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -619,6 +619,64 @@ public sealed class UpdateFrameOrchestratorTests StringComparison.Ordinal); } + [Fact] + public void CameraAndLocalPlayerFrameOwnersUseTypedSeamsWithoutWindowCallbacks() + { + Type[] owners = + [ + typeof(AcDream.App.Rendering.CameraFrameController), + typeof(AcDream.App.Input.RetailLocalPlayerFrameController), + typeof(AcDream.App.Input.LocalPlayerProjectionController), + typeof(AcDream.App.Input.LiveLocalPlayerFrameRuntime), + typeof(AcDream.App.Input.LiveLocalPlayerProjectionRuntime), + typeof(AcDream.App.Combat.CombatCameraTargetSource), + ]; + + foreach (Type owner in owners) + { + FieldInfo[] fields = owner.GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + string root = FindRepoRoot(); + string source = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + Assert.Equal(1, CountOccurrences(source, "_cameraFrame.Tick(frameTiming);")); + Assert.DoesNotContain("CanAdvanceLocalPlayer", source, StringComparison.Ordinal); + Assert.DoesNotContain("GetCombatCameraTargetPoint()", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_cameraController.Fly.Update(", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_localPlayerFrame.TryGetPresentationAfterNetwork", source, + StringComparison.Ordinal); + AssertAppearsInOrder( + source, + "_localPlayerTeleport!.Tick(frameDelta);", + "_playerModeAutoEntry?.TryEnter();", + "_cameraFrame.Tick(frameTiming);"); + + string cameraSource = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Rendering", + "CameraFrameController.cs")); + AssertAppearsInOrder( + cameraSource, + "_localFrame.TryGetPresentationAfterNetwork", + "_spatialReconciler.Reconcile();", + "legacy.Update(", + "retail?.Update("); + } + private static UpdateFrameOrchestrator Create( List calls, RecordingTeardown? teardown = null, From e91f310279eab69b471141e443613dc251448991 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 03:31:38 +0200 Subject: [PATCH 79/98] refactor(update): cut over the frame orchestrator Reduce GameWindow.OnUpdate to one typed orchestration call and remove transitive window callbacks from teardown, liveness, teleport placement, live presentation, auto-entry, and entity packet routing. Preserve the frozen retail object/network order while making the production owner graph explicit and testable. --- ...ndow-slice-6-update-frame-orchestration.md | 15 +- src/AcDream.App/Input/PlayerModeAutoEntry.cs | 145 +++++++-- .../Net/LiveEntitySessionController.cs | 97 ++++++ .../LiveEntityNetworkUpdateController.cs | 27 +- .../RemoteShadowPlacementSynchronizer.cs | 49 +++ src/AcDream.App/Rendering/GameWindow.cs | 302 +++++------------- .../Rendering/LiveEntityPartArrayLifecycle.cs | 19 ++ .../Update/UpdateFrameRuntimeAdapters.cs | 78 +++++ .../World/LiveEntityDeletionController.cs | 69 ++++ .../World/LiveEntityHydrationController.cs | 46 +-- .../World/LiveEntityHydrationPorts.cs | 38 ++- .../World/LiveEntityLivenessController.cs | 15 +- .../LiveEntityRuntimeTeardownController.cs | 9 +- src/AcDream.App/World/LiveWorldOriginState.cs | 19 ++ .../World/LocalPhysicsTimestampPublisher.cs | 43 +++ .../LiveEntityHydrationControllerTests.cs | 45 ++- .../World/LiveWorldOriginStateTests.cs | 14 + .../World/UpdateFrameOrchestratorTests.cs | 182 ++++++++--- 18 files changed, 864 insertions(+), 348 deletions(-) create mode 100644 src/AcDream.App/Net/LiveEntitySessionController.cs create mode 100644 src/AcDream.App/Physics/RemoteShadowPlacementSynchronizer.cs create mode 100644 src/AcDream.App/Rendering/LiveEntityPartArrayLifecycle.cs create mode 100644 src/AcDream.App/Update/UpdateFrameRuntimeAdapters.cs create mode 100644 src/AcDream.App/World/LiveEntityDeletionController.cs create mode 100644 src/AcDream.App/World/LocalPhysicsTimestampPublisher.cs diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md index 7cef3403..832c15ec 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md @@ -27,7 +27,7 @@ never hidden behind a retry, delay, suppression flag, or reordered callback. - [x] F — extract the shared player-mode lifecycle plus fly/chase/player camera presentation and cut production over to one `UpdateFrameOrchestrator`. -- [ ] G — delete the old `OnUpdate` bodies, callback facades, and obsolete +- [x] G — delete the old `OnUpdate` bodies, callback facades, and obsolete frame state; run focused corrected-diff reviews after each ownership edge. - [ ] H — full Release suite, connected lifecycle/reconnect gate, synchronized resource soak, documentation, durable memory, and line/field/method closeout. @@ -469,6 +469,19 @@ passed / 5 skipped. All three corrected-diff reviews are clean. `GameWindow.cs` is 7,160 lines, down 8,563 lines (54.5%) from the 15,723-line slice baseline. +**Checkpoint G completed 2026-07-22.** `GameWindow.OnUpdate` now owns only the +update profiler scope and one `UpdateFrameOrchestrator.Tick` call. Teardown, +absolute liveness time, local timestamp publication, expiry deletion, +player-mode auto-entry, live-origin initialization, Hidden PartArray +boundaries, remote teleport placement, and all twelve live-entity session +packet routes resolve through typed runtime owners; the former transitive +window callbacks and obsolete frame fields are gone. The App suite is green +at 2,823 passed / 3 skipped. Focused orchestration, lifecycle, presentation, +teleport, origin, and session tests are green, and all three corrected-diff +reviews are clean. `GameWindow.cs` is 7,026 raw lines / 241 fields / 108 +methods, down 1,785 lines and 45 methods from the Slice-6 baseline and 8,697 +lines (55.3%) from the 15,723-line pre-extraction class. + ### H — release and connected gates After all three independent corrected-diff reviews are clean: diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs index a23b20cc..cdf0837a 100644 --- a/src/AcDream.App/Input/PlayerModeAutoEntry.cs +++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs @@ -1,11 +1,14 @@ using System; +using AcDream.App.Net; +using AcDream.App.Streaming; +using AcDream.App.World; namespace AcDream.App.Input; /// /// Phase K.2 — one-shot guard that auto-enters player mode after a -/// successful login once every prerequisite is satisfied. Owned by -/// GameWindow and ticked each frame from OnUpdate. +/// successful login once every prerequisite is satisfied. The update-frame +/// orchestrator ticks it through a typed production context. /// /// /// Why is this its own class? The auto-entry has four independent @@ -33,20 +36,108 @@ namespace AcDream.App.Input; /// /// /// -/// All preconditions are passed in as predicates so the class doesn't -/// pull in WorldSession, PlayerMovementController, or -/// any GameWindow-internal types — the unit test wires them to plain -/// boolean fields. +/// Production preconditions come from focused runtime owners rather than the +/// window host. The public delegate constructor remains a narrow test seam for +/// plain boolean fixtures. /// /// +internal interface IPlayerModeAutoEntryContext +{ + bool IsLiveInWorld { get; } + bool IsPlayerEntityPresent { get; } + bool IsPlayerControllerReady { get; } + bool IsWorldReady { get; } + bool IsPlayerModeActive { get; } + void EnterPlayerMode(); +} + +/// Production auto-entry context over canonical runtime owners. +internal sealed class LivePlayerModeAutoEntryContext + : IPlayerModeAutoEntryContext +{ + private readonly ILiveInWorldSource _session; + private readonly LiveEntityRuntime _liveEntities; + private readonly ILocalPlayerIdentitySource _identity; + private readonly WorldRevealCoordinator _worldReveal; + private readonly ILocalPlayerModeSource _mode; + private readonly PlayerModeController _playerMode; + + public LivePlayerModeAutoEntryContext( + ILiveInWorldSource session, + LiveEntityRuntime liveEntities, + ILocalPlayerIdentitySource identity, + WorldRevealCoordinator worldReveal, + ILocalPlayerModeSource mode, + PlayerModeController playerMode) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)); + _mode = mode ?? throw new ArgumentNullException(nameof(mode)); + _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); + } + + public bool IsLiveInWorld => _session.IsInWorld; + + public bool IsPlayerEntityPresent => + _liveEntities.MaterializedWorldEntities.ContainsKey(_identity.ServerGuid); + + public bool IsPlayerControllerReady => true; + + public bool IsWorldReady => + _liveEntities.TryGetSnapshot( + _identity.ServerGuid, + out AcDream.Core.Net.WorldSession.EntitySpawn player) + && player.Position is { LandblockId: not 0u } position + && _worldReveal.Evaluate(position.LandblockId).IsReady; + + public bool IsPlayerModeActive => _mode.IsPlayerMode; + + public void EnterPlayerMode() => _playerMode.EnterFromAutoEntry(); +} + public sealed class PlayerModeAutoEntry { - private readonly Func _isLiveInWorld; - private readonly Func _isPlayerEntityPresent; - private readonly Func _isPlayerControllerReady; - private readonly Func _isWorldReady; - private readonly Func _isPlayerModeActive; - private readonly Action _enterPlayerMode; + private sealed class DelegateContext : IPlayerModeAutoEntryContext + { + private readonly Func _isLiveInWorld; + private readonly Func _isPlayerEntityPresent; + private readonly Func _isPlayerControllerReady; + private readonly Func _isWorldReady; + private readonly Action _enterPlayerMode; + private readonly Func _isPlayerModeActive; + + public DelegateContext( + Func isLiveInWorld, + Func isPlayerEntityPresent, + Func isPlayerControllerReady, + Func isWorldReady, + Action enterPlayerMode, + Func? isPlayerModeActive) + { + _isLiveInWorld = isLiveInWorld + ?? throw new ArgumentNullException(nameof(isLiveInWorld)); + _isPlayerEntityPresent = isPlayerEntityPresent + ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent)); + _isPlayerControllerReady = isPlayerControllerReady + ?? throw new ArgumentNullException(nameof(isPlayerControllerReady)); + _isWorldReady = isWorldReady + ?? throw new ArgumentNullException(nameof(isWorldReady)); + _enterPlayerMode = enterPlayerMode + ?? throw new ArgumentNullException(nameof(enterPlayerMode)); + _isPlayerModeActive = isPlayerModeActive ?? (() => false); + } + + public bool IsLiveInWorld => _isLiveInWorld(); + public bool IsPlayerEntityPresent => _isPlayerEntityPresent(); + public bool IsPlayerControllerReady => _isPlayerControllerReady(); + public bool IsWorldReady => _isWorldReady(); + public bool IsPlayerModeActive => _isPlayerModeActive(); + public void EnterPlayerMode() => _enterPlayerMode(); + } + + private readonly IPlayerModeAutoEntryContext _context; private bool _armed; @@ -72,20 +163,24 @@ public sealed class PlayerModeAutoEntry /// player transition). Must construct the controller + chase /// camera and switch the active camera; the auto-entry doesn't /// reach inside. + internal PlayerModeAutoEntry(IPlayerModeAutoEntryContext context) => + _context = context ?? throw new ArgumentNullException(nameof(context)); + public PlayerModeAutoEntry( Func isLiveInWorld, Func isPlayerEntityPresent, Func isPlayerControllerReady, Func isWorldReady, - Action enterPlayerMode, + Action enterPlayerMode, Func? isPlayerModeActive = null) + : this(new DelegateContext( + isLiveInWorld, + isPlayerEntityPresent, + isPlayerControllerReady, + isWorldReady, + enterPlayerMode, + isPlayerModeActive)) { - _isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld)); - _isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent)); - _isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady)); - _isWorldReady = isWorldReady ?? throw new ArgumentNullException(nameof(isWorldReady)); - _enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode)); - _isPlayerModeActive = isPlayerModeActive ?? (() => false); } /// True iff would still fire if the @@ -115,18 +210,18 @@ public sealed class PlayerModeAutoEntry public bool TryEnter() { if (!_armed) return false; - if (_isPlayerModeActive()) + if (_context.IsPlayerModeActive) { _armed = false; return false; } - if (!_isLiveInWorld()) return false; - if (!_isPlayerEntityPresent()) return false; - if (!_isPlayerControllerReady()) return false; - if (!_isWorldReady()) return false; + if (!_context.IsLiveInWorld) return false; + if (!_context.IsPlayerEntityPresent) return false; + if (!_context.IsPlayerControllerReady) return false; + if (!_context.IsWorldReady) return false; _armed = false; - _enterPlayerMode(); + _context.EnterPlayerMode(); return true; } } diff --git a/src/AcDream.App/Net/LiveEntitySessionController.cs b/src/AcDream.App/Net/LiveEntitySessionController.cs new file mode 100644 index 00000000..473a08f7 --- /dev/null +++ b/src/AcDream.App/Net/LiveEntitySessionController.cs @@ -0,0 +1,97 @@ +using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Net; + +/// +/// Routes one live session's entity packets through the non-reentrant retail +/// inbound envelope without retaining the window host. +/// +internal sealed class LiveEntitySessionController +{ + private readonly RetailInboundEventDispatcher _inbound; + private readonly LiveEntityHydrationController _hydration; + private readonly LiveEntityNetworkUpdateController _updates; + private readonly ILocalPlayerTeleportNetworkSink _teleport; + private readonly EntityEffectController _effects; + + public LiveEntitySessionController( + RetailInboundEventDispatcher inbound, + LiveEntityHydrationController hydration, + LiveEntityNetworkUpdateController updates, + ILocalPlayerTeleportNetworkSink teleport, + EntityEffectController effects) + { + _inbound = inbound ?? throw new ArgumentNullException(nameof(inbound)); + _hydration = hydration ?? throw new ArgumentNullException(nameof(hydration)); + _updates = updates ?? throw new ArgumentNullException(nameof(updates)); + _teleport = teleport ?? throw new ArgumentNullException(nameof(teleport)); + _effects = effects ?? throw new ArgumentNullException(nameof(effects)); + } + + public LiveEntitySessionSink CreateSink() => new( + OnSpawned, + OnDeleted, + OnPickedUp, + OnMotionUpdated, + OnPositionUpdated, + OnVectorUpdated, + OnStateUpdated, + OnParentUpdated, + OnTeleportStarted, + OnAppearanceUpdated, + OnPlayPhysicsScript, + OnPlayPhysicsScriptType); + + private void OnSpawned(WorldSession.EntitySpawn value) => + _inbound.Run(_hydration, value, + static (owner, message) => owner.OnCreate(message)); + + private void OnDeleted(DeleteObject.Parsed value) => + _inbound.Run(_hydration, value, + static (owner, message) => owner.OnDelete(message)); + + private void OnPickedUp(PickupEvent.Parsed value) => + _inbound.Run(_hydration, value, + static (owner, message) => owner.OnPickup(message)); + + private void OnMotionUpdated(WorldSession.EntityMotionUpdate value) => + _inbound.Run(_updates, value, + static (owner, message) => owner.OnMotion(message)); + + private void OnPositionUpdated(WorldSession.EntityPositionUpdate value) => + _inbound.Run(_updates, value, + static (owner, message) => owner.OnPosition(message)); + + private void OnVectorUpdated(VectorUpdate.Parsed value) => + _inbound.Run(_updates, value, + static (owner, message) => owner.OnVector(message)); + + private void OnStateUpdated(SetState.Parsed value) => + _inbound.Run(_updates, value, + static (owner, message) => owner.OnState(message)); + + private void OnParentUpdated(ParentEvent.Parsed value) => + _inbound.Run(_hydration, value, + static (owner, message) => owner.OnParent(message)); + + private void OnTeleportStarted(uint value) => + _inbound.Run(_teleport, value, + static (owner, message) => owner.OnTeleportStarted(message)); + + private void OnAppearanceUpdated(ObjDescEvent.Parsed value) => + _inbound.Run(_hydration, value, + static (owner, message) => owner.OnAppearance(message)); + + private void OnPlayPhysicsScript(PlayPhysicsScript value) => + _inbound.Run(_effects, value, + static (owner, message) => owner.HandleDirect(message)); + + private void OnPlayPhysicsScriptType(PlayPhysicsScriptType value) => + _inbound.Run(_effects, value, + static (owner, message) => owner.HandleTyped(message)); +} diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 41d65415..603e22db 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -1,6 +1,7 @@ using AcDream.App.Combat; using AcDream.App.Input; using AcDream.App.Interaction; +using AcDream.App.Net; using AcDream.App.Physics; using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; @@ -50,18 +51,18 @@ internal sealed class LiveEntityNetworkUpdateController private readonly LiveWorldOriginState _origin; private readonly AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink _localPlayerTeleport; - private readonly Func _playerControllerSource; + private readonly ILocalPlayerControllerSource _playerControllerSource; private readonly LocalPlayerOutboundController _localPlayerOutbound; - private readonly Func _playerHostSource; - private readonly Func _playerGuid; + private readonly ILocalPlayerPhysicsHostSource _playerHostSource; + private readonly ILocalPlayerIdentitySource _playerIdentity; private readonly IPhysicsScriptTimeSource _gameTime; - private readonly Func _session; + private readonly ILiveWorldSessionSource _session; private readonly LiveEntityInboundAuthorityGate _authorityGate; private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics; - private PlayerMovementController? _playerController => _playerControllerSource(); - private EntityPhysicsHost? _playerHost => _playerHostSource(); - private uint _playerServerGuid => _playerGuid(); + private PlayerMovementController? _playerController => _playerControllerSource.Controller; + private EntityPhysicsHost? _playerHost => _playerHostSource.Host; + private uint _playerServerGuid => _playerIdentity.ServerGuid; private double _physicsScriptGameTime => _gameTime.CurrentScriptTime; private IReadOnlyDictionary _entitiesByServerGuid => _liveEntities.MaterializedWorldEntities; @@ -96,12 +97,12 @@ internal sealed class LiveEntityNetworkUpdateController CombatTargetController? combatTargetController, LiveWorldOriginState origin, AcDream.App.Streaming.ILocalPlayerTeleportNetworkSink localPlayerTeleport, - Func playerControllerSource, + ILocalPlayerControllerSource playerControllerSource, LocalPlayerOutboundController localPlayerOutbound, - Func playerHostSource, - Func playerGuid, + ILocalPlayerPhysicsHostSource playerHostSource, + ILocalPlayerIdentitySource playerIdentity, IPhysicsScriptTimeSource gameTime, - Func session, + ILiveWorldSessionSource session, Action publishTimestamps, IMovementTruthDiagnosticSink movementTruthDiagnostics) { @@ -131,7 +132,7 @@ internal sealed class LiveEntityNetworkUpdateController _localPlayerOutbound = localPlayerOutbound ?? throw new ArgumentNullException(nameof(localPlayerOutbound)); _playerHostSource = playerHostSource ?? throw new ArgumentNullException(nameof(playerHostSource)); - _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _playerIdentity = playerIdentity ?? throw new ArgumentNullException(nameof(playerIdentity)); _gameTime = gameTime ?? throw new ArgumentNullException(nameof(gameTime)); _session = session ?? throw new ArgumentNullException(nameof(session)); _authorityGate = new LiveEntityInboundAuthorityGate( @@ -1029,7 +1030,7 @@ internal sealed class LiveEntityNetworkUpdateController p.PositionY, p.PositionZ)), () => _localPlayerOutbound.SendImmediatePosition( - _session(), + _session.CurrentSession, _playerController))) return; diff --git a/src/AcDream.App/Physics/RemoteShadowPlacementSynchronizer.cs b/src/AcDream.App/Physics/RemoteShadowPlacementSynchronizer.cs new file mode 100644 index 00000000..891f4690 --- /dev/null +++ b/src/AcDream.App/Physics/RemoteShadowPlacementSynchronizer.cs @@ -0,0 +1,49 @@ +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Physics; + +/// Synchronizes a resolved remote placement in the current live origin. +internal sealed class RemoteShadowPlacementSynchronizer +{ + private readonly RemotePhysicsUpdater _remotePhysics; + private readonly LiveWorldOriginState _origin; + + public RemoteShadowPlacementSynchronizer( + RemotePhysicsUpdater remotePhysics, + LiveWorldOriginState origin) + { + _remotePhysics = remotePhysics + ?? throw new ArgumentNullException(nameof(remotePhysics)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + } + + public void Sync(WorldEntity entity, PhysicsBody body, uint cellId) => + _remotePhysics.SyncRemoteShadowToBody( + entity.Id, + body, + _origin.CenterX, + _origin.CenterY, + cellId); +} + +/// Bridges remote placement ownership to live presentation state. +internal sealed class RemoteTeleportPlacementPresentation +{ + private readonly LiveEntityPresentationController _presentation; + + public RemoteTeleportPlacementPresentation( + LiveEntityPresentationController presentation) => + _presentation = presentation + ?? throw new ArgumentNullException(nameof(presentation)); + + public void Complete(uint serverGuid, ushort generation, bool deferShadowRestore) => + _presentation.CompleteAuthoritativePlacement( + serverGuid, + generation, + deferShadowRestore); + + public void Begin(uint serverGuid, ushort generation) => + _presentation.BeginAuthoritativePlacement(serverGuid, generation); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3e8f2ded..87a119c0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -134,7 +134,6 @@ public sealed class GameWindow : IDisposable private AcDream.App.Streaming.StreamingController? _streamingController; private AcDream.App.Streaming.StreamingOriginRecenterCoordinator? _streamingOriginRecenter; - private AcDream.App.Update.IStreamingFramePhase _streamingFrame = null!; private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal; private readonly AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink _localPlayerTeleportSink = new(); @@ -173,10 +172,7 @@ public sealed class GameWindow : IDisposable private readonly AcDream.App.Input.MovementTruthDiagnosticController _movementTruthDiagnostics; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; - private AcDream.App.Update.ICameraFramePhase _cameraFrame = null!; - private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!; - private AcDream.App.Update.LiveSpatialPresentationReconciler - _liveSpatialReconciler = null!; + private AcDream.App.Update.UpdateFrameOrchestrator _updateFrameOrchestrator = null!; private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new(); private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; // Step 7 projectile presentation. The controller owns no identity map; @@ -186,6 +182,7 @@ public sealed class GameWindow : IDisposable _liveEntityProjectionWithdrawal; private AcDream.App.World.LiveEntityHydrationController? _liveEntityHydration; private AcDream.App.Physics.LiveEntityNetworkUpdateController? _liveEntityNetworkUpdates; + private AcDream.App.Net.LiveEntitySessionController _liveEntitySessionEvents = null!; private readonly AcDream.App.Physics.DeferredLiveEntityMotionRuntimeBindings _liveEntityMotionBindings = new(); @@ -2170,36 +2167,32 @@ public sealed class GameWindow : IDisposable _entityEffects = entityEffects; entityEffects.DiagnosticSink = message => Console.Error.WriteLine($"vfx: {message}"); + var partArrayLifecycle = + new AcDream.App.Rendering.LiveEntityPartArrayLifecycle( + _animatedEntities); _liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController( _liveEntities, _physicsEngine.ShadowObjects, entityEffects.PlayTypedFromHiddenTransition, _equippedChildRenderer.SetDirectChildrenNoDraw, _liveEntityMotionBindings.ClearTargetForHiddenEntity, - () => (_liveCenterX, _liveCenterY), - handlePartArrayEnterWorld: localEntityId => - { - if (_animatedEntities.TryGetValue(localEntityId, out var animated)) - animated.Sequencer?.Manager.HandleEnterWorld(); - }); + _liveWorldOrigin.GetCenter, + handlePartArrayEnterWorld: partArrayLifecycle.HandleEnterWorld); + var remoteShadowPlacement = + new AcDream.App.Physics.RemoteShadowPlacementSynchronizer( + _remotePhysicsUpdater, + _liveWorldOrigin); + var remoteTeleportPresentation = + new AcDream.App.Physics.RemoteTeleportPlacementPresentation( + _liveEntityPresentation); _remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController( _physicsEngine, _liveEntities, _liveEntityMotionBindings.GetSetupCylinder, - CellLocalForSeed, - (entity, remote, cellId) => _remotePhysicsUpdater.SyncRemoteShadowToBody( - entity.Id, - remote, - _liveCenterX, - _liveCenterY, - cellId), - (guid, generation, deferShadowRestore) => - _liveEntityPresentation.CompleteAuthoritativePlacement( - guid, - generation, - deferShadowRestore), - (guid, generation) => - _liveEntityPresentation.BeginAuthoritativePlacement(guid, generation)); + _liveWorldOrigin.CellLocalForSeed, + remoteShadowPlacement.Sync, + remoteTeleportPresentation.Complete, + remoteTeleportPresentation.Begin); _equippedChildRenderer.ProjectionPoseReady += guid => _liveEntityLights.OnAttachedPoseReady(guid); _hookRouter.Register(entityEffects); @@ -2484,9 +2477,14 @@ public sealed class GameWindow : IDisposable _streamingController, _worldState, _worldReveal, - () => _playerServerGuid, + _localPlayerIdentity, sealedDungeonCells, Console.WriteLine); + _liveSessionController = new AcDream.App.Net.LiveSessionController(); + var localPhysicsTimestamps = + new AcDream.App.World.LiveSessionLocalPhysicsTimestampPublisher( + _localPlayerIdentity, + _liveSessionController); var liveEntityTeardown = new AcDream.App.World.LiveEntityRuntimeTeardownController( _liveEntities, @@ -2503,8 +2501,15 @@ public sealed class GameWindow : IDisposable _physicsEngine.ShadowObjects, _liveEntityLights!, _classificationCache, - () => _playerServerGuid); + _localPlayerIdentity); liveEntityComponentLifecycle.Bind(liveEntityTeardown); + var liveEntityDeletion = + new AcDream.App.World.LiveEntityDeletionController( + _liveEntities, + Objects, + liveEntityTeardown, + _localPlayerIdentity, + _options.DumpLiveSpawns ? Console.WriteLine : null); _liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController( _liveEntities, Objects, @@ -2518,9 +2523,9 @@ public sealed class GameWindow : IDisposable _liveEntityPresentation!), originCoordinator, networkUpdateBridge, - liveEntityTeardown, - PublishLocalPhysicsTimestamps, - () => _playerServerGuid, + localPhysicsTimestamps, + _localPlayerIdentity, + liveEntityDeletion, _options.DumpLiveSpawns ? Console.WriteLine : null); _liveEntityNetworkUpdates = new AcDream.App.Physics.LiveEntityNetworkUpdateController( @@ -2546,18 +2551,24 @@ public sealed class GameWindow : IDisposable _combatTargetController, _liveWorldOrigin, _localPlayerTeleportSink, - () => _playerController, + _playerControllerSlot, _localPlayerOutbound, - () => _playerHost, - () => _playerServerGuid, + _playerHostSlot, + _localPlayerIdentity, _updateFrameClock, - () => LiveSession, - PublishLocalPhysicsTimestamps, + _liveSessionController, + localPhysicsTimestamps.Publish, _movementTruthDiagnostics); _liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController( _liveEntities, - () => _playerServerGuid, - candidate => _liveEntityHydration.OnPrune(candidate)); + _localPlayerIdentity, + liveEntityDeletion); + _liveEntitySessionEvents = new AcDream.App.Net.LiveEntitySessionController( + _inboundEntityEvents, + _liveEntityHydration, + _liveEntityNetworkUpdates, + _localPlayerTeleportSink, + _entityEffects!); parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection); networkUpdateBridge.Bind(_liveEntityNetworkUpdates); _equippedChildRenderer.EntityReady += candidate => @@ -2573,7 +2584,6 @@ public sealed class GameWindow : IDisposable // CreateObject messages into _worldGameState as they arrive. Entirely // gated behind ACDREAM_LIVE=1 so the default run path is unchanged. _liveWorldOrigin.SetPlaceholder(centerX, centerY); - _liveSessionController = new AcDream.App.Net.LiveSessionController(); _combatAttackOperations.Bind( new AcDream.App.Combat.LiveCombatAttackOperations( Combat, @@ -2609,7 +2619,7 @@ public sealed class GameWindow : IDisposable mouseLookController, new AcDream.App.Input.CombatAttackInputFrameAdapter( _combatAttackController!)); - _streamingFrame = new AcDream.App.Streaming.StreamingFrameController( + var streamingFrame = new AcDream.App.Streaming.StreamingFrameController( _options.LiveMode, _localPlayerMode, _playerControllerSlot, @@ -2638,7 +2648,7 @@ public sealed class GameWindow : IDisposable new AcDream.App.Update.SettingsParticleRangeSource( _settingsVm, _persistedDisplay.ParticleRange)); - _liveSpatialReconciler = + var liveSpatialReconciler = new AcDream.App.Update.LiveSpatialPresentationReconciler( _entityEffects!, _equippedChildRenderer!, @@ -2721,16 +2731,15 @@ public sealed class GameWindow : IDisposable _localPlayerSkills, _viewportAspect); _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( - isLiveInWorld: () => _liveSessionController.IsInWorld, - isPlayerEntityPresent: () => - _liveEntities.MaterializedWorldEntities.ContainsKey( - _playerServerGuid), - isPlayerControllerReady: () => true, - // Retail SmartBox::UseTime @ 0x00455410 completes player - // position only after the destination cell load clears. - isWorldReady: LoginWorldReady, - enterPlayerMode: _playerModeController.EnterFromAutoEntry, - isPlayerModeActive: () => _localPlayerMode.IsPlayerMode); + new AcDream.App.Input.LivePlayerModeAutoEntryContext( + _liveSessionController, + _liveEntities, + _localPlayerIdentity, + _worldReveal + ?? throw new InvalidOperationException( + "World reveal was not composed before player auto-entry."), + _localPlayerMode, + _playerModeController)); _playerModeController.BindAutoEntry(_playerModeAutoEntry); var localTeleportPresentation = new AcDream.App.Streaming.LocalPlayerTeleportPresentation( @@ -2758,7 +2767,7 @@ public sealed class GameWindow : IDisposable _playerHostSlot, _chaseCameraInput, _liveWorldOrigin, - _liveSpatialReconciler), + liveSpatialReconciler), new AcDream.App.Streaming.LocalPlayerTeleportSession( _liveSessionController), localTeleportPresentation); @@ -2767,25 +2776,41 @@ public sealed class GameWindow : IDisposable // remains only as the staged-startup fallback used by shutdown when // composition fails before the transfer. _portalTunnel = null; - _liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( + var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( liveObjectFrame, _worldState, _liveSessionController, localPlayerFrame, - _liveSpatialReconciler); - _cameraFrame = new AcDream.App.Rendering.CameraFrameController( + liveSpatialReconciler); + var cameraFrame = new AcDream.App.Rendering.CameraFrameController( _cameraController!, _inputCapture, _cameraInput, localPlayerFrameRuntime, _chaseCameraInput, localPlayerFrame, - _liveSpatialReconciler, + liveSpatialReconciler, new AcDream.App.Combat.CombatCameraTargetSource( _gameplaySettings, Combat, _selection, _worldSelectionQuery!)); + _updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator( + new AcDream.App.Update.LiveEntityTeardownFramePhase( + _liveEntities), + new AcDream.App.Update.ConsoleUpdateFrameFailureSink(), + _updateFrameClock, + new AcDream.App.Update.PhysicsScriptClockPublisher(_scriptRunner!), + streamingFrame, + _gameplayInputFrame, + liveFrameCoordinator, + new AcDream.App.Update.LiveEntityLivenessFramePhase( + _liveEntityLiveness, + new AcDream.App.Update.StopwatchClientMonotonicTimeSource()), + _localPlayerTeleport, + new AcDream.App.Update.PlayerModeAutoEntryFramePhase( + _playerModeAutoEntry), + cameraFrame); AcDream.App.Net.LiveSessionStartResult liveStart = _liveSessionController.Start( _options, @@ -2958,7 +2983,7 @@ public sealed class GameWindow : IDisposable { events = new AcDream.App.Net.LiveSessionEventRouter( session, - CreateLiveEntitySessionSink(), + _liveEntitySessionEvents.CreateSink(), new AcDream.App.Net.LiveEnvironmentSessionSink( OnEnvironChanged, ticks => @@ -2987,44 +3012,6 @@ public sealed class GameWindow : IDisposable } } - private AcDream.App.Net.LiveEntitySessionSink CreateLiveEntitySessionSink() => new( - Spawned: spawn => _inboundEntityEvents.Run( - _liveEntityHydration!, - spawn, - static (hydration, value) => hydration.OnCreate(value)), - Deleted: deletion => _inboundEntityEvents.Run( - _liveEntityHydration!, deletion, - static (hydration, value) => hydration.OnDelete(value)), - PickedUp: pickup => _inboundEntityEvents.Run( - _liveEntityHydration!, pickup, - static (hydration, value) => hydration.OnPickup(value)), - MotionUpdated: motion => _inboundEntityEvents.Run( - _liveEntityNetworkUpdates!, motion, - static (updates, value) => updates.OnMotion(value)), - PositionUpdated: position => _inboundEntityEvents.Run( - _liveEntityNetworkUpdates!, position, - static (updates, value) => updates.OnPosition(value)), - VectorUpdated: vector => _inboundEntityEvents.Run( - _liveEntityNetworkUpdates!, vector, - static (updates, value) => updates.OnVector(value)), - StateUpdated: state => _inboundEntityEvents.Run( - _liveEntityNetworkUpdates!, state, - static (updates, value) => updates.OnState(value)), - ParentUpdated: parent => _inboundEntityEvents.Run( - _liveEntityHydration!, parent, - static (hydration, value) => hydration.OnParent(value)), - TeleportStarted: teleport => _inboundEntityEvents.Run( - _localPlayerTeleportSink, - teleport, - static (sink, value) => sink.OnTeleportStarted(value)), - AppearanceUpdated: appearance => _inboundEntityEvents.Run( - _liveEntityHydration!, appearance, - static (hydration, value) => hydration.OnAppearance(value)), - PlayPhysicsScript: script => _inboundEntityEvents.Run( - this, script, static (window, value) => window.OnPlayScriptReceived(value)), - PlayPhysicsScriptType: message => _inboundEntityEvents.Run( - this, message, static (window, value) => window._entityEffects?.HandleTyped(value))); - private AcDream.App.Net.LiveInventorySessionBindings CreateLiveInventorySessionBindings() => new( Objects, @@ -3276,47 +3263,6 @@ public sealed class GameWindow : IDisposable doll.MeshRefs = reposed; } - private void PublishLocalPhysicsTimestamps( - uint guid, - AcDream.App.World.AcceptedPhysicsTimestamps timestamps) - { - AcDream.Core.Net.WorldSession? session = LiveSession; - if (guid != _playerServerGuid || session is null) return; - session.PublishAcceptedLocalPhysicsTimestamps( - timestamps.Instance, - timestamps.ServerControlledMove, - timestamps.Teleport, - timestamps.ForcePosition); - } - - // #145: the LANDBLOCK-relative (cell-local) position used to SEED the player - // body's cell-relative CellPosition. This is the ONE place the streaming center - // (_liveCenter) is allowed to touch the physics frame — at the placement seam, - // converting the render-frame world position into the wire's (cell, local). After - // seeding, physics carries (cell, local) forward without ever reading _liveCenter. - private System.Numerics.Vector3 CellLocalForSeed(System.Numerics.Vector3 worldPos, uint cellId) - { - int lbX = (int)((cellId >> 24) & 0xFFu); - int lbY = (int)((cellId >> 16) & 0xFFu); - var origin = new System.Numerics.Vector3( - (lbX - _liveCenterX) * 192f, - (lbY - _liveCenterY) * 192f, - 0f); - return worldPos - origin; - } - - private bool LoginWorldReady() - { - return TryGetLoginWorldCell(out uint cell) - && EvaluateWorldRevealReadiness(cell).IsReady; - } - - private AcDream.App.Streaming.WorldRevealReadinessSnapshot EvaluateWorldRevealReadiness( - uint destinationCell) - { - return _worldReveal?.Evaluate(destinationCell) ?? default; - } - private bool TryGetLoginWorldCell(out uint cell) { cell = 0; @@ -3329,20 +3275,6 @@ public sealed class GameWindow : IDisposable return cell != 0; } - /// - /// Server-sent direct PhysicsScript (F754). EntityEffectController owns - /// GUID translation and pre-materialization queueing. - /// - /// - /// F754 remains a direct PhysicsScript DID. It is never resolved through a - /// typed PhysicsScriptTable. - /// - /// - private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message) - { - _entityEffects?.HandleDirect(message); - } - private void UpdateSkyPes( float dayFraction, AcDream.Core.World.DayGroupData? dayGroup, @@ -3512,76 +3444,10 @@ public sealed class GameWindow : IDisposable } private void OnUpdate(double dt) { - using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update); - - // A resource callback can request deletion while the presentation - // owner is inside a synchronous resume/suspend transition. The first - // unregister leaves a canonical teardown tombstone; drain it only now, - // after that callback stack has unwound on the same update thread. - try - { - _liveEntityHydration?.RetryPendingTeardowns(); - } - catch (AggregateException error) - { - // The tombstone retains every unfinished owner step. Report this - // attempt, but keep the frame advancing so the next update can - // retry after transient renderer/plugin teardown failures. - Console.Error.WriteLine($"[live-entity-teardown] {error}"); - } - - AcDream.App.Update.UpdateFrameTiming frameTiming = _updateFrameClock.Advance( + using var _updStage = _frameProfiler.BeginStage( + AcDream.App.Diagnostics.FrameStage.Update); + _updateFrameOrchestrator.Tick( new AcDream.App.Update.UpdateFrameInput(dt)); - float frameDelta = frameTiming.SimulationDeltaSecondsSingle; - - // Retail ScriptManager::AddScriptInternal (0x0051B310) stamps - // Timer::cur_time at the packet/default-script call site. Publish this - // update frame's clock before streaming and network dispatch, then - // reuse the same timestamp for animation hooks and the later drain. - _scriptRunner?.PublishTime(frameTiming.ScriptTime); - - // Streaming publishes before inbound CreateObject dispatch so newly - // accepted live projections can enter a resident bucket this frame. - _streamingFrame.Tick(); - - // Input callbacks feed the current object tick. Packets already read - // by Core.Net remain queued until the retail object/physics phase has - // consumed that input and published its final pose. - _gameplayInputFrame!.Tick(frameTiming); - - // Drain pending live-session traffic AFTER streaming so any incoming - // CreateObject events find their landblock already loaded in - // GpuWorldState. Non-blocking — returns immediately if no datagrams - // are in the kernel buffer. Fires EntitySpawned events synchronously. - // Step 2: routed through the controller; functionally identical. - // Retail SmartBox::UseTime (0x00455410) advances CObjectMaint and - // CPhysics before it drains the inbound event queue. Keep the complete - // live-object phase on that side of the barrier too. In particular, - // the recall action retires before ACE's Hidden SetState freezes its - // PartArray at the teleport boundary. - _liveFrameCoordinator.Tick(frameDelta); - _liveEntityLiveness?.Tick(ClientTimerNow()); - - // Retail teleport transit runs after streaming and inbound state so a - // newly resident destination can materialize in this same frame. The - // focused owner also converges an F751 that arrived before the local - // player projection/controller became available. - _localPlayerTeleport!.Tick(frameDelta); - // Phase K.1a — tick the input dispatcher so Hold-type bindings - // re-fire while their chord is held. K.1b adds the subscribers - // that actually consume the events. - // Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse - // on selection, then revert). No-op when nothing is flashing. - // Retained panel-local timers advance through RetailUiRuntime.Tick on the draw seam. - - // Phase K.2 — auto-enter player mode at login. The guard - // returns true on the firing tick (one-shot); subsequent ticks - // are no-ops. Skipped offline (no active session → IsLiveInWorld - // predicate stays false). Cancelled by manual fly-toggle in - // OnInputAction (Ctrl+Tab) or DebugPanel. - _playerModeAutoEntry?.TryEnter(); - - _cameraFrame.Tick(frameTiming); } private void OnCameraModeChanged(bool _modeBool) diff --git a/src/AcDream.App/Rendering/LiveEntityPartArrayLifecycle.cs b/src/AcDream.App/Rendering/LiveEntityPartArrayLifecycle.cs new file mode 100644 index 00000000..79e4fd32 --- /dev/null +++ b/src/AcDream.App/Rendering/LiveEntityPartArrayLifecycle.cs @@ -0,0 +1,19 @@ +using AcDream.App.World; + +namespace AcDream.App.Rendering; + +/// Routes retail PartArray world-entry boundaries to live animation owners. +internal sealed class LiveEntityPartArrayLifecycle +{ + private readonly LiveEntityAnimationRuntimeView _animations; + + public LiveEntityPartArrayLifecycle( + LiveEntityAnimationRuntimeView animations) => + _animations = animations ?? throw new ArgumentNullException(nameof(animations)); + + public void HandleEnterWorld(uint localEntityId) + { + if (_animations.TryGetValue(localEntityId, out LiveEntityAnimationState? animation)) + animation.Sequencer?.Manager.HandleEnterWorld(); + } +} diff --git a/src/AcDream.App/Update/UpdateFrameRuntimeAdapters.cs b/src/AcDream.App/Update/UpdateFrameRuntimeAdapters.cs new file mode 100644 index 00000000..92a5422f --- /dev/null +++ b/src/AcDream.App/Update/UpdateFrameRuntimeAdapters.cs @@ -0,0 +1,78 @@ +using AcDream.App.Input; +using AcDream.App.World; +using AcDream.Core.Vfx; + +namespace AcDream.App.Update; + +/// Bridges canonical live-entity teardown into the host frame. +internal sealed class LiveEntityTeardownFramePhase : IUpdateFrameTeardownPhase +{ + private readonly LiveEntityRuntime _runtime; + + public LiveEntityTeardownFramePhase(LiveEntityRuntime runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + + public void RetryPendingTeardowns() => _runtime.RetryPendingTeardowns(); +} + +internal sealed class ConsoleUpdateFrameFailureSink : IUpdateFrameFailureSink +{ + public void ReportTeardownFailure(AggregateException error) => + Console.Error.WriteLine($"[live-entity-teardown] {error}"); +} + +/// Publishes the frame clock to the retail PhysicsScript owner. +internal sealed class PhysicsScriptClockPublisher : IUpdateFrameScriptClockPublisher +{ + private readonly PhysicsScriptRunner _runner; + + public PhysicsScriptClockPublisher(PhysicsScriptRunner runner) => + _runner = runner ?? throw new ArgumentNullException(nameof(runner)); + + public void PublishTime(double scriptTime) => _runner.PublishTime(scriptTime); +} + +internal interface IClientMonotonicTimeSource +{ + double Now { get; } +} + +internal sealed class StopwatchClientMonotonicTimeSource + : IClientMonotonicTimeSource +{ + public double Now => + System.Diagnostics.Stopwatch.GetTimestamp() + / (double)System.Diagnostics.Stopwatch.Frequency; +} + +/// +/// Supplies the absolute client timer to liveness independently of the +/// normalized simulation delta. +/// +internal sealed class LiveEntityLivenessFramePhase + : ILiveEntityLivenessFramePhase +{ + private readonly LiveEntityLivenessController _liveness; + private readonly IClientMonotonicTimeSource _clock; + + public LiveEntityLivenessFramePhase( + LiveEntityLivenessController liveness, + IClientMonotonicTimeSource clock) + { + _liveness = liveness ?? throw new ArgumentNullException(nameof(liveness)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public void Tick() => _liveness.Tick(_clock.Now); +} + +internal sealed class PlayerModeAutoEntryFramePhase + : IPlayerModeAutoEntryFramePhase +{ + private readonly PlayerModeAutoEntry _autoEntry; + + public PlayerModeAutoEntryFramePhase(PlayerModeAutoEntry autoEntry) => + _autoEntry = autoEntry ?? throw new ArgumentNullException(nameof(autoEntry)); + + public void TryEnter() => _autoEntry.TryEnter(); +} diff --git a/src/AcDream.App/World/LiveEntityDeletionController.cs b/src/AcDream.App/World/LiveEntityDeletionController.cs new file mode 100644 index 00000000..b0436883 --- /dev/null +++ b/src/AcDream.App/World/LiveEntityDeletionController.cs @@ -0,0 +1,69 @@ +using AcDream.App.Input; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.World; + +internal interface ILiveEntityPruneSink +{ + bool Prune(LiveEntityPruneCandidate candidate); +} + +/// +/// Owns authoritative and expiry-driven live-object deletion through one +/// generation-safe runtime transaction. +/// +internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink +{ + private readonly LiveEntityRuntime _runtime; + private readonly ClientObjectTable _objects; + private readonly ILiveEntityTeardownCoordinator _teardown; + private readonly ILocalPlayerIdentitySource _identity; + private readonly Action? _diagnostic; + + public LiveEntityDeletionController( + LiveEntityRuntime runtime, + ClientObjectTable objects, + ILiveEntityTeardownCoordinator teardown, + ILocalPlayerIdentitySource identity, + Action? diagnostic = null) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _teardown = teardown ?? throw new ArgumentNullException(nameof(teardown)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _diagnostic = diagnostic; + } + + /// + /// Retail SmartBox::HandleDeleteObject @ 0x00451EA0 rejects the + /// player, requires the exact Instance timestamp, then performs one + /// symmetric logical teardown. + /// + public bool Delete(DeleteObject.Parsed delete) + { + if (delete.Guid == _identity.ServerGuid) + return false; + + if (!_runtime.TryGetRecord(delete.Guid, out _)) + _teardown.ForgetUnknownOwner(delete.Guid); + + bool removed = _runtime.UnregisterLiveEntity( + delete, + isLocalPlayer: false, + beforeTeardown: () => + ObjectTableWiring.ApplyEntityDelete(_objects, delete)); + if (removed) + { + _diagnostic?.Invoke( + $"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); + } + return removed; + } + + public bool Prune(LiveEntityPruneCandidate candidate) => + Delete(new DeleteObject.Parsed( + candidate.ServerGuid, + candidate.Generation)); +} diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index 1c20b5b7..c12cc06e 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -1,4 +1,5 @@ using AcDream.App.Rendering; +using AcDream.App.Input; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; @@ -146,9 +147,9 @@ internal sealed class LiveEntityHydrationController private readonly ILiveEntityReadyPublisher _ready; private readonly ILiveEntityWorldOriginCoordinator _origin; private readonly ILiveEntityNetworkUpdateSink _networkUpdates; - private readonly ILiveEntityTeardownCoordinator _teardown; - private readonly Action _publishTimestamps; - private readonly Func _playerGuid; + private readonly IAcceptedLocalPhysicsTimestampPublisher _timestamps; + private readonly ILocalPlayerIdentitySource _identity; + private readonly LiveEntityDeletionController _deletion; private readonly Action? _diagnostic; public LiveEntityHydrationController( @@ -160,9 +161,9 @@ internal sealed class LiveEntityHydrationController ILiveEntityReadyPublisher ready, ILiveEntityWorldOriginCoordinator origin, ILiveEntityNetworkUpdateSink networkUpdates, - ILiveEntityTeardownCoordinator teardown, - Action publishTimestamps, - Func playerGuid, + IAcceptedLocalPhysicsTimestampPublisher timestamps, + ILocalPlayerIdentitySource identity, + LiveEntityDeletionController deletion, Action? diagnostic = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); @@ -173,10 +174,9 @@ internal sealed class LiveEntityHydrationController _ready = ready ?? throw new ArgumentNullException(nameof(ready)); _origin = origin ?? throw new ArgumentNullException(nameof(origin)); _networkUpdates = networkUpdates ?? throw new ArgumentNullException(nameof(networkUpdates)); - _teardown = teardown ?? throw new ArgumentNullException(nameof(teardown)); - _publishTimestamps = publishTimestamps - ?? throw new ArgumentNullException(nameof(publishTimestamps)); - _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _timestamps = timestamps ?? throw new ArgumentNullException(nameof(timestamps)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _deletion = deletion ?? throw new ArgumentNullException(nameof(deletion)); _diagnostic = diagnostic; } @@ -207,7 +207,7 @@ internal sealed class LiveEntityHydrationController try { - _publishTimestamps(spawn.Guid, result.Timestamps); + _timestamps.Publish(spawn.Guid, result.Timestamps); if (_runtime.IsCurrentCreateIntegration( record, createIntegrationVersion) @@ -353,23 +353,7 @@ internal sealed class LiveEntityHydrationController // SmartBox::HandleDeleteObject rejects the player before resolving an // object or touching any queued owner state. A pre-Create player // Delete must therefore be side-effect-free too. - if (delete.Guid == _playerGuid()) - return false; - - if (!_runtime.TryGetRecord(delete.Guid, out _)) - _teardown.ForgetUnknownOwner(delete.Guid); - - bool removed = _runtime.UnregisterLiveEntity( - delete, - isLocalPlayer: false, - beforeTeardown: () => - ObjectTableWiring.ApplyEntityDelete(_objects, delete)); - if (removed) - { - _diagnostic?.Invoke( - $"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); - } - return removed; + return _deletion.Delete(delete); } /// @@ -377,9 +361,7 @@ internal sealed class LiveEntityHydrationController /// generation gate and teardown transaction as an authoritative F747. /// public bool OnPrune(LiveEntityPruneCandidate candidate) => - OnDelete(new DeleteObject.Parsed( - candidate.ServerGuid, - candidate.Generation)); + _deletion.Prune(candidate); public int RetryPendingTeardowns() => _runtime.RetryPendingTeardowns(); @@ -415,7 +397,7 @@ internal sealed class LiveEntityHydrationController uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu; LiveEntityRecord[] records = _runtime.Records - .Where(record => (record.ServerGuid != _playerGuid() + .Where(record => (record.ServerGuid != _identity.ServerGuid || !record.InitialHydrationCompleted || record.CreateProjectionSynchronizationPending || record.AppearanceProjectionSynchronizationPending) diff --git a/src/AcDream.App/World/LiveEntityHydrationPorts.cs b/src/AcDream.App/World/LiveEntityHydrationPorts.cs index b10dfcb6..ea0969c4 100644 --- a/src/AcDream.App/World/LiveEntityHydrationPorts.cs +++ b/src/AcDream.App/World/LiveEntityHydrationPorts.cs @@ -1,3 +1,4 @@ +using AcDream.App.Input; using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; using AcDream.App.Streaming; @@ -113,7 +114,17 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC private readonly StreamingController _streaming; private readonly GpuWorldState _worldState; private readonly WorldRevealCoordinator _worldReveal; - private readonly Func _playerGuid; + private sealed class DelegateIdentitySource : ILocalPlayerIdentitySource + { + private readonly Func _read; + + public DelegateIdentitySource(Func read) => + _read = read ?? throw new ArgumentNullException(nameof(read)); + + public uint ServerGuid => _read(); + } + + private readonly ILocalPlayerIdentitySource _identity; private readonly ISealedDungeonCellClassifier _sealedDungeonCells; private readonly Action? _diagnostic; @@ -122,7 +133,7 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC StreamingController streaming, GpuWorldState worldState, WorldRevealCoordinator worldReveal, - Func playerGuid, + ILocalPlayerIdentitySource identity, ISealedDungeonCellClassifier sealedDungeonCells, Action? diagnostic = null) { @@ -130,18 +141,37 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming)); _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); _worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)); - _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); _sealedDungeonCells = sealedDungeonCells ?? throw new ArgumentNullException(nameof(sealedDungeonCells)); _diagnostic = diagnostic; } + internal LiveEntityWorldOriginCoordinator( + LiveWorldOriginState origin, + StreamingController streaming, + GpuWorldState worldState, + WorldRevealCoordinator worldReveal, + Func playerGuid, + ISealedDungeonCellClassifier sealedDungeonCells, + Action? diagnostic = null) + : this( + origin, + streaming, + worldState, + worldReveal, + new DelegateIdentitySource(playerGuid), + sealedDungeonCells, + diagnostic) + { + } + public bool IsKnown => _origin.IsKnown; public LiveEntityOriginInitialization TryInitialize( WorldSession.EntitySpawn spawn) { - if (spawn.Guid != _playerGuid() + if (spawn.Guid != _identity.ServerGuid || spawn.Position is not { } position) { return new(_origin.IsKnown, Array.Empty()); diff --git a/src/AcDream.App/World/LiveEntityLivenessController.cs b/src/AcDream.App/World/LiveEntityLivenessController.cs index c6ebfd3c..1645ce29 100644 --- a/src/AcDream.App/World/LiveEntityLivenessController.cs +++ b/src/AcDream.App/World/LiveEntityLivenessController.cs @@ -1,4 +1,5 @@ using AcDream.Core.Net.Messages; +using AcDream.App.Input; namespace AcDream.App.World; @@ -86,19 +87,19 @@ internal sealed class LiveEntityLivenessController private const double MaintenanceIntervalSeconds = 1.0; private readonly LiveEntityRuntime _runtime; - private readonly Func _playerGuid; - private readonly Action _prune; + private readonly ILocalPlayerIdentitySource _identity; + private readonly ILiveEntityPruneSink _prune; private readonly LiveEntityLivenessTracker _tracker = new(); private readonly List _samples = new(); private double _nextMaintenanceAt; public LiveEntityLivenessController( LiveEntityRuntime runtime, - Func playerGuid, - Action prune) + ILocalPlayerIdentitySource identity, + ILiveEntityPruneSink prune) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); _prune = prune ?? throw new ArgumentNullException(nameof(prune)); } @@ -108,7 +109,7 @@ internal sealed class LiveEntityLivenessController return; _nextMaintenanceAt = now + MaintenanceIntervalSeconds; - uint playerGuid = _playerGuid(); + uint playerGuid = _identity.ServerGuid; if (playerGuid == 0 || !_runtime.TryGetRecord(playerGuid, out LiveEntityRecord player) || player.Snapshot.Position is not { } playerPosition) @@ -144,7 +145,7 @@ internal sealed class LiveEntityLivenessController if (_runtime.TryGetRecord(candidate.ServerGuid, out LiveEntityRecord current) && current.Generation == candidate.Generation) { - _prune(candidate); + _prune.Prune(candidate); } } } diff --git a/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs b/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs index 48bd8cb3..cd79d305 100644 --- a/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs +++ b/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs @@ -1,4 +1,5 @@ using AcDream.App.Interaction; +using AcDream.App.Input; using AcDream.App.Physics; using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; @@ -38,7 +39,7 @@ internal sealed class LiveEntityRuntimeTeardownController private readonly ShadowObjectRegistry? _shadows; private readonly LiveEntityLightController? _lights; private readonly EntityClassificationCache? _classification; - private readonly Func? _playerGuid; + private readonly ILocalPlayerIdentitySource? _identity; private readonly Func _createPlan; private readonly Action _forgetUnknownOwner; @@ -57,7 +58,7 @@ internal sealed class LiveEntityRuntimeTeardownController ShadowObjectRegistry shadows, LiveEntityLightController lights, EntityClassificationCache classification, - Func playerGuid) + ILocalPlayerIdentitySource identity) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _presentation = presentation ?? throw new ArgumentNullException(nameof(presentation)); @@ -77,7 +78,7 @@ internal sealed class LiveEntityRuntimeTeardownController _lights = lights ?? throw new ArgumentNullException(nameof(lights)); _classification = classification ?? throw new ArgumentNullException(nameof(classification)); - _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); _createPlan = CreatePlan; _forgetUnknownOwner = effects.ForgetUnknownOwner; } @@ -158,7 +159,7 @@ internal sealed class LiveEntityRuntimeTeardownController cleanups.Add(() => _translucencyFades!.ClearEntity(existingEntity.Id)); cleanups.Add(() => _projectionWithdrawal!.LeaveWorld( record, - _playerGuid!())); + _identity!.ServerGuid)); cleanups.Add(() => _children!.OnLogicalTeardown(record)); cleanups.Add(() => _shadows!.Deregister(existingEntity.Id)); cleanups.Add(() => _lights!.Forget(existingEntity.Id)); diff --git a/src/AcDream.App/World/LiveWorldOriginState.cs b/src/AcDream.App/World/LiveWorldOriginState.cs index 370e411f..551bb45c 100644 --- a/src/AcDream.App/World/LiveWorldOriginState.cs +++ b/src/AcDream.App/World/LiveWorldOriginState.cs @@ -1,3 +1,5 @@ +using System.Numerics; + namespace AcDream.App.World; /// @@ -40,6 +42,23 @@ internal sealed class LiveWorldOriginState CenterY = centerY; } + public (int X, int Y) GetCenter() => (CenterX, CenterY); + + /// + /// Converts the streamed render frame back into retail's landblock-local + /// physics frame at the one placement seed boundary. + /// + public Vector3 CellLocalForSeed(Vector3 worldPosition, uint cellId) + { + int landblockX = (int)((cellId >> 24) & 0xFFu); + int landblockY = (int)((cellId >> 16) & 0xFFu); + var origin = new Vector3( + (landblockX - CenterX) * 192f, + (landblockY - CenterY) * 192f, + 0f); + return worldPosition - origin; + } + /// /// Ends the accepted-origin lifetime while retaining the last coordinates /// as the next placeholder. Consumers must gate them on diff --git a/src/AcDream.App/World/LocalPhysicsTimestampPublisher.cs b/src/AcDream.App/World/LocalPhysicsTimestampPublisher.cs new file mode 100644 index 00000000..965995ce --- /dev/null +++ b/src/AcDream.App/World/LocalPhysicsTimestampPublisher.cs @@ -0,0 +1,43 @@ +using AcDream.App.Input; +using AcDream.App.Net; + +namespace AcDream.App.World; + +internal interface IAcceptedLocalPhysicsTimestampPublisher +{ + void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps); +} + +/// +/// Publishes accepted local-player physics timestamps to the current exact +/// session without retaining the window host. +/// +internal sealed class LiveSessionLocalPhysicsTimestampPublisher + : IAcceptedLocalPhysicsTimestampPublisher +{ + private readonly ILocalPlayerIdentitySource _identity; + private readonly ILiveWorldSessionSource _session; + + public LiveSessionLocalPhysicsTimestampPublisher( + ILocalPlayerIdentitySource identity, + ILiveWorldSessionSource session) + { + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + } + + public void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps) + { + if (serverGuid != _identity.ServerGuid + || _session.CurrentSession is not { } session) + { + return; + } + + session.PublishAcceptedLocalPhysicsTimestamps( + timestamps.Instance, + timestamps.ServerControlledMove, + timestamps.Teleport, + timestamps.ForcePosition); + } +} diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index ba308732..ea55f29f 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.Input; using AcDream.App.Rendering; using AcDream.App.Streaming; using AcDream.App.World; @@ -1594,9 +1595,14 @@ public sealed class LiveEntityHydrationControllerTests public readonly RecordingOrigin Origin; public readonly RecordingNetworkSink Network = new(); public readonly RecordingTeardownCoordinator Teardown = new(); + public readonly RecordingTimestampPublisher Timestamps; public readonly LiveEntityHydrationController Controller; - public Action? PublishTimestampsAction { get; set; } + public Action? PublishTimestampsAction + { + get => Timestamps.PublishAction; + set => Timestamps.PublishAction = value; + } public Fixture( bool originKnown, @@ -1617,6 +1623,7 @@ public sealed class LiveEntityHydrationControllerTests Relationships = new RecordingRelationships(Operations); Ready = new RecordingReadyPublisher(Operations); Origin = new RecordingOrigin(originKnown); + Timestamps = new RecordingTimestampPublisher(Operations); Network.ApplyAction = events => { if (events.Position is not { } position) @@ -1631,6 +1638,15 @@ public sealed class LiveEntityHydrationControllerTests out _, out _); }; + var identity = new LocalPlayerIdentityState + { + ServerGuid = playerGuid, + }; + var deletion = new LiveEntityDeletionController( + Runtime, + Objects, + Teardown, + identity); Controller = new LiveEntityHydrationController( Runtime, Objects, @@ -1640,13 +1656,9 @@ public sealed class LiveEntityHydrationControllerTests Ready, Origin, Network, - Teardown, - (guid, timestamps) => - { - Operations.Add($"timestamps:{timestamps.Instance}"); - PublishTimestampsAction?.Invoke(guid, timestamps); - }, - () => playerGuid); + Timestamps, + identity, + deletion); } public LiveEntityRecord Record @@ -1671,6 +1683,23 @@ public sealed class LiveEntityHydrationControllerTests } } + private sealed class RecordingTimestampPublisher + : IAcceptedLocalPhysicsTimestampPublisher + { + private readonly List _operations; + + public RecordingTimestampPublisher(List operations) => + _operations = operations; + + public Action? PublishAction { get; set; } + + public void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps) + { + _operations.Add($"timestamps:{timestamps.Instance}"); + PublishAction?.Invoke(serverGuid, timestamps); + } + } + private class RecordingResources : ILiveEntityResourceLifecycle { public int RegisterCount { get; protected set; } diff --git a/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs b/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs index 3303e781..b2298e78 100644 --- a/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs +++ b/tests/AcDream.App.Tests/World/LiveWorldOriginStateTests.cs @@ -1,3 +1,4 @@ +using System.Numerics; using AcDream.App.World; namespace AcDream.App.Tests.World; @@ -59,4 +60,17 @@ public sealed class LiveWorldOriginStateTests Assert.Equal(50, state.CenterX); Assert.Equal(51, state.CenterY); } + + [Fact] + public void CellLocalForSeed_UsesTheCurrentLandblockOrigin() + { + var state = new LiveWorldOriginState(); + state.SetPlaceholder(0x30, 0x32); + + Vector3 local = state.CellLocalForSeed( + new Vector3(200f, -300f, 5f), + 0x3130_0001u); + + Assert.Equal(new Vector3(8f, 84f, 5f), local); + } } diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 12c1f907..b9e2aded 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -36,9 +36,8 @@ public sealed class UpdateFrameOrchestratorTests [Fact] public void ConditionalReconciles_RemainAtTheirOwningEdges() { - // Checkpoint A freezes only the outer ownership contract. Checkpoints - // E and F must replace these probes with production teleport/camera - // owner tests before the conditional edges can be claimed guarded. + // The outer ownership oracle is complemented by the production + // teleport and camera tests that pin both conditional reconcile edges. var calls = new List(); UpdateFrameOrchestrator frame = Create( calls, @@ -228,19 +227,6 @@ public sealed class UpdateFrameOrchestratorTests FieldInfo[] ownerFields = owner.GetFields( BindingFlags.Instance | BindingFlags.NonPublic); Assert.DoesNotContain(ownerFields, field => field.FieldType == typeof(GameWindow)); - if (owner == typeof(AcDream.App.Input.RetailLocalPlayerFrameController)) - { - // Checkpoint D replaced input capture with its typed owner. - // Checkpoint F still owns the remaining player presentation - // callback composition. No other phase owner may add one. - Assert.Contains( - ownerFields, - field => typeof(Delegate).IsAssignableFrom(field.FieldType)); - Assert.DoesNotContain( - ownerFields, - field => field.FieldType == typeof(Func)); - continue; - } Assert.DoesNotContain( ownerFields, field => typeof(Delegate).IsAssignableFrom(field.FieldType)); @@ -277,14 +263,24 @@ public sealed class UpdateFrameOrchestratorTests [Fact] public void ProductionFrame_PublishesPhysicsScriptTimeExactlyOnce() { + string root = FindRepoRoot(); string source = File.ReadAllText(Path.Combine( - FindRepoRoot(), + root, "src", "AcDream.App", "Rendering", "GameWindow.cs")); + string adapters = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Update", + "UpdateFrameRuntimeAdapters.cs")); - Assert.Equal(1, source.Split("PublishTime(", StringSplitOptions.None).Length - 1); + Assert.Equal(0, CountOccurrences(source, "PublishTime(")); + Assert.Equal(1, CountOccurrences(adapters, "_runner.PublishTime(")); + Assert.Contains("new AcDream.App.Update.PhysicsScriptClockPublisher(", source, + StringComparison.Ordinal); } [Fact] @@ -363,12 +359,10 @@ public sealed class UpdateFrameOrchestratorTests "GameWindow.cs")); Assert.Contains("new AcDream.App.Streaming.StreamingFrameController(", source); - Assert.Equal(1, CountOccurrences(source, "_streamingFrame.Tick();")); - AssertAppearsInOrder( + Assert.DoesNotContain("_streamingFrame", source, StringComparison.Ordinal); + Assert.Equal(1, CountOccurrences( source, - "_streamingFrame.Tick();", - "_gameplayInputFrame!.Tick(frameTiming);", - "_liveFrameCoordinator.Tick(frameDelta);"); + "_updateFrameOrchestrator.Tick(")); Assert.DoesNotContain("_streamingController.Tick(observerCx", source, StringComparison.Ordinal); Assert.DoesNotContain("DungeonStreamingGate.Compute", source, @@ -387,7 +381,8 @@ public sealed class UpdateFrameOrchestratorTests "GameWindow.cs")); Assert.Contains("new AcDream.App.Input.GameplayInputFrameController(", source); - Assert.Equal(1, CountOccurrences(source, "_gameplayInputFrame!.Tick(frameTiming);")); + Assert.DoesNotContain("_gameplayInputFrame!.Tick", source, + StringComparison.Ordinal); Assert.DoesNotContain("_inputDispatcher?.Tick()", source, StringComparison.Ordinal); Assert.DoesNotContain("TryTakeRawSample", source, @@ -468,9 +463,8 @@ public sealed class UpdateFrameOrchestratorTests "new AcDream.App.Streaming.LocalPlayerTeleportController(", source, StringComparison.Ordinal); - Assert.Equal(1, CountOccurrences( - source, - "_localPlayerTeleport!.Tick(frameDelta);")); + Assert.DoesNotContain("_localPlayerTeleport!.Tick", source, + StringComparison.Ordinal); Assert.DoesNotContain("_teleportTransit", source, StringComparison.Ordinal); Assert.DoesNotContain("_teleportAnim", source, StringComparison.Ordinal); Assert.DoesNotContain("_teleportViewPlane", source, StringComparison.Ordinal); @@ -486,9 +480,10 @@ public sealed class UpdateFrameOrchestratorTests StringComparison.Ordinal); AssertAppearsInOrder( source, - "_liveEntityLiveness?.Tick(ClientTimerNow());", - "_localPlayerTeleport!.Tick(frameDelta);", - "_playerModeAutoEntry?.TryEnter();"); + "new AcDream.App.Update.LiveEntityLivenessFramePhase(", + "_localPlayerTeleport,", + "new AcDream.App.Update.PlayerModeAutoEntryFramePhase(", + "cameraFrame);"); } [Fact] @@ -649,7 +644,9 @@ public sealed class UpdateFrameOrchestratorTests "AcDream.App", "Rendering", "GameWindow.cs")); - Assert.Equal(1, CountOccurrences(source, "_cameraFrame.Tick(frameTiming);")); + Assert.Equal(1, CountOccurrences( + source, + "_updateFrameOrchestrator.Tick(")); Assert.DoesNotContain("CanAdvanceLocalPlayer", source, StringComparison.Ordinal); Assert.DoesNotContain("GetCombatCameraTargetPoint()", source, StringComparison.Ordinal); @@ -657,11 +654,7 @@ public sealed class UpdateFrameOrchestratorTests StringComparison.Ordinal); Assert.DoesNotContain("_localPlayerFrame.TryGetPresentationAfterNetwork", source, StringComparison.Ordinal); - AssertAppearsInOrder( - source, - "_localPlayerTeleport!.Tick(frameDelta);", - "_playerModeAutoEntry?.TryEnter();", - "_cameraFrame.Tick(frameTiming);"); + Assert.DoesNotContain("_cameraFrame", source, StringComparison.Ordinal); string cameraSource = File.ReadAllText(Path.Combine( root, @@ -677,6 +670,123 @@ public sealed class UpdateFrameOrchestratorTests "retail?.Update("); } + [Fact] + public void GameWindow_OnUpdateOwnsOnlyProfilingAndOneOrchestratorTick() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.Equal(1, CountOccurrences( + source, + "_updateFrameOrchestrator.Tick(")); + Assert.DoesNotContain("_updateFrameClock.Advance(", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_liveFrameCoordinator", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_liveEntityLiveness?.Tick", source, + StringComparison.Ordinal); + Assert.DoesNotContain("_playerModeAutoEntry?.TryEnter", source, + StringComparison.Ordinal); + AssertAppearsInOrder( + source, + "private void OnUpdate(double dt)", + "_frameProfiler.BeginStage(", + "_updateFrameOrchestrator.Tick(", + "private void OnCameraModeChanged"); + } + + [Fact] + public void ProductionFrameAdaptersRetainTypedOwnersWithoutWindowCallbacks() + { + Type[] adapters = + [ + typeof(LiveEntityTeardownFramePhase), + typeof(ConsoleUpdateFrameFailureSink), + typeof(PhysicsScriptClockPublisher), + typeof(LiveEntityLivenessFramePhase), + typeof(PlayerModeAutoEntryFramePhase), + ]; + + foreach (Type adapter in adapters) + { + FieldInfo[] fields = adapter.GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + FieldInfo clock = Assert.Single( + typeof(LiveEntityLivenessFramePhase).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_clock"); + Assert.Equal(typeof(IClientMonotonicTimeSource), clock.FieldType); + + FieldInfo teardownRuntime = Assert.Single( + typeof(LiveEntityTeardownFramePhase).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic)); + Assert.Equal(typeof(LiveEntityRuntime), teardownRuntime.FieldType); + + Type[] typedProductionOwners = + [ + typeof(LiveEntityLivenessController), + typeof(AcDream.App.Input.LivePlayerModeAutoEntryContext), + typeof(LiveSessionLocalPhysicsTimestampPublisher), + typeof(AcDream.App.Physics.LiveEntityNetworkUpdateController), + typeof(AcDream.App.Rendering.LiveEntityPartArrayLifecycle), + typeof(AcDream.App.Physics.RemoteShadowPlacementSynchronizer), + typeof(AcDream.App.Physics.RemoteTeleportPlacementPresentation), + typeof(AcDream.App.Net.LiveEntitySessionController), + ]; + foreach (Type owner in typedProductionOwners) + { + FieldInfo[] fields = owner.GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + FieldInfo originIdentity = Assert.Single( + typeof(LiveEntityWorldOriginCoordinator).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_identity"); + Assert.Equal( + typeof(AcDream.App.Input.ILocalPlayerIdentitySource), + originIdentity.FieldType); + + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + Assert.DoesNotContain("PublishLocalPhysicsTimestamps", source, + StringComparison.Ordinal); + Assert.DoesNotContain("LoginWorldReady", source, + StringComparison.Ordinal); + Assert.DoesNotContain("candidate => _liveEntityHydration.OnPrune", source, + StringComparison.Ordinal); + Assert.DoesNotContain("CreateLiveEntitySessionSink", source, + StringComparison.Ordinal); + Assert.DoesNotContain("private System.Numerics.Vector3 CellLocalForSeed", source, + StringComparison.Ordinal); + Assert.DoesNotContain("OnPlayScriptReceived", source, + StringComparison.Ordinal); + Assert.Contains("_liveWorldOrigin.GetCenter", source, + StringComparison.Ordinal); + Assert.Contains("_liveWorldOrigin.CellLocalForSeed", source, + StringComparison.Ordinal); + Assert.Contains("_liveEntitySessionEvents.CreateSink()", source, + StringComparison.Ordinal); + } + private static UpdateFrameOrchestrator Create( List calls, RecordingTeardown? teardown = null, From 9512404e2556cb01a24f092060605b33715fb839 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 03:54:11 +0200 Subject: [PATCH 80/98] docs(architecture): close GameWindow slice 6 --- AGENTS.md | 23 ++--- CLAUDE.md | 23 ++--- docs/ISSUES.md | 12 +-- docs/architecture/code-structure.md | 24 ++++-- .../retail-divergence-register.md | 6 +- docs/plans/2026-04-11-roadmap.md | 16 +++- docs/plans/2026-05-12-milestones.md | 16 ++-- ...ndow-slice-6-update-frame-orchestration.md | 24 +++++- memory/project_gamewindow_decomposition.md | 84 +++++++++++++++++++ 9 files changed, 182 insertions(+), 46 deletions(-) create mode 100644 memory/project_gamewindow_decomposition.md diff --git a/AGENTS.md b/AGENTS.md index 30fb71a3..209851cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,054 tests pass / 5 skip. +Release builds; 7,182 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -123,18 +123,21 @@ Selection/interaction Slice 1, live-animation-presentation Slice 2, complete live-session ownership Slice 3, and live-entity App integration Slice 4 are landed. Focused hydration, teardown, inbound-authority, network-update, and motion-runtime owners now sit over canonical `LiveEntityRuntime` without a -second GUID dictionary. Slice 5 is complete: the complete DAT -build, render/physics/static publication, retryable retirement, and shared- -origin lifetime barrier are out of `GameWindow`; it is now 8,811 raw lines / -247 fields / 153 methods, down 44% from the 15,723-line campaign baseline. -The lifecycle/reconnect and synchronized nine-stop soak gates pass. Slice 6 -update-frame orchestration is active; later slices finish render orchestration -and composition cleanup. +second GUID dictionary. Slice 5 landblock presentation and Slice 6 update-frame +orchestration are complete. Streaming, input, the object/network barrier, +liveness, teleport, player mode, and camera presentation have typed phase +owners; `GameWindow.OnUpdate` is one orchestrator handoff with no substantial +transitive callback facade. `GameWindow` is 7,026 raw lines / 241 fields / 108 +methods, down 8,697 lines (55.3%) from the 15,723-line campaign baseline. The +7,182-test Release suite, 314-second lifecycle/reconnect gate, and 394-second +synchronized nine-stop soak pass. Slice 7 draw-frame orchestration is active; +Slice 8 finishes composition and Silk callback cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining -R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's +R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. -Start structural work at `docs/architecture/code-structure.md`; start +Start structural work at `memory/project_gamewindow_decomposition.md` and +`docs/architecture/code-structure.md`; start magic follow-up at `claude-memory/project_magic_ui_and_casting.md`; start render/streaming work at `claude-memory/project_render_pipeline_digest.md`. diff --git a/CLAUDE.md b/CLAUDE.md index ce3e9c7f..e2cad234 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,054 tests pass / 5 skip. +Release builds; 7,182 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -121,18 +121,21 @@ Selection/interaction Slice 1, live-animation-presentation Slice 2, complete live-session ownership Slice 3, and live-entity App integration Slice 4 are landed. Focused hydration, teardown, inbound-authority, network-update, and motion-runtime owners now sit over canonical `LiveEntityRuntime` without a -second GUID dictionary. Slice 5 is complete: the complete DAT -build, render/physics/static publication, retryable retirement, and shared- -origin lifetime barrier are out of `GameWindow`; it is now 8,811 raw lines / -247 fields / 153 methods, down 44% from the 15,723-line campaign baseline. -The lifecycle/reconnect and synchronized nine-stop soak gates pass. Slice 6 -update-frame orchestration is active; later slices finish render orchestration -and composition cleanup. +second GUID dictionary. Slice 5 landblock presentation and Slice 6 update-frame +orchestration are complete. Streaming, input, the object/network barrier, +liveness, teleport, player mode, and camera presentation have typed phase +owners; `GameWindow.OnUpdate` is one orchestrator handoff with no substantial +transitive callback facade. `GameWindow` is 7,026 raw lines / 241 fields / 108 +methods, down 8,697 lines (55.3%) from the 15,723-line campaign baseline. The +7,182-test Release suite, 314-second lifecycle/reconnect gate, and 394-second +synchronized nine-stop soak pass. Slice 7 draw-frame orchestration is active; +Slice 8 finishes composition and Silk callback cleanup. See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining -R6 ownership cleanup, TS-50/TS-51, Modern Pipeline MP1b+, and #225's +R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. -Start structural work at `docs/architecture/code-structure.md`; start +Start structural work at `memory/project_gamewindow_decomposition.md` and +`docs/architecture/code-structure.md`; start magic follow-up at `claude-memory/project_magic_ui_and_casting.md`; start render/streaming work at `claude-memory/project_render_pipeline_digest.md`. diff --git a/docs/ISSUES.md b/docs/ISSUES.md index e4ba7c64..194fcefb 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,17 +24,19 @@ 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. -## Current queue — 2026-07-21 +## Current queue — 2026-07-22 -- **Active structural work:** `GameWindow` decomposition. Selection/interaction - ownership, live animation presentation, and complete live-session ownership - are landed; Slice 4 live-entity App integration is next in +- **Active structural work:** `GameWindow` decomposition. Slices 1–6 are + complete; `GameWindow.OnUpdate` is one typed orchestration handoff and the + class is 7,026 lines / 241 fields / 108 methods. Slice 7 draw-frame + orchestration is active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. - **Separate rendering gate:** `#225`, lifestone/particle alpha ordering. Its connected performance, lifetime, and unattended portal routes pass. - **Carried behavior debt:** `#153` far-teleport unstreamed-edge arrival and - `#116` slide response. TS-50/TS-51 are tracked in the divergence register. + `#116` slide response. TS-50/TS-51/TS-53 are tracked in the divergence + register. - **Deferred visual fidelity:** `#226` retail landscape detail overlay. - **Build hygiene:** `#228` records 17 clean-Release test-project warnings; production compilation and all tests pass. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 9fc6cd54..f27abb03 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -1,8 +1,9 @@ # acdream — code structure & extraction sequence **Status:** Living document. Created 2026-05-16; implementation reconciliation -completed 2026-07-21; Slices 1–5 landed the same day. This is the active -structural program before new M4 subsystems enter the App layer. +completed 2026-07-21; Slices 1–6 landed by 2026-07-22. Slice 7 draw-frame +orchestration is the active structural program before new M4 subsystems enter +the App layer. **Purpose:** Describe the desired structural state of the App layer, explain the rules we've adopted, and lay out the safe extraction sequence from today's reality (one 15,723-line `GameWindow.cs` at the @@ -28,6 +29,7 @@ after Slice 2 14,546 lines / 277 fields / 190 method after Slice 3 14,310 lines / 274 fields / 190 methods after Slice 4 10,301 lines / 267 fields / 163 methods after Slice 5 closeout 8,811 lines / 247 fields / 153 methods +after Slice 6 closeout 7,026 lines / 241 fields / 108 methods ``` `GameWindow` is the single object that: @@ -396,7 +398,7 @@ useful ordering seam, but its ownership status is **partial**. | Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. | | Network session | **Complete** | `LiveSessionController` owns resolve/create/bind/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal; `LiveSessionLifecycleHost` is the narrow App composition boundary; `LiveSessionEventRouter` and `LiveSessionCommandRouter` own exact inbound/outbound lifetimes. `GameWindow` retains one controller field and focused domain sink factories only (`d9ccf8a6`, `6a5d9e2e`). | | Live identity/lifetime | **Complete App integration** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, and logical/spatial lifetime. `LiveEntityHydrationController`, `LiveEntityRuntimeTeardownController`, and `LiveEntityNetworkUpdateController` own DAT-backed hydration, retryable cleanup, and accepted Position/Vector/State/Movement presentation without a second GUID owner (`aa90c646`). | -| Inbound/object-frame order | **Partial** | `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveEntityAnimationScheduler`, `LiveEntityAnimationPresenter`, static scheduler, remote/projectile updaters, and teleport owners are shipped. `AdvanceLiveObjectRuntimeCore` retains the visible cross-owner order until Slice 6. | +| Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). | | World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. | | Retained gameplay UI | **Mostly complete feature ownership** | `RetailUiRuntime` and focused panel/controllers own layout and behavior. `GameWindow.OnLoad` still performs substantial service composition, which is allowed until the final composition cleanup. | | Selection/interaction | **Complete** | `WorldSelectionQuery` owns read-only picking/classification/range queries; `SelectionInteractionController` owns selection intent, Use/PickUp transport, exact-incarnation queues, and auto-walk deferral; `ItemInteractionController` owns ItemHolder policy plus the shared retail inventory-request transaction. `GameWindow` retains construction and narrow lifecycle forwarding only. | @@ -572,11 +574,11 @@ resource pin/release balance, collision footprints across landblock seams, and first-login bootstrap replacement. The deterministic world-lifecycle gate and nine-destination resource soak pass. -#### Slice 6 — extract update-frame orchestration +#### Slice 6 — extract update-frame orchestration — COMPLETE 2026-07-22 Detailed execution ledger: [`docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`](../plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md) -(active). +(complete). After the stateful bodies above have owners, make the update path a real orchestrator instead of delegates back into the window. It preserves the @@ -590,6 +592,18 @@ graph and registered TS-53 host-order adaptation live in the detailed ledger. Frame-order tests and the existing R6 gate must produce the same lifecycle and movement traces before and after extraction. +The production cutover is complete in `e91f3102`. `GameWindow.OnUpdate` now +contains the profiler scope and one `UpdateFrameOrchestrator.Tick` handoff; +streaming, input, the object/network barrier, liveness, teleport, player-mode, +and camera phases have typed owners with no substantial callback facade into +the window. The complete Release suite passes 7,182 tests with five intentional +skips. The 314-second lifecycle/reconnect gate and 394-second synchronized +nine-stop resource soak both pass with graceful exits, stable live-owner +counts, and a 0.8 ms update-frame p95 at the final Caul checkpoint. +`GameWindow` is 7,026 raw lines / 241 fields / 108 methods: 8,697 lines (55.3%) +smaller than the campaign baseline. TS-50, TS-51, and TS-53 remain accurately +registered; this behavior-preserving extraction introduced no new divergence. + #### Slice 7 — extract `RenderFrameOrchestrator` Move the complete draw graph and its reusable frame-local scratch state into a diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b2f3dd95..516f9f12 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -257,10 +257,10 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` | | TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` | | TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` | -| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order | -| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order | +| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; shared frame drain in `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveEffectFrameController`) | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order | +| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveObjectFrameController` + `LiveEffectFrameController` shared `_particles.Tick` / `_scripts.Tick` tail); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order | | TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 | -| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slice 6 preserves the accepted host order as an ownership-only extraction. | `src/AcDream.App/Rendering/GameWindow.cs` (`OnUpdate` teleport/camera tails and `_retailUiRuntime.Tick` in `OnRender`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | +| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slice 6 preserves the accepted host order as an ownership-only extraction. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/GameWindow.cs` (`_retailUiRuntime.Tick` in `OnRender`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index f2023076..46c10bca 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-21. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); new M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–6 are complete and Slice 7 draw-frame orchestration is active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. 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. --- @@ -56,9 +56,19 @@ session replacement, including failure/retry and fast relogin. Shutdown keeps the reset graph alive until disposal converges. `GameWindow` is 8,811 raw lines / 247 fields / 153 methods (44% below the campaign baseline); 7,054 Release tests, the capped/reconnect lifecycle gate, and the synchronized -nine-stop resource soak pass. Slice 6 update-frame orchestration is active; -its detailed checkpoint ledger is +nine-stop resource soak pass. + +Slice 6 update-frame orchestration is complete. Streaming, input, the retail- +shaped object/network barrier, liveness, local teleport, player mode, and +camera presentation now have typed phase owners. `GameWindow.OnUpdate` is one +orchestrator handoff, with no substantial transitive callback facade back into +the window. `GameWindow` is 7,026 raw lines / 241 fields / 108 methods, down +8,697 lines (55.3%) from the campaign baseline. The complete Release suite +passes 7,182 tests / 5 skips; the 314-second lifecycle/reconnect gate and +394-second synchronized nine-stop resource soak pass with graceful exits and +stable owner counts. The completed ledger is [`2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`](2026-07-21-gamewindow-slice-6-update-frame-orchestration.md). +Slice 7 `RenderFrameOrchestrator` extraction is now active. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index ff932553..c398945d 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -23,16 +23,20 @@ complete. Slice 5 landblock presentation is also complete: the worker DAT transaction, render/physics/static publishers, and retryable Near/full retirement now have focused owners. The direct production cutover also serializes complete old-window retirement with teleport and session -origin lifetimes. `GameWindow` is 8,811 raw lines / 247 fields / 153 methods, -down 1,490 lines from the Slice-5 baseline and 6,912 (44%) from the campaign -baseline. The full Release suite passes 7,054 tests / 5 skips; the 311-second -capped/reconnect lifecycle gate and synchronized 394-second nine-destination -resource soak both pass. Slice 6 update-frame orchestration is active under +origin lifetimes. Slice 6 update-frame orchestration is complete: streaming, +input, the object/network barrier, liveness, teleport, player mode, and camera +presentation have typed phase owners, and `GameWindow.OnUpdate` is one +orchestrator handoff. `GameWindow` is 7,026 raw lines / 241 fields / 108 +methods, down 8,697 lines (55.3%) from the campaign baseline. The full Release +suite passes 7,182 tests / 5 skips; the 314-second capped/reconnect lifecycle +gate and synchronized 394-second nine-destination resource soak both pass with +graceful exits and stable owner counts. The completed Slice 6 ledger is [`2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`](2026-07-21-gamewindow-slice-6-update-frame-orchestration.md). +Slice 7 draw-frame orchestration is active. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus -registered TS-50/TS-51 timing residuals (the complete-root-Frame/object-workset +registered TS-50/TS-51/TS-53 timing residuals (the complete-root-Frame/object-workset cutover is automated- and visual-gate complete; Release builds with 17 known test-project warnings tracked by #228, and 6,575 tests pass / 5 skip), and the deferred Modern Pipeline track (MP1b+). The separate #225 shared-alpha diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md index 832c15ec..344e1444 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md @@ -1,6 +1,6 @@ # GameWindow Slice 6 — update-frame orchestration -**Status:** Active 2026-07-21. +**Status:** Complete 2026-07-22. **Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 6. **Baseline:** `d4ecac1d`; `GameWindow.cs` is 8,811 raw lines, 247 fields, and 153 methods. Release baseline: 7,054 passed / 5 skipped. The connected @@ -29,7 +29,7 @@ never hidden behind a retry, delay, suppression flag, or reordered callback. `UpdateFrameOrchestrator`. - [x] G — delete the old `OnUpdate` bodies, callback facades, and obsolete frame state; run focused corrected-diff reviews after each ownership edge. -- [ ] H — full Release suite, connected lifecycle/reconnect gate, synchronized +- [x] H — full Release suite, connected lifecycle/reconnect gate, synchronized resource soak, documentation, durable memory, and line/field/method closeout. Every checked checkpoint is committed as one bisectable architectural unit. @@ -437,7 +437,7 @@ Focused App/Core tests cover destination readiness, forced holds, same-cell placement, LoginComplete order, reset/disposal, GUID reuse, reentrancy, host rollback, auto-entry idempotence, and completion-mailbox ordering. The full App suite is green at 2,814 passed / 3 skipped. `GameWindow.cs` is 7,351 -lines at this checkpoint, down from the slice baseline of 15,723. +lines at this checkpoint, down from the 15,723-line campaign baseline. ### F/G — camera, cutover, and deletion @@ -467,7 +467,7 @@ retains no duplicate lifecycle state. Focused Release tests are green at 37/37, the App suite at 2,820 passed / 3 skipped, and the full suite at 7,179 passed / 5 skipped. All three corrected-diff reviews are clean. `GameWindow.cs` is 7,160 lines, down 8,563 lines (54.5%) from the 15,723-line -slice baseline. +campaign baseline. **Checkpoint G completed 2026-07-22.** `GameWindow.OnUpdate` now owns only the update profiler scope and one `UpdateFrameOrchestrator.Tick` call. Teardown, @@ -501,6 +501,22 @@ The slice requires no new visual behavior. If connected screenshots or traces change, return to the responsible checkpoint; do not defer the difference to Slice 7. +**Completed 2026-07-22.** The complete Release suite is green at 7,182 passed / +5 fixture or environment skips. The deterministic lifecycle/reconnect gate +passed in 314.195 seconds across capped login/travel checkpoints, graceful close, +and a fresh-process reconnect. Its only warning was the expected set of 25 +world-edge empty landblocks. The synchronized nine-stop resource soak passed in +393.581 seconds with movement, turn, jump, combat, teleport waits, fatal-log scan, +and graceful exit. The final Caul plateau held the same 21,025 entity and 13 +animation-owner counts as the earlier Caul return, with update-frame p95 at +0.8 ms; working/private memory increased by 112.2/111.0 MiB across that retained +route, inside the existing deterministic gate. The soak's only diagnostics were +35 known DAT-driven missing VFX table/emitter records and the same 25 expected +world-edge misses. The divergence audit keeps TS-50, TS-51, and TS-53 unchanged +and found no new behavior adaptation. Final `GameWindow` size is 7,026 raw +lines / 241 fields / 108 methods, versus 8,811 / 247 / 153 at Slice 6 entry and +15,723 / 278 / 205 at the campaign baseline. + ## 6. Review gate for every checkpoint After each implementation checkpoint: diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md new file mode 100644 index 00000000..9acbe0b1 --- /dev/null +++ b/memory/project_gamewindow_decomposition.md @@ -0,0 +1,84 @@ +# GameWindow decomposition — current truth + +## Current state + +The behavior-preserving App ownership campaign is complete through Slice 6. +`GameWindow.cs` moved from the 2026-07-21 baseline of 15,723 lines / 278 fields / +205 methods to 7,026 lines / 241 fields / 108 methods: 8,697 lines (55.3%) were +removed without changing accepted gameplay or rendering behavior. + +| Slice | Ownership moved out | Closeout size | +|---|---|---:| +| 1 | world selection and interaction intent | 14,912 / 278 / 191 | +| 2 | live animation presentation | 14,546 / 277 / 190 | +| 3 | complete live-session lifecycle and routing | 14,310 / 274 / 190 | +| 4 | live-entity hydration, teardown, and network presentation | 10,301 / 267 / 163 | +| 5 | landblock build, publication, retirement, and shared-origin lifetime | 8,811 / 247 / 153 | +| 6 | complete update-frame orchestration | 7,026 / 241 / 108 | + +Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, +`c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. +The detailed ledgers and earlier commit sets live under `docs/plans/2026-07-21- +gamewindow-slice-*.md` and `docs/architecture/code-structure.md`. + +## Frozen update graph + +`GameWindow.OnUpdate` starts the profiler scope and calls one +`UpdateFrameOrchestrator.Tick`. The accepted production graph is: + +1. retry retained live-entity teardown; +2. normalize/publish the update and PhysicsScript clocks; +3. converge streaming origin, residence, readiness, and rescued projections; +4. sample semantic input, raw mouse, and combat intent; +5. advance live objects and deferred presentation hooks; +6. drain inbound network traffic inside one world mutation batch; +7. run the local post-network position tail; +8. reconcile root/child/emitter/light presentation without advancing time; +9. expire liveness; +10. advance local teleport/reveal/tunnel presentation; +11. evaluate one-shot player-mode entry; +12. publish fly/chase/player camera presentation. + +`LiveEntityRuntime` remains the canonical identity/incarnation owner. +`GpuWorldState` remains the spatial bucket owner. `LiveSessionController` owns +the session generation. Extracted phase owners query these typed runtimes and +must not create replacement dictionaries, service locators, or callback +facades into substantial `GameWindow` methods. + +## Accepted verification + +- Release suite: 7,182 passed / 5 fixture or environment skips. +- Connected lifecycle/reconnect: 314.195 seconds, graceful close and fresh + process reconnect passed; only 25 expected world-edge empty landblocks. +- Synchronized nine-stop resource soak: 393.581 seconds, graceful exit; stable + 21,025 entity and 13 animation-owner counts at the Caul return and Caul + plateau; update p95 0.8 ms. Working/private memory delta was +112.2/+111.0 + MiB, inside the gate. +- The 35 missing VFX table/emitter diagnostics are known DAT-driven records, + not a Slice 6 regression. +- TS-50, TS-51, and TS-53 remain registered; Slice 6 introduced no new retail + divergence. + +## Next work + +Slice 7 extracts `RenderFrameOrchestrator`: the modern draw graph, portal/PView +visibility routing, landscape/opaque/shared-alpha flushes, particles, debug +draw, paperdoll, retained UI, frame fences, and reusable frame-local scratch. +It must keep the exact accepted order and use a small immutable service set plus +explicit per-frame input. It may not become a GL service locator or call +substantial draw bodies back on `GameWindow`. + +Slice 8 then groups composition and shutdown wiring and reduces Silk callbacks +to narrow calls into input, update, render, resize, focus, and shutdown owners. + +## Do not retry + +- Do not reintroduce delegate facades into `GameWindow`; an ordering wrapper is + not an extraction until the state and behavior body move. +- Do not reorder the frozen update graph during structural work. TS-53 records + the known retail host-order difference for a separate evidence-backed port. +- Do not add a second GUID, session, spatial, or resource owner. +- Do not fold full `GameEntity` aggregation into this campaign; evaluate it + after Slice 8. +- Small value/policy callbacks and diagnostics are acceptable only when they do + not call a substantial window body or hide mutable ownership. From f316bbd817b087b6120688843615218f2e8bf9a1 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 04:11:58 +0200 Subject: [PATCH 81/98] docs(architecture): plan GameWindow render-frame extraction --- docs/architecture/acdream-architecture.md | 131 +++-- docs/architecture/code-structure.md | 6 +- ...ndow-slice-7-render-frame-orchestration.md | 481 ++++++++++++++++++ 3 files changed, 545 insertions(+), 73 deletions(-) create mode 100644 docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 743ab0a5..96431340 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -605,89 +605,76 @@ visual-distance reduction. ## Per-Frame Update Order (current runtime) ``` -1. Network tick - └── Drain inbound queue → process CreateObject, UpdateMotion, - UpdatePosition, PlayerTeleport → create/update GameEntities - -2. Streaming tick - └── Compute observer position → load/unload landblocks → - create terrain + scenery GameEntities - -3. Input tick (player mode only) - └── InputDispatcher scopes → PlayerMovementController → - MotionInterpreter/body prediction → ResolveWithTransition → - send MoveToState/AutonomousPosition to server - -4. Entity / animation tick - └── Current code still has scattered world/entity state. L.1 owns - animation parity; L.2 owns movement/collision conformance. - -5. Render tick - └── Read current entity mesh refs, draw - TerrainModernRenderer + WbDrawDispatcher + EnvCellRenderer - (frustum cull, translucency pass, portal visibility, etc.) - World Wb translucents + Scene particles submit to RetailAlphaQueue; - RetailPViewRenderer flushes the landscape scope before the conditional - depth clear and GameWindow flushes the final scope before private - viewports/UI. Sky/private viewport particles remain immediate. - Projectiles remain ordinary live-entity draws; there is no global or - projectile-specific render pass. - -6. Plugin tick - └── Fire IEvents, drain IActions queue - -6a. UI tick - IPanelHost.Draw → iterate registered IPanel instances, build - ViewModels from IGameState, dispatch user Commands via ICommandBus. - Backend-agnostic — ImGui or custom retail-look draws here depending - on which is compiled in. See docs/plans/2026-04-24-ui-framework.md. +UpdateFrameOrchestrator + 1. retry retained live-entity teardown tombstones + 2. normalize/publish the host and PhysicsScript clocks + 3. converge streaming origin, readiness, residence, and rescued projections + 4. sample semantic input, raw mouse, and combat intent + 5. advance local/ordinary/static live objects and deferred effect tails + 6. drain inbound session traffic in one GpuWorldState mutation batch + 7. run the local post-network CommandInterpreter position tail + 8. reconcile roots, children, emitters, and lights without advancing time + 9. expire liveness + 10. advance local teleport/reveal/tunnel presentation + 11. evaluate one-shot player-mode entry + 12. publish fly/chase/player camera presentation ``` +`GameWindow.OnUpdate` owns only the profiler scope and one orchestrator handoff. +The accepted host order above is preserved as TS-53; it is not overclaimed as +the exact retail `Client::UseTime` order. See the completed Slice 6 ledger and +`memory/project_gamewindow_decomposition.md`. + +The render callback currently owns the GPU frame boundary and draw graph. Slice +7 extracts that graph without changing its order: per-resource frame begins and +render-thread uploads; world/PView and its two shared-alpha scopes; portal and +paperdoll private viewports; retained gameplay UI; ImGui devtools; screenshot; +then the GPU-flight close in `finally`. Both UI stacks coexist in the same frame. + --- -## Render Pipeline (SSOT — current state + unified-PView target) +## Render Pipeline (SSOT — current accepted state) > The modern path (Phase N.5, mandatory) is > `WbDrawDispatcher` (entities) + `EnvCellRenderer` (indoor cell shells) + -> `TerrainModernRenderer` (terrain), fed by the portal-visibility stack. This section is -> the authoritative description of how indoor/outdoor rendering is *supposed* to work and -> where the code currently diverges. Canonical reset handoff: -> `docs/research/2026-05-31-render-architecture-reset-handoff.md`. +> `TerrainModernRenderer` (terrain), fed by the unified PView stack. This is the +> authoritative current draw model; the 2026-05-31 reset handoff is historical. -**The principle (retail PView).** acdream must render the world the way retail does — -through **one** portal-visibility traversal whose output **gates every geometry type -uniformly**. From the player's cell, walk the portal graph; each visible cell carries a -screen-space clip region (its portal opening, recursively intersected); the **outside** -(terrain + outdoor scenery) is reached only through **exit portals** and is clipped to -those openings. Interior cell shells, interior statics, and the outside are **all** -clipped to their PView region. This is why retail is **seamless by construction**. Decomp -anchors: `PView::ConstructView` (`:433750`), `InitCell` (`:432896`), `DrawCells` -(`:432715`), `CEnvCell::find_visible_child_cell` (`:311397`), `SmartBox::update_viewer` -(`:92761`). Reference port acdream owns but never invokes: WB `RenderInsideOut` / -`VisibilityManager`. +**One visibility owner.** `RetailPViewRenderer.DrawInside` is the production +world gate. Its root is the collided camera/viewer cell, or the synthetic +outdoor cell adaptation; the player's current cell separately owns sunlight +and indoor lighting. A null root exists only for login/debug/streaming-gap +fallback frames. `RetailPViewRenderer` is the one authoritative PView owner and +product family: it builds the main frame, deliberate per-building exterior +floods for the synthetic outdoor root, and separate interior-root look-in +frames. No second per-frame ACME visibility BFS competes with that family. -**The one rule:** *compute visibility once; enforce it once, for all geometry.* Indoors, -you see the outside **only** through portal openings (clipped); an empty outside-view -(windowless interior) draws **no** outdoor geometry. Outdoors, the gate is "everything." +**Current draw discipline.** Outside-view slices draw sky, terrain, and outdoor +statics first. Interior-root building look-ins then punch all entry apertures +before drawing their shell and contents. The landscape shared-alpha scope +flushes before the root-specific depth boundary. Interior roots perform the +conditional depth clear and then write true-depth exit seals; the synthetic +outdoor root retains world depth and writes far-Z building-entry punches. +Opaque EnvCell shells, immediate far-to-near transparent EnvCell shells, cell +statics and their particles, then the surviving main-stage dynamics and their +particles follow. Look-in and outside-stage dynamics intentionally draw in +their earlier landscape phases. The final world shared-alpha scope flushes +before private portal/paperdoll viewports and UI. -**Current divergence (the patchwork — what the reset must fix).** acdream computes the -visibility correctly (`CellVisibility.ComputeVisibility` → `PortalVisibilityBuilder.Build`, -a `ConstructView` port → `ClipFrameAssembler`) but then **enforces it three different, -inconsistent ways**: -1. `TerrainModernRenderer` — gated by `TerrainClipMode {Skip|Scissor|Planes}` (the Scissor - fallback over-includes). -2. `EnvCellRenderer` — gated by the per-cell clip slot (≈correct; the shells DO render — - proven by the `[shell]` probe, `ACDREAM_PROBE_SHELL`). -3. `WbDrawDispatcher` — gated by `ParentCellId ∈ visibleCellIds`, **but outdoor stabs - (`ParentCellId==null`) bypass the gate** → outdoor scenery/terrain shows from inside - (issue #78). +The modern renderer intentionally does not hard-clip every shell or entity to +the accumulated polygon. It combines PView admission and viewcone checks with +retail's punch/seal depth discipline; terrain/outside slices use the bounded +clip-plane/scissor adaptation. World Wb translucents and Scene particles share +one stable far-to-near `RetailAlphaQueue`; EnvCell transparent shells and +private viewports remain immediate. Projectiles are ordinary live-entity draws, +never a separate global pass. -Three gates that must agree but don't → structural seams (transparent walls, -terrain-through-floor, grey enclosure). **The reset consolidates them into the single -PView gate** (outside content clipped to the `OutsideView` region; no `ParentCellId==null` -bypass; no Scissor over-include). This is a **consolidation of existing machinery** -(`PortalVisibilityBuilder` + `ClipFrame`), not a rewrite. Do NOT add a fourth special-case -gate to mask a seam — that anti-pattern produced the patchwork. +Retail anchors are `SmartBox::RenderNormalMode @ 0x00453AA0`, +`PView::DrawInside @ 0x005A5860`, `PView::DrawCells @ 0x005A4840`, +`LScape::draw @ 0x00506330`, `D3DPolyRender::FlushAlphaList @ 0x0059D2E0`, +and `SceneTool::EndFrame @ 0x0043FB30`. The deliberate modern/PView +adaptations are audited in the retail divergence register; Slice 7 changes +their ownership only, not their behavior. ### Streaming publication ownership diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index f27abb03..07d89b24 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -179,7 +179,7 @@ src/AcDream.App/ ├── RuntimeOptions.cs # typed startup options (Rule 4) ├── Rendering/ │ ├── GameWindow.cs # thin: GL/window lifecycle + delegates per-frame to RenderFrameOrchestrator -│ ├── RenderFrameOrchestrator.cs # per-frame draw order (sky → terrain → opaque → trans → particles → debug → UI) +│ ├── RenderFrameOrchestrator.cs # GPU-flight boundary + typed world/private/UI render phases │ ├── LiveEntityAnimationScheduler.cs # shipped: ordinary live-object update workset │ ├── LiveEntityAnimationPresenter.cs # final part-pose/mesh/effect composition after scheduler output │ ├── RetailStaticAnimatingObjectScheduler.cs # shipped: separate static-animation workset @@ -606,6 +606,10 @@ registered; this behavior-preserving extraction introduced no new divergence. #### Slice 7 — extract `RenderFrameOrchestrator` +Detailed execution ledger: +[`docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md`](../plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md) +(active). + Move the complete draw graph and its reusable frame-local scratch state into a GL-owning App collaborator. Preserve the exact modern pipeline order, clip routing, PView flood, landscape/opaque/shared-alpha flush boundaries, diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md new file mode 100644 index 00000000..135cf817 --- /dev/null +++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md @@ -0,0 +1,481 @@ +# GameWindow Slice 7 — render-frame orchestration + +**Status:** Active 2026-07-22. +**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 7. +**Baseline:** `9512404e`; `GameWindow.cs` is 7,026 raw lines, 241 fields, and +108 methods. The Release suite passes 7,182 tests / 5 fixture or environment +skips. The 314-second lifecycle/reconnect gate and 394-second synchronized +nine-stop resource soak pass. +**Behavior rule:** This is an ownership extraction. Preserve every accepted +world/PView/alpha/private-viewport/UI draw edge, render-thread publication +edge, GL-state boundary, and GPU-flight failure contract. Do not combine the +extraction with a render-quality change, visibility correction, frame-order +port, or resource-lifetime redesign. + +## Progress ledger + +- [ ] A — freeze the complete render graph, correct the architecture SSOT, and + introduce data-only frame contracts plus deterministic order/failure tests. +- [ ] B — extract paperdoll, panel-layout/devtools, and render-diagnostics leaf + owners while `GameWindow` still orders the frame. +- [ ] C — extract frame-resource begin/upload/publication and live display, + and weather/display foundations that do not depend on camera/root + classification. +- [ ] D — extract frame-data classification and reusable world scratch without + duplicating live identity, spatial, visibility, or resource ownership; then + run the dependent audio, sky, lighting, and listener preparation. +- [ ] E — replace `RetailPViewDrawContext`'s callback bag with a typed + `RetailPViewPassExecutor` and data-only inputs. +- [ ] F — extract `WorldSceneRenderer`, including both shared-alpha scopes, + PView/fallback world branches, particles, debug world draw, and completion. +- [ ] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one + handoff, and delete obsolete frame bodies, fields, helpers, and callbacks. +- [ ] H — corrected-diff reviews, full Release suite, connected lifecycle and + soak gates, framebuffer comparison, documentation, memory, and metrics. + +Every checked checkpoint is one bisectable architectural commit. A checkpoint +does not complete while a new owner merely delegates a substantial body back to +`GameWindow`. + +## 1. Outcome and non-goals + +At slice exit, `GameWindow.OnRender` supplies one immutable delta/viewport input +to `RenderFrameOrchestrator`. The orchestrator owns the GPU-flight transaction +and composes focused frame-resource, world-scene, private-presentation, and +diagnostic phases. `GameWindow` remains the construction and shutdown shell; it +does not keep the draw graph, PView pass callbacks, frame scratch, paperdoll +assembly, or performance-title body. + +The target ownership graph is: + +```text +RenderFrameOrchestrator + ├── RenderFrameResourceController GPU slot + resource BeginFrame/upload + ├── WorldRenderFrameBuilder camera/root/light/classification facts + ├── WorldSceneRenderer accepted world draw graph + │ ├── RetailPViewRenderer visibility/order owner + │ ├── RetailPViewPassExecutor concrete GL pass implementation + │ └── WorldRenderDiagnostics probes/debug counters/signatures + ├── PrivatePresentationRenderer portal + paperdoll + retained UI + ImGui + │ ├── PaperdollFramePresenter + │ └── DevToolsFramePresenter + └── RenderFrameDiagnosticsController title/resource/frame snapshots +``` + +Names may narrow as code lands, but the ownership boundaries do not collapse +into one replacement god object. No new owner may receive `GameWindow`, a broad +service locator, or an anonymous list of callbacks into the window. + +This slice does **not** change: + +- the viewer-cell render root or player-cell lighting root; +- synthetic outdoor-cell behavior or the null-root safety path; +- portal flood, building pre-gates, clip-plane/scissor limits, punch/seal + depth behavior, shell lift, viewcone culling, or draw distances; +- shared-alpha sort keys, scope boundaries, blend/cull behavior, or immediate + EnvCell/private alpha; +- weather, fog, point-light selection, particle visibility/range, terrain + quality, FOV, frame pacing, or audio-listener formulas; +- portal-space, paperdoll, retained gameplay UI, ImGui, or screenshot visuals; +- resource ownership, shutdown/disposal order, or three-frame GPU retirement; +- TS-53's retained-UI time placement on the render seam. + +Issue #225's translucent lifestone/particle comparison remains a separate user +visual gate. Passing this structural slice must not mark it resolved. + +## 2. Retail oracle and accepted adaptations + +This extraction reuses the existing named-retail research rather than +reinterpreting the draw algorithms: + +- `SceneTool::BeginScene @ 0x0043DAD0` establishes the black scene-clear + baseline used by portal CreatureMode replacement; +- `SmartBox::DrawNoBlit @ 0x00454C20` updates the viewer and invokes the normal + world render before post-world selection work; +- `SmartBox::RenderNormalMode @ 0x00453AA0` owns landscape/inside dispatch and + the final retail alpha flush; +- `RenderDeviceD3D::DrawInside @ 0x0059F0D0`, + `PView::DrawInside @ 0x005A5860`, and + `PView::ConstructView @ 0x005A57B0` own the portal traversal; +- `PView::DrawCells @ 0x005A4840` fixes the outside-landscape → alpha flush → + conditional depth clear → exit masks → shells → object-list order; +- `LScape::draw @ 0x00506330`, `GameSky::Draw @ 0x00506FF0`, + `DrawBuilding @ 0x0059F2A0`, `DrawBlock @ 0x005A17C0`, and + `DrawObjCellForDummies @ 0x005A0760` constrain the landscape/building/cell + phases; +- `AddMeshToAlphaList @ 0x0059C230` and + `FlushAlphaList @ 0x0059D2E0` constrain alpha submission/flush boundaries; +- `CPhysicsPart::UpdateViewerDistance @ 0x0050E030` and + `CShadowPart::insertion_sort @ 0x006B5130` constrain transformed DAT + SortCenter/CYpt and stable far-to-near ordering; +- `CreatureMode::Render @ 0x004529D0` constrains portal and paperdoll private + viewports; +- `SceneTool::EndFrame @ 0x0043FB30` proves retail gameplay UI precedes retail + profiler/debug overlays; placing ImGui above gameplay UI is acdream's IA-12 + modern coexistence adaptation, not literal Keystone parity. + +The readable pseudocode oracle is +[`docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md`](../research/2026-06-05-retail-pview-indoor-render-pseudocode.md), +with the current full reference in +[`docs/research/2026-06-02-retail-render-pipeline-full-reference.md`](../research/2026-06-02-retail-render-pipeline-full-reference.md). +Shared-alpha ordering is pinned separately by +[`docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`](../research/2026-07-18-retail-shared-alpha-list-pseudocode.md). + +This slice preserves, rather than retires, the registered modern adaptations: +IA-8/9/10/12/14/15; AD-16/17/19/24; +AP-21/32/33/34/35/85/92/115/116/117; +TS-6/7; and TS-53. Source pointers move with ownership in the same commit, but +mechanism/risk language changes only if behavior actually changes. + +## 3. Frozen production render order + +The complete accepted order is: + +1. call `GpuFrameFlightController.BeginFrame` outside the render `try`; +2. begin the composite-texture frame and cache tick; +3. begin, in order, dispatcher, EnvCell, portal depth, world text, retained-UI + text, clip, terrain, lighting UBO, then profiler frame state for the current + GPU slot; +4. select the portal replacement viewport; reset particle visibility when it + is active; +5. choose portal black or normal fog clear color, force depth writes, clear + color/depth/stencil, and establish CW/back-face frame-global state; +6. run GL-state/surface diagnostics; +7. advance render-thread publication in order: `WbMeshAdapter.Tick`, destination + reveal preparation/evaluation, then `ParticleRenderer.BeginFrame`; +8. begin ImGui, tick weather, and begin retail selection-scene accumulation; +9. when a normal camera/world viewport is active: + - begin the one frame-scoped `RetailAlphaQueue` collection; its landscape + `Flush` drains the first sub-scope and `EndFrame` later drains the final + world sub-scope without a second begin; + - resolve camera/projection/frustum and begin particle/terrain visibility; + - publish world-reveal visibility/completion; + - preview audio/display settings and publish the listener; + - resolve player lighting cell and collided viewer render cell; + - build prior-visible-cell point-light candidacy, sun/fog/lighting UBO, and + animated/equipped classification; + - construct the synthetic outdoor root/building candidates when required; + - run the null-root safety world or the one `RetailPViewRenderer.DrawInside` + graph; + - draw the post-world Scene particles/weather fallback and debug geometry; + - sample current-frame DebugVM visible-landblock and nearest-collision facts + before the developer UI can consume them; + - close the final shared-alpha scope and publish terrain/cell particle + visibility; +10. complete retail selection-scene accumulation; +11. draw the portal CreatureMode replacement viewport; +12. render the private paperdoll FBO before its 2-D widget blit; +13. tick/update-cursor/draw retained gameplay UI; +14. render ImGui menus and developer panels above gameplay UI; +15. capture a requested default-framebuffer screenshot; +16. publish window-title, resource-dump, and frame-profile diagnostics; +17. close the exact GPU frame once in `finally`. + +Within `RetailPViewRenderer.DrawInside`: + +1. construct the main PView; merge per-building exterior floods only for the + synthetic outdoor root and build separate look-in frames for interior roots; +2. assemble/upload the one clip arena, prepare EnvCell batches, partition + statics/dynamics, and classify outside-stage dynamics; +3. draw each outside slice early: sky, terrain, and outdoor statics; +4. draw building look-ins: all far-Z punches, opaque shells, then transparent + shells plus statics/dynamics/emitters; +5. draw outside-stage dynamic meshes, Scene particles, and weather late; +6. draw one interior-root unattached Scene-particle pass; +7. flush the **landscape** shared-alpha scope; +8. for an interior root, perform the full depth clear, then write true-depth + exit seals; for an outdoor root, retain world depth and use far-Z entry + punches; +9. draw opaque EnvCell shells once, then transparent shell cells immediate + far-to-near; +10. draw cell statics as one cross-cell batch, then their particles; +11. draw the surviving main-flood dynamics last, then their particles; look-in + and outside-stage dynamics remain in their earlier landscape phases. + +The landscape alpha flush must never move across the depth clear. The final +world alpha close must never move after portal/paperdoll private viewports. + +## 4. Architecture and interfaces + +### 4.1 Frame transaction + +Use immutable values equivalent to: + +```csharp +internal readonly record struct RenderFrameInput( + double DeltaSeconds, + int ViewportWidth, + int ViewportHeight); + +internal readonly record struct WorldRenderFrameOutcome( + int VisibleLandblocks, + int TotalLandblocks, + bool NormalWorldDrawn); +``` + +Portal-viewport selection and drawing belong to `PrivatePresentationRenderer`, +which runs after the world phase. If a combined public outcome becomes useful, +the top-level orchestrator composes it only after private presentation; the +world owner never claims that a later viewport was drawn. + +`RenderFrameOrchestrator` receives a small fixed set of typed phase owners. It +does not receive every renderer separately, a mutable frame-services bag, or +`GameWindow`. + +`BeginFrame` remains outside the `try`: if it fails, no `EndFrame` is attempted. +After begin succeeds, every failure closes exactly once. Render failure plus +close failure becomes the current explicit `AggregateException`; close-only +failure propagates directly. Alpha, particle-visibility, and selection-scene +completion retain their current non-finally behavior; changing that recovery +contract requires separate approval and evidence. + +### 4.2 Borrowed resources and shutdown + +Slice 7 collaborators borrow the already-created GL/render owners. They do not +dispose them. The existing retryable shutdown transaction keeps its proven +dependency order: wait for submitted work; withdraw UI/render publications; +release textures and mesh owners; then dispose GL backing stores, with +`GpuFrameFlightController` last among render resources. Slice 8 may group +construction/disposal after the render cutover is stable. + +`DisplayFramePacingController` is the focused shared owner for requested VSync, +live display preview, monitor/window callbacks, and the separate +`OnFrameRendered` pacing event. Render preparation and window lifecycle wiring +invoke that typed owner directly; neither calls back into a substantial +`GameWindow` method. Its existing disposal remains in the Slice 7 shutdown +transaction. + +### 4.3 World frame data + +`WorldRenderFrameBuilder` owns reusable classification scratch: + +- animated/equipped entity IDs; +- synthetic outdoor node and candidate building cells; +- previous-frame drawable cells used by point-light candidacy; +- camera/root/classification facts with one-frame borrowed lifetime. + +The builder owns only facts available before PView execution. Slice-local +visible/outdoor/unattached particle-owner IDs belong to +`RetailPViewPassExecutor`. The current interior partition is produced by +`RetailPViewRenderer.DrawInside` and remains in its borrowed result/diagnostic +snapshot; it is never stored as stale pre-draw builder state. + +It queries canonical `LiveEntityRuntime`, `GpuWorldState`, player-mode/camera, +physics cell graph, cell visibility, and landblock presentation sources. It +does not copy their dictionaries or create a second visibility computation. +Returned collections are borrowed until the next build; tests pin this +lifetime and consumers cannot retain them. + +### 4.4 PView pass execution + +Replace the current `RetailPViewDrawContext` callback bag with a data-only +`RetailPViewFrameInput`, a typed cell-visibility source, and one explicit +`IRetailPViewPassExecutor`/`RetailPViewPassExecutor`. The executor owns the GL +resources needed for these named operations: + +```text +SetTerrainClip +DrawLandscapeSlice +DrawLandscapeSliceLate +ClearInteriorDepth +DrawExitPortalMask +DrawLookInPortalPunch +DrawUnattachedSceneParticles +FlushLandscapeAlpha +DrawCellParticles +DrawDynamicsParticles +EmitDiagnostics +``` + +`RetailPViewRenderer` remains the retail ordering owner and calls these typed +operations. The executor may not call `GameWindow` or make independent +visibility/order decisions. Existing borrowed `RetailPViewFrameResult` scratch +semantics remain explicit. + +### 4.5 Presentation and diagnostics + +`PaperdollFramePresenter` owns dirty state, DAT-backed clone/re-dress, pose +selection/application, and FBO rendering. Inventory/session paths call +`MarkDirty`; they do not mutate its internals. + +`PrivatePresentationRenderer` owns portal viewport, paperdoll, retained UI, +ImGui, and screenshot order. A focused panel-layout/devtools owner handles menu +actions and is also reused by framebuffer resize; it cannot call a substantial +window method. + +`WorldRenderDiagnostics` owns pass-local PView/GL/scissor state, render +signatures, and world-pass timings. `RenderFrameDiagnosticsController` owns +FPS/frame-time accumulation, final visible-landblock counters, performance +title/resource publication, and the public render-frame snapshot. Only +render-derived DebugVM providers move in Slice 7. Update/session/streaming +providers and the cross-domain world-lifecycle resource sampler stay at the +composition boundary until Slice 8 rather than turning either diagnostics +owner into a replacement service locator. Diagnostics observe the frame; they +never choose a draw branch or mutate canonical world ownership. + +## 5. Implementation checkpoints + +### A — contracts, architecture, and oracles + +- correct the stale render-pipeline and per-frame architecture sections; +- add `RenderFrameInput`, phase interfaces, and a recording orchestrator; +- pin normal order, portal replacement, login wait, indoor/outdoor/null-root + branches, and screenshot placement; camera is an initialized runtime + invariant, so the slice does not invent a new graceful missing-camera path; +- pin the exact GPU begin/end exception matrix; +- add source guards against `GameWindow` back-references, mega contexts, and a + new PView delegate bag; +- capture the current connected framebuffer/resource baseline. + +### B — leaf presentation and diagnostics + +- extract paperdoll refresh/pose/dirty/FBO ownership; +- extract panel-layout reuse and devtools menu/panel rendering; +- extract pass-local render signature, PView/GL/scissor probes, and timing state + into `WorldRenderDiagnostics`; extract FPS, final counts, title/resource + publication, and the public render snapshot into + `RenderFrameDiagnosticsController`; +- preserve the pre-presentation render-derived DebugVM sampling site separately + from the post-screenshot title/resource publication site; leave non-render + DebugVM providers and cross-domain world-lifecycle sampling at composition; +- keep call sites in their current `GameWindow.OnRender` positions. + +### C — frame resources and live preparation + +- extract ordered per-resource `BeginFrame` calls, clear/state establishment, + histogram probe, mesh upload, reveal evaluation, and particle begin; +- extract weather accumulation and display foundations that do not consume the + camera/root classification, preserving their current render-time positions; +- consolidate requested-VSync/live-preview, monitor callbacks, and + `OnFrameRendered` pacing through `DisplayFramePacingController`, while + leaving its disposal in the existing shutdown transaction; +- prove `WbMeshAdapter.Tick` remains before reveal evaluation and drawing; +- prove dispatcher begin occurs once for the whole world/paperdoll frame. + +### D — world frame builder + +- move animated/equipped classification, camera/root selection, outdoor-node + building gather, and prior-visible-cell light scratch out of the window; +- after those facts exist, run focused audio/listener, live preview, sky PES, + sun/fog/point-light, and lighting-UBO preparation; do not build an interim + `GameWindow` mega-context; +- keep PView-generated particle-owner sets in `RetailPViewPassExecutor` and the + post-draw partition in the borrowed PView result/diagnostic snapshot; +- pin viewer-cell versus player-cell roles, portal replacement, login wait, + fallback/null-root, sealed/SeenOutside, and borrowed scratch reset; +- preserve allocation-free warmed frames and hysteretic scratch retention. + +### E — typed PView pass executor + +- replace `Action`/`Func` draw callbacks in `RetailPViewDrawContext`; +- move landscape early/late, scissor/clip, depth seal/punch, particle passes, + landscape alpha flush, and PView diagnostics to the executor; +- add a recording executor test for the complete `DrawInside` call sequence; +- verify no executor operation rebuilds portal visibility or retains a borrowed + frame result across calls. + +### F — world scene owner + +- extract alpha/selection/particle-visibility begin/end and the normal-world + conditional; +- extract sky/terrain/null-root fallback and unified PView branches; +- extract post-world particles/weather and debug collision drawing; +- return a small `WorldRenderFrameOutcome` for diagnostics/presentation; +- delete the world draw body and helper closure from `GameWindow`. + +### G — final orchestration cutover + +- compose concrete resource, world, private-presentation, and diagnostic owners; +- replace `OnRender` with one `RenderFrameOrchestrator.Render` handoff; +- remove obsolete fields/helpers/callbacks and prove the transitive render graph + has no substantial path back to `GameWindow`; +- preserve the existing shutdown resource list without transferring disposal; +- measure line/field/method counts. Ownership decides completion; the expected + signal is roughly 5,000–5,500 lines before Slice 8 composition cleanup. + +### H — release and connected gates + +After three clean corrected-diff reviews: + +1. run focused App/Core render, PView, alpha, portal, screenshot, and resource + tests; +2. run `dotnet build AcDream.slnx -c Release`; +3. run the complete Release suite; +4. run `tools/run-connected-world-lifecycle-gate.ps1 -SkipBuild`; +5. run `tools/run-connected-r6-soak.ps1 -SkipBuild`; +6. require code-zero graceful exits, exact lifecycle checkpoints/PNGs, no + fatal/invariant/reveal/pending-resource failures, and unchanged same-location + resource tolerances; +7. compare the six existing lifecycle/reconnect PNGs (`capped_login`, + `aerlinthe_first`, `facility_hub`, `holtburg_after_dungeon`, + `aerlinthe_revisit`, and `uncapped_reconnect`) with the Slice 6 baseline; +8. audit architecture, divergence pointers, roadmap, milestones, issues, + `AGENTS.md`, `CLAUDE.md`, and durable memory. + +The accepted baseline at docs-only commit `9512404e` is behavior-equivalent to +production cutover `e91f3102`: 7,182 passed / 5 skipped, 314.195-second +lifecycle/reconnect, and 393.581-second soak. Exact counters are recorded in +`memory/project_gamewindow_decomposition.md` and the Slice 6 closeout ledger. +Building/doorway poses, an open paperdoll, transient portal-exit visuals, and +#225 remain user visual gates; the stable post-materialization PNGs do not +overclaim them. + +## 6. Mandatory review gate + +After every implementation checkpoint, three read-only reviews run: + +1. **Retail conformance:** named anchors, PView/alpha/depth/private-viewport/UI + order, and registered adaptation boundaries. +2. **Architecture/integration:** focused owner scope, no replacement god object, + no service locator/back-callback, render-thread mutation, borrowed lifetime, + GL-state symmetry, and shutdown dependency direction. +3. **Adversarial:** begin/end failures, missing resources, portal/login + branches, stale borrowed scratch, callback reentrancy, frame spikes, + mid-frame resource churn, and GUID/session replacement. + +The primary agent fixes confirmed findings at their root, then requests +corrected-diff re-review until clean. Each checkpoint then runs focused tests, +Release build/full suite, divergence/doc audit, and lands as one commit. + +## 7. Final acceptance matrix + +### Automated + +- exact orchestrator order for normal, login, portal, indoor, outdoor, and + fallback frames, plus construction-time rejection of missing required phase + owners; +- exact GPU begin/end/aggregate failure behavior; +- PView landscape/look-in/flush/clear/seal/shell/static/dynamics ordering; +- screenshot after retained UI/ImGui, before title/resource/frame-profile + diagnostics, and before GPU `EndFrame`; +- dispatcher frame begin once across world and paperdoll scopes; +- frame scratch reset/reuse after skipped, portal, normal, and failed frames; +- no `GameWindow` reference or anonymous draw delegate bag in extracted owners; +- existing PView replay, shared-alpha, particle visibility, portal, paperdoll, + GPU-fence, screenshot, resource, and structural suites remain green; +- Release build and all tests green; +- connected lifecycle/reconnect and synchronized soak reports pass. + +### In-client campaign gate + +- outdoor dense scenes, terrain, sky, weather, particles, and selection match; +- buildings, doorway look-in/out, depth seals/punches, and dynamics stay stable; +- sealed dungeons keep correct visibility and lighting; +- portal replacement and destination reveal preserve the accepted flow; +- paperdoll, retained gameplay UI, cursor, and ImGui layering are unchanged; +- no resource growth, GPU reset, render exception, or shutdown regression. + +## 8. Fixed assumptions + +- all render/GL mutation remains on the existing render thread; +- `DatCollection` remains the only DAT reader; +- `LiveEntityRuntime`, `GpuWorldState`, cell visibility, session, and resource + owners remain canonical and are not copied; +- existing render resources are borrowed through Slice 7 and disposed by the + current shutdown transaction; +- frame-local results and scratch collections are borrowed until the next + frame/call unless explicitly copied; +- no quality/range/performance tradeoff is introduced to make the extraction + easier; +- missing optional diagnostic/UI owners skip only their own presentation and + never suppress world drawing or GPU frame closure. From 7e4cfb37c307155b74c3d03ab3596b0952b98f96 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 04:18:31 +0200 Subject: [PATCH 82/98] refactor(render): establish frame orchestration contract --- ...ndow-slice-7-render-frame-orchestration.md | 18 +- .../Rendering/GpuFrameFlightController.cs | 5 +- .../Rendering/RenderFrameOrchestrator.cs | 131 +++++++ .../Rendering/RenderFrameOrchestratorTests.cs | 340 ++++++++++++++++++ 4 files changed, 489 insertions(+), 5 deletions(-) create mode 100644 src/AcDream.App/Rendering/RenderFrameOrchestrator.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md index 135cf817..0a1e41dd 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md +++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md @@ -14,7 +14,7 @@ port, or resource-lifetime redesign. ## Progress ledger -- [ ] A — freeze the complete render graph, correct the architecture SSOT, and +- [x] A — freeze the complete render graph, correct the architecture SSOT, and introduce data-only frame contracts plus deterministic order/failure tests. - [ ] B — extract paperdoll, panel-layout/devtools, and render-diagnostics leaf owners while `GameWindow` still orders the frame. @@ -319,14 +319,24 @@ never choose a draw branch or mutate canonical world ownership. - correct the stale render-pipeline and per-frame architecture sections; - add `RenderFrameInput`, phase interfaces, and a recording orchestrator; -- pin normal order, portal replacement, login wait, indoor/outdoor/null-root - branches, and screenshot placement; camera is an initialized runtime - invariant, so the slice does not invent a new graceful missing-camera path; +- pin the outer resource/world/private/diagnostic order across normal, + portal-replacement, login-wait, and screenshot outcome shapes; defer the + concrete indoor/outdoor/null-root branch tests to the world builder and scene + owner in D/F; camera is an initialized runtime invariant, so the slice does + not invent a new graceful missing-camera path; - pin the exact GPU begin/end exception matrix; - add source guards against `GameWindow` back-references, mega contexts, and a new PView delegate bag; - capture the current connected framebuffer/resource baseline. +Completed 2026-07-22. `RenderFrameOrchestrator` now owns the inert five-owner +outer transaction contract; `GpuFrameFlightController` implements its narrow +lifetime seam. Twenty-six focused render/GPU tests pin value-only contracts, +required-owner construction, exact phase prefixes, per-phase input propagation, +and every begin/render/close failure combination. Release build and the full +suite pass (7,199 passed / 5 skipped after adding 17 tests). All three +corrected-diff reviews are clean; production `GameWindow.OnRender` is unchanged. + ### B — leaf presentation and diagnostics - extract paperdoll refresh/pose/dirty/FBO ownership; diff --git a/src/AcDream.App/Rendering/GpuFrameFlightController.cs b/src/AcDream.App/Rendering/GpuFrameFlightController.cs index 3090eb21..d2342d7d 100644 --- a/src/AcDream.App/Rendering/GpuFrameFlightController.cs +++ b/src/AcDream.App/Rendering/GpuFrameFlightController.cs @@ -214,7 +214,10 @@ internal sealed class ImmediateGpuResourceRetirementQueue : IGpuResourceRetireme } } -internal sealed class GpuFrameFlightController : IGpuResourceRetirementQueue, IDisposable +internal sealed class GpuFrameFlightController : + IGpuResourceRetirementQueue, + IRenderFrameLifetime, + IDisposable { internal const int DefaultMaximumFramesInFlight = 3; private const ulong WaitSliceNanoseconds = 1_000_000; diff --git a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs new file mode 100644 index 00000000..6aa79fb9 --- /dev/null +++ b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs @@ -0,0 +1,131 @@ +namespace AcDream.App.Rendering; + +/// +/// Host-owned values for one render callback. Render phases borrow this value +/// for the duration of only. +/// +internal readonly record struct RenderFrameInput( + double DeltaSeconds, + int ViewportWidth, + int ViewportHeight); + +/// +/// World facts published after the normal-world phase has either drawn or +/// intentionally skipped the viewport. +/// +internal readonly record struct WorldRenderFrameOutcome( + int VisibleLandblocks, + int TotalLandblocks, + bool NormalWorldDrawn); + +/// +/// Private-presentation facts published after portal, paperdoll, retained UI, +/// developer UI, and screenshot work has completed. +/// +internal readonly record struct PrivatePresentationFrameOutcome( + bool PortalViewportDrawn, + bool ScreenshotCaptured); + +/// The immutable result observed by post-screenshot diagnostics. +internal readonly record struct RenderFrameOutcome( + WorldRenderFrameOutcome World, + PrivatePresentationFrameOutcome Presentation); + +internal interface IRenderFrameLifetime +{ + void BeginFrame(); + + void EndFrame(); +} + +internal interface IRenderFrameResourcePhase +{ + void Prepare(RenderFrameInput input); +} + +internal interface IWorldSceneFramePhase +{ + WorldRenderFrameOutcome Render(RenderFrameInput input); +} + +internal interface IPrivatePresentationFramePhase +{ + PrivatePresentationFrameOutcome Render( + RenderFrameInput input, + WorldRenderFrameOutcome world); +} + +internal interface IRenderFrameDiagnosticsPhase +{ + void Publish(RenderFrameInput input, RenderFrameOutcome outcome); +} + +/// +/// Owns the accepted outer render transaction. Focused phase owners retain the +/// detailed PView, private-viewport, UI, and diagnostic order inside their +/// typed boundaries. +/// +/// +/// intentionally runs outside +/// the protected render body. Once begin succeeds, close is attempted exactly +/// once. This preserves the production GPU-flight contract: a render failure +/// and close failure are reported together, while either failure alone +/// propagates directly. +/// +internal sealed class RenderFrameOrchestrator +{ + private readonly IRenderFrameLifetime _lifetime; + private readonly IRenderFrameResourcePhase _resources; + private readonly IWorldSceneFramePhase _world; + private readonly IPrivatePresentationFramePhase _presentation; + private readonly IRenderFrameDiagnosticsPhase _diagnostics; + + public RenderFrameOrchestrator( + IRenderFrameLifetime lifetime, + IRenderFrameResourcePhase resources, + IWorldSceneFramePhase world, + IPrivatePresentationFramePhase presentation, + IRenderFrameDiagnosticsPhase diagnostics) + { + _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + _world = world ?? throw new ArgumentNullException(nameof(world)); + _presentation = presentation ?? throw new ArgumentNullException(nameof(presentation)); + _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + } + + public RenderFrameOutcome Render(RenderFrameInput input) + { + _lifetime.BeginFrame(); + Exception? renderFailure = null; + try + { + _resources.Prepare(input); + WorldRenderFrameOutcome world = _world.Render(input); + PrivatePresentationFrameOutcome presentation = + _presentation.Render(input, world); + var outcome = new RenderFrameOutcome(world, presentation); + _diagnostics.Publish(input, outcome); + return outcome; + } + catch (Exception error) + { + renderFailure = error; + throw; + } + finally + { + try + { + _lifetime.EndFrame(); + } + catch (Exception closeFailure) when (renderFailure is not null) + { + throw new AggregateException( + "Rendering failed and the in-flight GPU frame could not be closed.", + renderFailure, + closeFailure); + } + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs new file mode 100644 index 00000000..b132cadb --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs @@ -0,0 +1,340 @@ +using System.Reflection; +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RenderFrameOrchestratorTests +{ + private static readonly RenderFrameInput Input = new( + DeltaSeconds: 1.0 / 60.0, + ViewportWidth: 1920, + ViewportHeight: 1080); + + [Theory] + [InlineData(12, 40, true, false, false)] + [InlineData(0, 0, false, false, false)] + [InlineData(0, 0, false, true, false)] + [InlineData(4, 17, true, false, true)] + public void Render_PreservesOuterOrderAcrossWorldAndPresentationOutcomes( + int visibleLandblocks, + int totalLandblocks, + bool normalWorldDrawn, + bool portalViewportDrawn, + bool screenshotCaptured) + { + var calls = new List(); + var phases = new RecordingPhases(calls) + { + World = new WorldRenderFrameOutcome( + visibleLandblocks, + totalLandblocks, + normalWorldDrawn), + Presentation = new PrivatePresentationFrameOutcome( + portalViewportDrawn, + screenshotCaptured), + }; + var orchestrator = Create(phases); + + RenderFrameOutcome outcome = orchestrator.Render(Input); + + Assert.Equal( + ["gpu-begin", "resources", "world", "presentation", "diagnostics", "gpu-end"], + calls); + Assert.Equal(phases.World, outcome.World); + Assert.Equal(phases.Presentation, outcome.Presentation); + Assert.Equal( + [ + ("resources", Input), + ("world", Input), + ("presentation", Input), + ("diagnostics", Input), + ], + phases.ObservedInputs); + Assert.Equal(phases.World, phases.ObservedWorld); + Assert.Equal(outcome, phases.ObservedOutcome); + } + + [Fact] + public void BeginFailure_DoesNotAttemptAnyPhaseOrClose() + { + var calls = new List(); + var expected = new InvalidOperationException("begin"); + var phases = new RecordingPhases(calls) + { + FailurePoint = "gpu-begin", + Failure = expected, + }; + + InvalidOperationException actual = Assert.Throws( + () => Create(phases).Render(Input)); + + Assert.Same(expected, actual); + Assert.Equal(["gpu-begin"], calls); + } + + [Theory] + [InlineData("resources")] + [InlineData("world")] + [InlineData("presentation")] + [InlineData("diagnostics")] + public void RenderFailure_ClosesExactlyOnceAndPropagates(string failurePoint) + { + var calls = new List(); + var expected = new InvalidOperationException(failurePoint); + var phases = new RecordingPhases(calls) + { + FailurePoint = failurePoint, + Failure = expected, + }; + + InvalidOperationException actual = Assert.Throws( + () => Create(phases).Render(Input)); + + Assert.Same(expected, actual); + Assert.Equal(ExpectedFailureCalls(failurePoint), calls); + Assert.Equal( + ExpectedPhaseInputs(failurePoint), + phases.ObservedInputs); + } + + [Fact] + public void CloseOnlyFailure_PropagatesDirectly() + { + var calls = new List(); + var expected = new InvalidOperationException("close"); + var phases = new RecordingPhases(calls) + { + CloseFailure = expected, + }; + + InvalidOperationException actual = Assert.Throws( + () => Create(phases).Render(Input)); + + Assert.Same(expected, actual); + Assert.Equal( + ["gpu-begin", "resources", "world", "presentation", "diagnostics", "gpu-end"], + calls); + } + + [Theory] + [InlineData("resources")] + [InlineData("world")] + [InlineData("presentation")] + [InlineData("diagnostics")] + public void RenderAndCloseFailure_AreAggregatedInCausalOrder(string failurePoint) + { + var calls = new List(); + var renderFailure = new InvalidOperationException("render"); + var closeFailure = new InvalidOperationException("close"); + var phases = new RecordingPhases(calls) + { + FailurePoint = failurePoint, + Failure = renderFailure, + CloseFailure = closeFailure, + }; + + AggregateException actual = Assert.Throws( + () => Create(phases).Render(Input)); + + Assert.Equal( + "Rendering failed and the in-flight GPU frame could not be closed.", + actual.Message.Split(" (")[0]); + Assert.Equal(2, actual.InnerExceptions.Count); + Assert.Same(renderFailure, actual.InnerExceptions[0]); + Assert.Same(closeFailure, actual.InnerExceptions[1]); + Assert.Equal(ExpectedFailureCalls(failurePoint), calls); + Assert.Equal( + ExpectedPhaseInputs(failurePoint), + phases.ObservedInputs); + } + + [Fact] + public void Constructor_RejectsEveryMissingRequiredOwner() + { + var phases = new RecordingPhases([]); + + Assert.Throws(() => new RenderFrameOrchestrator( + null!, phases, phases, phases, phases)); + Assert.Throws(() => new RenderFrameOrchestrator( + phases, null!, phases, phases, phases)); + Assert.Throws(() => new RenderFrameOrchestrator( + phases, phases, null!, phases, phases)); + Assert.Throws(() => new RenderFrameOrchestrator( + phases, phases, phases, null!, phases)); + Assert.Throws(() => new RenderFrameOrchestrator( + phases, phases, phases, phases, null!)); + } + + [Fact] + public void Orchestrator_UsesOnlyTheExplicitTypedOwnerGraph() + { + Type[] expectedFieldTypes = + [ + typeof(IRenderFrameLifetime), + typeof(IRenderFrameResourcePhase), + typeof(IWorldSceneFramePhase), + typeof(IPrivatePresentationFramePhase), + typeof(IRenderFrameDiagnosticsPhase), + ]; + FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.Equal( + expectedFieldTypes.OrderBy(type => type.FullName), + fields.Select(field => field.FieldType).OrderBy(type => type.FullName)); + Assert.All(fields, field => + { + Assert.NotEqual(typeof(GameWindow), field.FieldType); + Assert.False(typeof(Delegate).IsAssignableFrom(field.FieldType)); + }); + Assert.All( + expectedFieldTypes, + contract => Assert.False(contract.IsAssignableFrom(typeof(GameWindow)))); + foreach (Type contract in expectedFieldTypes) + { + foreach (MethodInfo method in contract.GetMethods()) + { + Assert.True( + method.ReturnType == typeof(void) || method.ReturnType.IsValueType, + $"{contract.Name}.{method.Name} returned owner-like type " + + method.ReturnType.FullName); + Assert.All( + method.GetParameters(), + parameter => Assert.True( + parameter.ParameterType.IsValueType, + $"{contract.Name}.{method.Name} accepted owner-like type " + + parameter.ParameterType.FullName)); + } + } + Assert.True(typeof(IRenderFrameLifetime).IsAssignableFrom( + typeof(GpuFrameFlightController))); + } + + [Fact] + public void FrameContracts_AreDataOnlyValuesWithoutOwnerOrDelegateReferences() + { + Type[] contracts = + [ + typeof(RenderFrameInput), + typeof(WorldRenderFrameOutcome), + typeof(PrivatePresentationFrameOutcome), + typeof(RenderFrameOutcome), + ]; + + foreach (Type contract in contracts) + { + Assert.True(contract.IsValueType); + Assert.All( + contract.GetFields( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic), + field => + { + Assert.True( + field.FieldType.IsValueType, + $"{contract.Name}.{field.Name} retained owner-like type " + + field.FieldType.FullName); + }); + } + } + + private static string[] ExpectedFailureCalls(string failurePoint) => failurePoint switch + { + "resources" => ["gpu-begin", "resources", "gpu-end"], + "world" => ["gpu-begin", "resources", "world", "gpu-end"], + "presentation" => + ["gpu-begin", "resources", "world", "presentation", "gpu-end"], + "diagnostics" => + [ + "gpu-begin", "resources", "world", "presentation", "diagnostics", + "gpu-end", + ], + _ => throw new ArgumentOutOfRangeException(nameof(failurePoint)), + }; + + private static (string Phase, RenderFrameInput Input)[] ExpectedPhaseInputs( + string failurePoint) + { + string[] phases = failurePoint switch + { + "resources" => ["resources"], + "world" => ["resources", "world"], + "presentation" => ["resources", "world", "presentation"], + "diagnostics" => ["resources", "world", "presentation", "diagnostics"], + _ => throw new ArgumentOutOfRangeException(nameof(failurePoint)), + }; + return phases.Select(phase => (phase, Input)).ToArray(); + } + + private static RenderFrameOrchestrator Create(RecordingPhases phases) => + new(phases, phases, phases, phases, phases); + + private sealed class RecordingPhases : + IRenderFrameLifetime, + IRenderFrameResourcePhase, + IWorldSceneFramePhase, + IPrivatePresentationFramePhase, + IRenderFrameDiagnosticsPhase + { + private readonly List _calls; + + public RecordingPhases(List calls) + { + _calls = calls; + } + + public string? FailurePoint { get; init; } + public Exception? Failure { get; init; } + public Exception? CloseFailure { get; init; } + public WorldRenderFrameOutcome World { get; init; } = new(7, 19, true); + public PrivatePresentationFrameOutcome Presentation { get; init; } = new(false, false); + public List<(string Phase, RenderFrameInput Input)> ObservedInputs { get; } = []; + public WorldRenderFrameOutcome ObservedWorld { get; private set; } + public RenderFrameOutcome ObservedOutcome { get; private set; } + + public void BeginFrame() => Record("gpu-begin"); + + public void EndFrame() + { + _calls.Add("gpu-end"); + if (CloseFailure is not null) + throw CloseFailure; + } + + public void Prepare(RenderFrameInput input) + { + ObservedInputs.Add(("resources", input)); + Record("resources"); + } + + public WorldRenderFrameOutcome Render(RenderFrameInput input) + { + ObservedInputs.Add(("world", input)); + Record("world"); + return World; + } + + public PrivatePresentationFrameOutcome Render( + RenderFrameInput input, + WorldRenderFrameOutcome world) + { + ObservedInputs.Add(("presentation", input)); + ObservedWorld = world; + Record("presentation"); + return Presentation; + } + + public void Publish(RenderFrameInput input, RenderFrameOutcome outcome) + { + ObservedInputs.Add(("diagnostics", input)); + ObservedOutcome = outcome; + Record("diagnostics"); + } + + private void Record(string call) + { + _calls.Add(call); + if (FailurePoint == call && Failure is not null) + throw Failure; + } + } +} From 733126a2720d47cbf74177dadf70f7f7c939b890 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 05:02:31 +0200 Subject: [PATCH 83/98] refactor(render): extract frame presentation diagnostics --- .../retail-divergence-register.md | 4 +- ...ndow-slice-7-render-frame-orchestration.md | 17 +- .../Diagnostics/FrameScreenshotController.cs | 6 +- .../Rendering/DebugVmRenderFactsPublisher.cs | 79 ++ .../Rendering/DevToolsFramePresenter.cs | 387 +++++ src/AcDream.App/Rendering/GameWindow.cs | 1258 ++++------------- .../Rendering/PaperdollFramePresenter.cs | 309 ++++ .../Rendering/PaperdollViewportRenderer.cs | 17 +- .../Rendering/RenderFrameDiagnosticSources.cs | 168 +++ .../RenderFrameDiagnosticsController.cs | 269 ++++ .../Rendering/RollingTimingSampleWindow.cs | 54 + .../Rendering/WorldRenderDiagnostics.cs | 593 ++++++++ ...WorldLifecycleAutomationControllerTests.cs | 25 +- .../DebugVmRenderFactsPublisherTests.cs | 167 +++ .../Rendering/DevToolsFramePresenterTests.cs | 333 +++++ .../GameWindowRenderLeafCompositionTests.cs | 167 +++ .../Rendering/PaperdollFramePresenterTests.cs | 256 ++++ .../RenderFrameDiagnosticsControllerTests.cs | 321 +++++ .../RollingTimingSampleWindowTests.cs | 66 + .../Rendering/WorldRenderDiagnosticsTests.cs | 292 ++++ 20 files changed, 3767 insertions(+), 1021 deletions(-) create mode 100644 src/AcDream.App/Rendering/DebugVmRenderFactsPublisher.cs create mode 100644 src/AcDream.App/Rendering/DevToolsFramePresenter.cs create mode 100644 src/AcDream.App/Rendering/PaperdollFramePresenter.cs create mode 100644 src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs create mode 100644 src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs create mode 100644 src/AcDream.App/Rendering/RollingTimingSampleWindow.cs create mode 100644 src/AcDream.App/Rendering/WorldRenderDiagnostics.cs create mode 100644 tests/AcDream.App.Tests/Rendering/DebugVmRenderFactsPublisherTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RenderFrameDiagnosticsControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RollingTimingSampleWindowTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 516f9f12..e9b658d1 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -191,8 +191,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) | | AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) | | AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 | -| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` | -| AP-93 | Paperdoll does not port `UpdateForRace`; all characters use the cdb-confirmed default held pose `0x030003C0` and default presentation | `src/AcDream.App/Rendering/DollEntityBuilder.cs`; paperdoll mount wiring | Exact for the tested Horan character; unresolved for other heritage/gender/body combinations | Non-default races can use the wrong pose, heading, camera framing, or presentation asset | `gmPaperDollUI::UpdateForRace @ 0x004A3ED0` | +| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollFramePresenter.cs`; `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` | +| AP-93 | Paperdoll does not port `UpdateForRace`; all characters use the cdb-confirmed default held pose `0x030003C0` and default presentation | `src/AcDream.App/Rendering/DollEntityBuilder.cs`; `RetailPaperdollPoseApplicator` in `src/AcDream.App/Rendering/PaperdollFramePresenter.cs` | Exact for the tested Horan character; unresolved for other heritage/gender/body combinations | Non-default races can use the wrong pose, heading, camera framing, or presentation asset | `gmPaperDollUI::UpdateForRace @ 0x004A3ED0` | | AP-94 | Imported Type-12 text defaults to interactive/selectable behavior; display labels can focus/capture/drag, and vitals synthesize duplicate runtime labels instead of binding `0x100000EB/ED/EF` | `src/AcDream.App/UI/UiText.cs`; `src/AcDream.App/UI/Layout/VitalsController.cs` | Historical widget-generalization default; Wave 1 ports explicit Display/Selectable/Editable roles | Invisible/static text steals input and duplicate labels drift from DAT geometry | `UIElement_Text` property handlers; `gmVitalsUI::PostInit @ 0x004BFCE0` | | ~~AP-95~~ | **RETIRED 2026-07-11** — `UiButton` owns retail hover/pressed/released-outside, disabled, selected/toggle, missing-state fallback, hot-click, and distinct press/release controller callbacks. | `src/AcDream.App/UI/UiButton.cs`; `UiButtonStateMachine.cs` | — | — | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` | | AP-96 | Layout/property import loses raw edge-mode semantics (especially 3/4) and treats default scalar values as absent, so explicit `false`/`0` cannot override inheritance | `src/AcDream.App/UI/Layout/ElementReader.cs`; `LayoutImporter.cs` | Existing shipped layouts are hand-gated; Wave 1 adds presence-aware properties and the exact solver | Non-reference resizing recenters/stretches incorrectly; derived DAT properties silently inherit the wrong base value | `UIElement::UpdateForParentSizeChange @ 0x00462640`; production LayoutDesc inheritance | diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md index 0a1e41dd..ac50d0e7 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md +++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md @@ -16,7 +16,7 @@ port, or resource-lifetime redesign. - [x] A — freeze the complete render graph, correct the architecture SSOT, and introduce data-only frame contracts plus deterministic order/failure tests. -- [ ] B — extract paperdoll, panel-layout/devtools, and render-diagnostics leaf +- [x] B — extract paperdoll, panel-layout/devtools, and render-diagnostics leaf owners while `GameWindow` still orders the frame. - [ ] C — extract frame-resource begin/upload/publication and live display, and weather/display foundations that do not depend on camera/root @@ -350,6 +350,21 @@ corrected-diff reviews are clean; production `GameWindow.OnRender` is unchanged. DebugVM providers and cross-domain world-lifecycle sampling at composition; - keep call sites in their current `GameWindow.OnRender` positions. +Completed 2026-07-22. Paperdoll clone/re-dress/pose/FBO work now belongs to +`PaperdollFramePresenter`; retained surfaces are narrow and optional, while +live identity and DAT pose application resolve through focused adapters. +`DevToolsFramePresenter` owns the ImGui frame, menus, panels, layout, and input +actions without taking shutdown ownership of the borrowed bootstrap. +`WorldRenderDiagnostics`, `DebugVmRenderFactsPublisher`, and +`RenderFrameDiagnosticsController` own the former render probes, debug facts, +title/resource cadence, and public snapshot. The production handoff now reports +the observed login/world and screenshot outcomes, preserves the original shared +TERRAIN→FRAME diagnostic transaction, and keeps the devtools-off scan path +allocation-free. `GameWindow.cs` is 6,270 raw lines, 60.1% below the 15,723-line +campaign baseline. Sixty-two focused tests, the Release build, and the complete +suite pass (7,255 passed / 5 skipped). Retail, architecture, and adversarial +corrected-diff reviews are clean. + ### C — frame resources and live preparation - extract ordered per-resource `BeginFrame` calls, clear/state establishment, diff --git a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs index e1f4fcc5..8a050504 100644 --- a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs +++ b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs @@ -76,10 +76,10 @@ internal sealed class FrameScreenshotController && status.State == CaptureState.Complete; /// Captures at most one queued image on the current GL thread. - public void CapturePending() + public bool CapturePending() { if (_pending.Count == 0) - return; + return false; string name = _pending.Dequeue(); try @@ -104,12 +104,14 @@ internal sealed class FrameScreenshotController _status[name] = new CaptureStatus(CaptureState.Complete); _log($"[world-gate] screenshot-complete name={name} path={path} size={width}x{height}"); + return true; } catch (Exception exception) { string message = $"screenshot '{name}' failed: {exception.Message}"; _status[name] = new CaptureStatus(CaptureState.Failed, message); _log($"[world-gate] screenshot-failed name={name} error={exception.Message}"); + return false; } } diff --git a/src/AcDream.App/Rendering/DebugVmRenderFactsPublisher.cs b/src/AcDream.App/Rendering/DebugVmRenderFactsPublisher.cs new file mode 100644 index 00000000..e111c26b --- /dev/null +++ b/src/AcDream.App/Rendering/DebugVmRenderFactsPublisher.cs @@ -0,0 +1,79 @@ +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.App.Rendering; + +/// Same-frame render facts consumed by the developer DebugVM. +internal readonly record struct DebugVmRenderFacts( + int VisibleLandblocks, + int TotalLandblocks, + float NearestObjectDistance, + string NearestObjectLabel, + bool Colliding) +{ + public static DebugVmRenderFacts Initial => new( + VisibleLandblocks: 0, + TotalLandblocks: 0, + NearestObjectDistance: float.PositiveInfinity, + NearestObjectLabel: "-", + Colliding: false); +} + +internal interface IDebugVmRenderFactsSource +{ + DebugVmRenderFacts DebugVmFacts { get; } +} + +/// +/// Owns render-derived DebugVM facts without retaining the physics registry, +/// camera, player controller, world state, or any other canonical runtime owner. +/// The pass-local diagnostics owner publishes through this focused query surface. +/// +internal sealed class DebugVmRenderFactsPublisher : IDebugVmRenderFactsSource +{ + internal const float PlayerCollisionRadius = 0.48f; + internal const float ContactThreshold = 0.05f; + + public DebugVmRenderFacts DebugVmFacts { get; private set; } = + DebugVmRenderFacts.Initial; + + /// + /// Publishes the facts immediately before private presentation. A disabled + /// consumer leaves the prior snapshot untouched and does not enumerate the + /// borrowed shadow-object sequence. + /// + public void PublishDebugVmFacts( + bool consumerActive, + int visibleLandblocks, + int totalLandblocks, + Vector3 nearestOrigin, + IEnumerable? shadowObjects) + { + if (!consumerActive) + return; + ArgumentNullException.ThrowIfNull(shadowObjects); + + float nearestDistance = float.PositiveInfinity; + string nearestLabel = "-"; + foreach (ShadowEntry shadow in shadowObjects) + { + float dx = shadow.Position.X - nearestOrigin.X; + float dy = shadow.Position.Y - nearestOrigin.Y; + float distance = MathF.Sqrt(dx * dx + dy * dy) + - shadow.Radius + - PlayerCollisionRadius; + if (distance < nearestDistance) + { + nearestDistance = distance; + nearestLabel = $"0x{shadow.EntityId:X8} {shadow.CollisionType}"; + } + } + + DebugVmFacts = new DebugVmRenderFacts( + visibleLandblocks, + totalLandblocks, + nearestDistance < 0f ? 0f : nearestDistance, + nearestLabel, + nearestDistance < ContactThreshold); + } +} diff --git a/src/AcDream.App/Rendering/DevToolsFramePresenter.cs b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs new file mode 100644 index 00000000..d1bb4143 --- /dev/null +++ b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs @@ -0,0 +1,387 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; +using AcDream.UI.Abstractions.Panels.Debug; +using AcDream.UI.Abstractions.Panels.Settings; +using AcDream.UI.Abstractions.Panels.Vitals; +using AcDream.UI.ImGui; +using ImGuiNET; + +namespace AcDream.App.Rendering; + +internal enum DevToolsPanelLayoutCondition +{ + FirstUseEver, + Always, +} + +internal enum DevToolsPanelKind +{ + Vitals, + Chat, + Debug, + Settings, +} + +internal interface IDevToolsFrameBackend +{ + void BeginFrame(float deltaSeconds); + + bool BeginMainMenuBar(); + + void EndMainMenuBar(); + + bool BeginMenu(string label); + + void EndMenu(); + + bool MenuItem(string label, string? shortcut = null, bool selected = false); + + void Separator(); + + void RenderPanels(PanelContext context); + + void RenderDrawData(); + + void SetWindowLayout( + string title, + Vector2 position, + Vector2 size, + DevToolsPanelLayoutCondition condition); +} + +internal interface IDevToolsCameraMenuOperations +{ + bool IsFlyMode { get; } + + bool CollideCamera { get; set; } + + void ToggleFlyOrChase(); +} + +internal interface IDevToolsCommandBusSource +{ + ICommandBus Current { get; } +} + +internal interface IDevToolsPanelSet +{ + bool Contains(DevToolsPanelKind kind); + + string? GetTitle(DevToolsPanelKind kind); + + bool IsVisible(DevToolsPanelKind kind); + + void Toggle(DevToolsPanelKind kind); + + void FocusChatInput(); + + void AddDebugToast(string message); +} + +/// Concrete ImGui backend for the developer presentation owner. +internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend +{ + private readonly ImGuiBootstrapper _bootstrap; + private readonly ImGuiPanelHost _panels; + + public ImGuiDevToolsFrameBackend( + ImGuiBootstrapper bootstrap, + ImGuiPanelHost panels) + { + _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap)); + _panels = panels ?? throw new ArgumentNullException(nameof(panels)); + } + + public void BeginFrame(float deltaSeconds) => _bootstrap.BeginFrame(deltaSeconds); + + public bool BeginMainMenuBar() => ImGuiNET.ImGui.BeginMainMenuBar(); + + public void EndMainMenuBar() => ImGuiNET.ImGui.EndMainMenuBar(); + + public bool BeginMenu(string label) => ImGuiNET.ImGui.BeginMenu(label); + + public void EndMenu() => ImGuiNET.ImGui.EndMenu(); + + public bool MenuItem(string label, string? shortcut = null, bool selected = false) => + ImGuiNET.ImGui.MenuItem(label, shortcut ?? string.Empty, selected); + + public void Separator() => ImGuiNET.ImGui.Separator(); + + public void RenderPanels(PanelContext context) => _panels.RenderAll(context); + + public void RenderDrawData() => _bootstrap.Render(); + + public void SetWindowLayout( + string title, + Vector2 position, + Vector2 size, + DevToolsPanelLayoutCondition condition) + { + ImGuiCond imguiCondition = condition switch + { + DevToolsPanelLayoutCondition.FirstUseEver => ImGuiCond.FirstUseEver, + DevToolsPanelLayoutCondition.Always => ImGuiCond.Always, + _ => throw new ArgumentOutOfRangeException(nameof(condition)), + }; + ImGuiNET.ImGui.SetWindowPos(title, position, imguiCondition); + ImGuiNET.ImGui.SetWindowSize(title, size, imguiCondition); + } + +} + +/// Mutable late-composition seam for player-mode menu operations. +internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperations +{ + private readonly CameraController _camera; + private PlayerModeController? _playerMode; + + public DevToolsCameraMenuOperations(CameraController camera) + { + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + } + + public bool IsFlyMode => _camera.IsFlyMode; + + public bool CollideCamera + { + get => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera; + set => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera = value; + } + + public void Bind(PlayerModeController playerMode) + { + _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); + } + + public void ToggleFlyOrChase() => _playerMode?.ToggleFlyOrChase(); +} + +/// Resolves the reconnect-safe command bus at draw time. +internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource +{ + private LiveSessionController? _session; + + public ICommandBus Current => + _session?.Commands ?? NullCommandBus.Instance; + + public void Bind(LiveSessionController session) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + } +} + +/// Typed panel operations used by menu and input presentation. +internal sealed class DevToolsPanelSet : IDevToolsPanelSet +{ + private readonly VitalsPanel _vitals; + private readonly ChatPanel _chat; + private readonly DebugPanel _debug; + private readonly DebugVM _debugViewModel; + private readonly SettingsPanel? _settings; + + public DevToolsPanelSet( + VitalsPanel vitals, + ChatPanel chat, + DebugPanel debug, + DebugVM debugViewModel, + SettingsPanel? settings) + { + _vitals = vitals ?? throw new ArgumentNullException(nameof(vitals)); + _chat = chat ?? throw new ArgumentNullException(nameof(chat)); + _debug = debug ?? throw new ArgumentNullException(nameof(debug)); + _debugViewModel = debugViewModel + ?? throw new ArgumentNullException(nameof(debugViewModel)); + _settings = settings; + } + + public bool Contains(DevToolsPanelKind kind) => + kind != DevToolsPanelKind.Settings || _settings is not null; + + public string? GetTitle(DevToolsPanelKind kind) => GetPanel(kind)?.Title; + + public bool IsVisible(DevToolsPanelKind kind) => GetPanel(kind)?.IsVisible == true; + + public void Toggle(DevToolsPanelKind kind) + { + if (GetPanel(kind) is { } panel) + panel.IsVisible = !panel.IsVisible; + } + + public void FocusChatInput() => _chat.FocusInput(); + + public void AddDebugToast(string message) => _debugViewModel.AddToast(message); + + private IPanel? GetPanel(DevToolsPanelKind kind) => kind switch + { + DevToolsPanelKind.Vitals => _vitals, + DevToolsPanelKind.Chat => _chat, + DevToolsPanelKind.Debug => _debug, + DevToolsPanelKind.Settings => _settings, + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; +} + +/// +/// Owns the optional ImGui developer frame, menu policy, panel actions, and +/// reusable default layout. Retained gameplay UI remains a separate earlier +/// presentation phase. +/// +internal sealed class DevToolsFramePresenter +{ + private readonly IDevToolsFrameBackend _backend; + private readonly IDevToolsCameraMenuOperations _camera; + private readonly IDevToolsCommandBusSource _commands; + private readonly IDevToolsPanelSet _panels; + private readonly AcDream.App.Diagnostics.FrameProfiler _profiler; + + public DevToolsFramePresenter( + IDevToolsFrameBackend backend, + IDevToolsCameraMenuOperations camera, + IDevToolsCommandBusSource commands, + AcDream.App.Diagnostics.FrameProfiler profiler, + IDevToolsPanelSet panels) + { + _backend = backend ?? throw new ArgumentNullException(nameof(backend)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _commands = commands ?? throw new ArgumentNullException(nameof(commands)); + _profiler = profiler ?? throw new ArgumentNullException(nameof(profiler)); + _panels = panels ?? throw new ArgumentNullException(nameof(panels)); + } + + public void BeginFrame(float deltaSeconds) => _backend.BeginFrame(deltaSeconds); + + public void Render(double deltaSeconds, int viewportWidth, int viewportHeight) + { + var context = new PanelContext((float)deltaSeconds, _commands.Current); + DrawMenu(viewportWidth, viewportHeight); + _backend.RenderPanels(context); + using var stage = _profiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui); + _backend.RenderDrawData(); + } + + public void ToggleDebugPanel() + { + _panels.Toggle(DevToolsPanelKind.Debug); + _panels.AddDebugToast( + $"Debug panel {(_panels.IsVisible(DevToolsPanelKind.Debug) ? "ON" : "OFF")}"); + } + + public void FocusChatInput() => _panels.FocusChatInput(); + + public void ToggleSettingsPanel() + { + _panels.Toggle(DevToolsPanelKind.Settings); + } + + public void ResetLayout( + int viewportWidth, + int viewportHeight, + DevToolsPanelLayoutCondition condition) + { + float width = Math.Max(viewportWidth, 480); + float height = Math.Max(viewportHeight, 320); + + SetLayout( + DevToolsPanelKind.Vitals, + new Vector2(10f, 30f), + new Vector2(220f, 110f), + condition); + SetLayout( + DevToolsPanelKind.Chat, + new Vector2(10f, height - 320f), + new Vector2(450f, 300f), + condition); + SetLayout( + DevToolsPanelKind.Debug, + new Vector2(width - 380f, 30f), + new Vector2(370f, 520f), + condition); + if (_panels.Contains(DevToolsPanelKind.Settings)) + { + SetLayout( + DevToolsPanelKind.Settings, + new Vector2((width - 700f) * 0.5f, (height - 500f) * 0.5f), + new Vector2(700f, 500f), + condition); + } + } + + private void DrawMenu(int viewportWidth, int viewportHeight) + { + if (!_backend.BeginMainMenuBar()) + return; + + try + { + if (_backend.BeginMenu("View")) + { + try + { + if (_panels.Contains(DevToolsPanelKind.Settings) + && _backend.MenuItem("Settings", "F11")) + { + _panels.Toggle(DevToolsPanelKind.Settings); + } + if (_backend.MenuItem("Vitals")) + _panels.Toggle(DevToolsPanelKind.Vitals); + if (_backend.MenuItem("Chat")) + _panels.Toggle(DevToolsPanelKind.Chat); + if (_backend.MenuItem("Debug", "Ctrl+F1")) + _panels.Toggle(DevToolsPanelKind.Debug); + _backend.Separator(); + if (_backend.MenuItem("Reset window layout")) + ResetLayout( + viewportWidth, + viewportHeight, + DevToolsPanelLayoutCondition.Always); + } + finally + { + _backend.EndMenu(); + } + } + + if (_backend.BeginMenu("Camera")) + { + try + { + string flyLabel = _camera.IsFlyMode + ? "Exit Free-Fly Mode" + : "Enter Free-Fly Mode"; + if (_backend.MenuItem(flyLabel, "Ctrl+Shift+F")) + _camera.ToggleFlyOrChase(); + + bool collide = _camera.CollideCamera; + if (_backend.MenuItem( + "Collide Camera (spring arm)", + selected: collide)) + { + _camera.CollideCamera = !collide; + } + } + finally + { + _backend.EndMenu(); + } + } + } + finally + { + _backend.EndMainMenuBar(); + } + } + + private void SetLayout( + DevToolsPanelKind panel, + Vector2 position, + Vector2 size, + DevToolsPanelLayoutCondition condition) + { + string? title = _panels.GetTitle(panel); + if (!string.IsNullOrEmpty(title)) + _backend.SetWindowLayout(title, position, size, condition); + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 87a119c0..654acb7d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -78,30 +78,6 @@ public sealed class GameWindow : IDisposable // scene. They are no longer used for any debug overlay. private TextRenderer? _textRenderer; private BitmapFont? _debugFont; - // Last-computed perf values so the HUD always has something to show even - // though the title-bar FPS is only updated every 0.5s. - private double _lastFps = 60.0; - private double _lastFrameMs = 16.7; - - // Phase I.2: per-frame counters surfaced through the ImGui DebugPanel - // VM closures. Computed once per render pass alongside the frustum - // walk + nearest-object scan; the VM closures just read the cached - // values. Skipped when DevTools are off (zero cost). - private int _lastVisibleLandblocks; - private int _lastTotalLandblocks; - private float _lastNearestObjDist = float.PositiveInfinity; - private string _lastNearestObjLabel = "-"; - private bool _lastColliding; - - // Phase N.5b: CPU timing for [TERRAIN-DIAG] under ACDREAM_WB_DIAG=1 - // (parallel diagnostic to [WB-DIAG] in WbDrawDispatcher — same env var - // gate so flipping one switch turns on both dispatcher rollups). Mirrors - // the rolling-256-sample buffer pattern from WbDrawDispatcher. - private readonly System.Diagnostics.Stopwatch _terrainCpuStopwatch = new(); - private readonly long[] _terrainCpuSamples = new long[256]; // microseconds - private int _terrainCpuSampleCursor; - private long _terrainLastDiagTick; - // [FRAME-DIAG]: retain only the render-thread entity upload distribution. // Landblock publication timing/counts live with the focused publishers and // are read from their typed snapshots when diagnostics flush. @@ -109,13 +85,22 @@ public sealed class GameWindow : IDisposable System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", System.StringComparison.Ordinal); - private readonly long[] _entityUploadSamples = new long[256]; - private int _entityUploadSampleCursor; + private readonly AcDream.App.Rendering.RollingTimingSampleWindow + _entityUploadTiming = new(256); + private long _frameDiagLastPublicationMilliseconds; // MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call // per OnRender + three stage scopes. All logic lives in // AcDream.App.Diagnostics.FrameProfiler (structure rule 1). private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new(); + private readonly AcDream.App.Rendering.IRenderFrameDiagnosticLog + _renderDiagnosticLog = + new AcDream.App.Rendering.ConsoleRenderFrameDiagnosticLog(); + private readonly AcDream.App.Rendering.DebugVmRenderFactsPublisher + _debugVmRenderFacts = new(); + private AcDream.App.Rendering.WorldRenderDiagnostics? _worldRenderDiagnostics; + private AcDream.App.Rendering.RenderFrameDiagnosticsController? + _renderFrameDiagnostics; private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; @@ -249,9 +234,6 @@ public sealed class GameWindow : IDisposable private readonly HashSet _outdoorSceneParticleEntityIds = new(); private readonly HashSet _visibleSceneParticleEntityIds = new(); private readonly HashSet _noSceneParticleEntityIds = new(); - private string? _lastRenderSignature; - private int _renderSignatureFrame; - private int _renderSignatureStableFrames; /// /// Phase 6.4: per-entity animation playback state for entities whose @@ -440,8 +422,10 @@ public sealed class GameWindow : IDisposable // Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1. // See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy. - private AcDream.UI.ImGui.ImGuiBootstrapper? _imguiBootstrap; - private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost; + private AcDream.App.Rendering.ImGuiDevToolsFrameBackend? _devToolsBackend; + private AcDream.App.Rendering.DevToolsFramePresenter? _devToolsFramePresenter; + private AcDream.App.Rendering.DevToolsCameraMenuOperations? _devToolsCameraMenu; + private AcDream.App.Rendering.DevToolsCommandBusSource? _devToolsCommandBus; private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm; private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm; // Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1. @@ -462,12 +446,12 @@ public sealed class GameWindow : IDisposable // Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport // widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625). private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer; - private bool _paperdollDollDirty = true; + private AcDream.App.Rendering.PaperdollFramePresenter? _paperdollFramePresenter; // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; - // Phase I.2: ImGui debug panel ViewModel. Lives for as long as - // _panelHost does. Self-subscribes to CombatState in its ctor, so - // disposing isn't required (panel host holds the only ref). + // Phase I.2: ImGui debug panel ViewModel. The devtools presenter owns + // its panel; the VM remains here because runtime feedback producers bind + // directly to it during composition. private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm; // DevToolsEnabled reads through typed RuntimeOptions. private bool DevToolsEnabled => _options.DevTools; @@ -833,6 +817,9 @@ public sealed class GameWindow : IDisposable _gl = GL.GetApi(_window!); _gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl); + _worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics( + new AcDream.App.Rendering.SilkRenderGlStateReader(_gl), + _renderDiagnosticLog); _input = _window!.CreateInput(); // Phase K.1b — every keyboard/mouse handler routes through the @@ -877,8 +864,7 @@ public sealed class GameWindow : IDisposable // K.1b §E: explicit WantCaptureMouse defense-in-depth on the // surviving direct-mouse handler. Suppresses RMB orbit / // FlyCamera look while ImGui has the mouse focus. - if ((DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) - || (_uiHost?.Root.WantsMouse ?? false)) + if (_inputCapture.WantCaptureMouse) { _lastMouseX = pos.X; _lastMouseY = pos.Y; @@ -1110,10 +1096,12 @@ public sealed class GameWindow : IDisposable // See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md. if (DevToolsEnabled) { + AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null; try { - _imguiBootstrap = new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!); - _panelHost = new AcDream.UI.ImGui.ImGuiPanelHost(); + imguiBootstrap = + new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!); + var panelHost = new AcDream.UI.ImGui.ImGuiPanelHost(); // VitalsVM: GUID=0 at construction; set later at EnterWorld // (see the _playerServerGuid assignment path). Pre-login the @@ -1121,8 +1109,9 @@ public sealed class GameWindow : IDisposable // bars surface only after the first PlayerDescription has // populated LocalPlayer (Issue #5). _vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); - _vitalsPanel = new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm); - _panelHost.Register(_vitalsPanel); + var vitalsPanel = + new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm); + panelHost.Register(vitalsPanel); // ChatPanel: reads the tail of the shared ChatLog. No GUID // dependency — works pre-login (empty) and post-login (live @@ -1133,11 +1122,12 @@ public sealed class GameWindow : IDisposable // delegates, no panel-vs-renderer-vs-state coupling. var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat) { - FpsProvider = () => (float)_lastFps, + FpsProvider = () => + (float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0), PositionProvider = () => GetDebugPlayerPosition(), }; - _chatPanel = new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm); - _panelHost.Register(_chatPanel); + var chatPanel = new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm); + panelHost.Register(chatPanel); // Phase I.2: DebugPanel — replaces the deleted custom // DebugOverlay (six floating panels + hint bar + toast). @@ -1156,12 +1146,17 @@ public sealed class GameWindow : IDisposable getVerticalVelocity: () => _playerController?.VerticalVelocity ?? 0f, getEntityCount: () => _worldState.Entities.Count, getAnimatedCount: () => _animatedEntities.Count, - getLandblocksVisible: () => _lastVisibleLandblocks, - getLandblocksTotal: () => _lastTotalLandblocks, + getLandblocksVisible: () => + _debugVmRenderFacts.DebugVmFacts.VisibleLandblocks, + getLandblocksTotal: () => + _debugVmRenderFacts.DebugVmFacts.TotalLandblocks, getShadowObjectCount: () => _physicsEngine.ShadowObjects.TotalRegistered, - getNearestObjDist: () => _lastNearestObjDist, - getNearestObjLabel: () => _lastNearestObjLabel, - getColliding: () => _lastColliding, + getNearestObjDist: () => + _debugVmRenderFacts.DebugVmFacts.NearestObjectDistance, + getNearestObjLabel: () => + _debugVmRenderFacts.DebugVmFacts.NearestObjectLabel, + getColliding: () => + _debugVmRenderFacts.DebugVmFacts.Colliding, getDebugWireframes: () => _debugCollisionVisible, getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier @@ -1174,8 +1169,10 @@ public sealed class GameWindow : IDisposable getActiveLights: () => Lighting.ActiveCount, getRegisteredLights: () => Lighting.RegisteredCount, getParticleCount: () => _particleSystem?.ActiveParticleCount ?? 0, - getFps: () => (float)_lastFps, - getFrameMs: () => (float)_lastFrameMs, + getFps: () => + (float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0), + getFrameMs: () => + (float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7), combat: Combat); _debugVm.CycleTimeOfDay = CycleTimeOfDay; _debugVm.CycleWeather = CycleWeather; @@ -1188,8 +1185,9 @@ public sealed class GameWindow : IDisposable _debugVm.ToggleFlyMode = () => _playerModeController?.ToggleFlyOrChase(); _combatFeedback.ViewModel = _debugVm; - _debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm); - _panelHost.Register(_debugPanel); + var debugPanel = + new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm); + panelHost.Register(debugPanel); // Phase K.3 — Settings panel. SettingsVM owns a draft // copy of the active KeyBindings. Save replaces the @@ -1197,6 +1195,7 @@ public sealed class GameWindow : IDisposable // the draft. Construction is null-safe vs. the // dispatcher because the dispatcher is built earlier in // the same OnLoad path (see _inputDispatcher field). + AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? settingsPanel = null; if (_inputDispatcher is not null && _settingsStore is not null) { // L.0 — SettingsStore + persisted-settings load + apply @@ -1336,8 +1335,9 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"settings: character save failed: {ex.Message}"); } }); - _settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm); - _panelHost.Register(_settingsPanel); + settingsPanel = + new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm); + panelHost.Register(settingsPanel); } Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)"); @@ -1348,22 +1348,46 @@ public sealed class GameWindow : IDisposable // dragged positions persist. Without this, the first-run // experience stacks every panel at (0,0) which looks // broken. - ResetPanelLayout(ImGuiNET.ImGuiCond.FirstUseEver); + _devToolsBackend = new AcDream.App.Rendering.ImGuiDevToolsFrameBackend( + imguiBootstrap, + panelHost); + _devToolsCameraMenu = + new AcDream.App.Rendering.DevToolsCameraMenuOperations( + _cameraController!); + _devToolsCommandBus = + new AcDream.App.Rendering.DevToolsCommandBusSource(); + _devToolsFramePresenter = + new AcDream.App.Rendering.DevToolsFramePresenter( + _devToolsBackend, + _devToolsCameraMenu, + _devToolsCommandBus, + _frameProfiler, + new AcDream.App.Rendering.DevToolsPanelSet( + vitalsPanel, + chatPanel, + debugPanel, + _debugVm, + settingsPanel)); + _devToolsFramePresenter.ResetLayout( + _window!.Size.X, + _window.Size.Y, + AcDream.App.Rendering.DevToolsPanelLayoutCondition.FirstUseEver); } catch (Exception ex) { Console.WriteLine($"devtools: ImGui init failed: {ex.Message} — devtools disabled"); - _imguiBootstrap?.Dispose(); - _imguiBootstrap = null; - _panelHost = null; + // The focused backend borrows the bootstrap during normal + // operation. Construction failure still owns this local and + // must release it before abandoning the optional frontend. + imguiBootstrap?.Dispose(); + _devToolsBackend = null; + _devToolsFramePresenter = null; + _devToolsCameraMenu = null; + _devToolsCommandBus = null; _vitalsVm = null; - _vitalsPanel = null; _combatFeedback.ViewModel = null; _debugVm = null; - _debugPanel = null; - _chatPanel = null; _settingsVm = null; - _settingsPanel = null; } } @@ -1790,7 +1814,7 @@ public sealed class GameWindow : IDisposable JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings( () => _playerController?.JumpCharge ?? default), Fps: new AcDream.App.UI.FpsRuntimeBindings( - () => _lastFps, + () => _renderFrameDiagnostics?.Snapshot.Fps ?? 60.0, // Retail displays either the user degrade bias or the live // distance-driven multiplier. acdream does not yet implement // adaptive distance degradation (tracked by TS-15), so its @@ -2259,11 +2283,27 @@ public sealed class GameWindow : IDisposable // the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only // blits the texture; the pre-UI hook drives the 3-D pass (see OnRender). if (_retailUiRuntime?.PaperdollViewportWidget is { } paperdollViewport + && _retailUiRuntime.InventoryFrame is { } paperdollInventoryFrame && _sceneLightingUbo is not null) { _paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer( _gl, _wbDrawDispatcher, _sceneLightingUbo, _textureCache!); paperdollViewport.Renderer = _paperdollViewportRenderer; + _paperdollFramePresenter = + new AcDream.App.Rendering.PaperdollFramePresenter( + _paperdollViewportRenderer, + new AcDream.App.Rendering.RetailPaperdollFrameView( + paperdollViewport, + new AcDream.App.Rendering.PaperdollInventoryVisibility( + paperdollInventoryFrame)), + new AcDream.App.Rendering.RetailPaperdollDollFactory( + new AcDream.App.Rendering.LivePaperdollEntityLookup( + _liveEntities), + _localPlayerIdentity, + new AcDream.App.Rendering.RetailPaperdollPoseApplicator( + _dats!, + _animLoader!, + _datLock))); } // Phase A8: EnvCellRenderer init. Shares _meshShader (mesh_modern.{vert,frag}) @@ -2357,6 +2397,33 @@ public sealed class GameWindow : IDisposable _wbMeshAdapter, _retailAlphaQueue); + AcDream.App.Rendering.IRenderFrameResourceDiagnosticsSource? resourceDiagnostics = + _options.UiProbeDump + ? new AcDream.App.Rendering.RuntimeRenderFrameResourceDiagnosticsSource( + _particleSystem, + _particleSink, + _wbDrawDispatcher, + _envCellRenderer, + _particleRenderer, + _uiHost?.TextRenderer, + _portalDepthMask, + _clipFrame, + _terrain, + _sceneLightingUbo, + _wbMeshAdapter, + _textureCache) + : null; + _renderFrameDiagnostics = + new AcDream.App.Rendering.RenderFrameDiagnosticsController( + new AcDream.App.Rendering.RuntimeRenderFrameTitleFactsSource( + _worldState, + _animatedEntities, + WorldTime), + new AcDream.App.Rendering.SilkRenderFrameTitleSink(_window!), + _renderDiagnosticLog, + _options.UiProbeDump, + resourceDiagnostics); + // A.5 T22.5: apply radii from the already-resolved _resolvedQuality. // _resolvedQuality was set by the quality block immediately after // LoadAndApplyPersistedSettings() above, absorbing all env-var overrides. @@ -2576,7 +2643,7 @@ public sealed class GameWindow : IDisposable _liveEntityHydration.AppearanceApplied += guid => { if (guid == _playerServerGuid) - _paperdollDollDirty = true; + _paperdollFramePresenter?.MarkDirty(); }; // Phase 4.7: optional live-mode startup. Connect to the ACE server, @@ -2730,6 +2797,8 @@ public sealed class GameWindow : IDisposable _movementTruthDiagnostics, _localPlayerSkills, _viewportAspect); + _devToolsCameraMenu?.Bind(_playerModeController); + _devToolsCommandBus?.Bind(_liveSessionController); _playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry( new AcDream.App.Input.LivePlayerModeAutoEntryContext( _liveSessionController, @@ -2934,7 +3003,7 @@ public sealed class GameWindow : IDisposable _liveEntityHydration?.ResetSessionState(); Shortcuts = Array.Empty(); DesiredComponents = Array.Empty<(uint Id, uint Amount)>(); - _paperdollDollDirty = true; + _paperdollFramePresenter?.MarkDirty(); // X/Y are ignored until the next logical player CreateObject claims a // new origin. Keeping the last values avoids inventing a synthetic @@ -3154,115 +3223,6 @@ public sealed class GameWindow : IDisposable session.SendTurbineChatTo( roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - /// - /// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) + - /// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette / - /// part overrides into a dedicated entity posed at the scene origin - /// facing the viewer. The MeshRefs are COPIED so the doll holds a stable pose independent of the - /// player's live in-world animation. Returns false (leaving the dirty flag set to retry) when the - /// player entity isn't available yet. - /// - private bool RefreshPaperdollDoll() - { - if (_paperdollViewportRenderer is null) return false; - if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe) || pe.MeshRefs.Count == 0) - { - _paperdollViewportRenderer.SetDoll(null); - return false; // player not ready — retry next frame - } - - uint? basePal = null; - List<(uint, byte, byte)>? subs = null; - if (pe.PaletteOverride is { } po) - { - basePal = po.BasePaletteId; - subs = new List<(uint, byte, byte)>(); - foreach (var r in po.SubPalettes) subs.Add((r.SubPaletteId, r.Offset, r.Length)); - } - - List<(byte, uint)>? parts = null; - if (pe.PartOverrides.Count > 0) - { - parts = new List<(byte, uint)>(pe.PartOverrides.Count); - foreach (var p in pe.PartOverrides) parts.Add((p.PartIndex, p.GfxObjId)); - } - - var meshRefsCopy = new List(pe.MeshRefs); // dressed parts (player's live frame) - var doll = AcDream.App.Rendering.DollEntityBuilder.Build( - pe.SourceGfxObjOrSetupId, meshRefsCopy, basePal, subs, parts); - ApplyPaperdollPose(doll, pe.SourceGfxObjOrSetupId); // re-pose into the paperdoll "posing" stance - _paperdollViewportRenderer.SetDoll(doll); - return true; - } - - /// - /// Resolve the paperdoll "posing" stance DID (the model pose — left leg back) exactly as retail: - /// gmPaperDollUI ctor (decomp 174243) sets m_didAnimation = DBObj::GetDIDByEnum(0x10000005, 7), - /// which is DBCache::GetDIDFromEnumStatic (decomp 20380) = master[MasterMapId][0x10000005] - /// → submap 0x25000009 → key 7. (Icon effects use keys 1-6 of the same submap; key 7 is the - /// paperdoll pose.) Per-race UpdateForRace override deferred — the ctor default applies to all - /// body-types for now. Returns 0 if the chain can't resolve. - /// - private uint ResolvePaperdollPoseDid() - { - var dats = _dats; - if (dats is null) return 0u; - uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId; - if (masterDid == 0) return 0u; - if (!dats.Portal.TryGet(masterDid, out var master)) return 0u; - // DBCache::GetDIDFromEnum (decomp 20380) resolves master[arg4] → submap, then submap[arg3]. - // GetDIDFromEnumStatic(0x10000005, 7) ⇒ arg3=0x10000005, arg4=7 ⇒ master[7] → submap, submap[0x10000005]. - if (!master.ClientEnumToID.TryGetValue(7u, out var subDid)) return 0u; // master-level key = 7 - if (!dats.Portal.TryGet(subDid, out var sub)) return 0u; - return sub.ClientEnumToID.TryGetValue(0x10000005u, out var did) ? did : 0u; // submap index = 0x10000005 - } - - /// - /// Re-pose the doll's already-dressed parts into the paperdoll "posing" stance — the C# analog of - /// retail set_sequence_animation(doll, m_didAnimation, …). Keeps each cloned part's GfxObjId + - /// surface overrides (the dressed appearance), but replaces its transform with the pose animation's - /// frame-0 per-part transform, using the same Scale·Rotate·Translate composition as the per-frame - /// animation tick (, GameWindow.cs ~9999). Static (the pose is fixed). If the - /// DID does not resolve to an Animation, the doll keeps its cloned pose (no regression) — the log - /// line surfaces what resolved so the pose is verified, not guessed. - /// - private void ApplyPaperdollPose(AcDream.Core.World.WorldEntity doll, uint setupId) - { - var dats = _dats; - if (dats is null) return; - // Retail's paperdoll doll is STATIC — it holds the pose animation's frame, it does NOT loop. - _animatedEntities.Remove(doll.Id); - // poseDID is cdb-CONFIRMED = 0x030003C0 (== retail gmPaperDollUI m_didAnimation for Horan; UpdateForRace - // did not override). Crash guard: only load a 0x03xxxxxx (Animation) DID (a non-animation id parses a - // garbage frame count → OOM). - uint poseDid = ResolvePaperdollPoseDid(); - if ((poseDid >> 24) != 0x03u) return; - var anim = _animLoader?.LoadAnimation(poseDid); - var setup = dats.Get(setupId); - if (anim is null || setup is null || anim.PartFrames.Count == 0) return; - - // Retail plays the pose animation once and HOLDS the last frame (set_sequence_animation framerate=0, - // RedressCreature decomp 0x004a3c22). For 0x030003C0 the animation has only two distinct keyframes — - // frame 0 (a transitional, raised/bent arm) and frames 1..N (all byte-identical: the settled stance, - // arms down + one leg back) — verified by dumping the dat. The held pose is therefore the last frame. - int frameIdx = anim.PartFrames.Count - 1; - var frame = anim.PartFrames[frameIdx]; - var src = doll.MeshRefs; - var reposed = new List(src.Count); - for (int i = 0; i < src.Count; i++) - { - var scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : System.Numerics.Vector3.One; - System.Numerics.Vector3 origin = System.Numerics.Vector3.Zero; - System.Numerics.Quaternion orient = System.Numerics.Quaternion.Identity; - if (i < frame.Frames.Count) { origin = frame.Frames[i].Origin; orient = frame.Frames[i].Orientation; } - var transform = System.Numerics.Matrix4x4.CreateScale(scale) - * System.Numerics.Matrix4x4.CreateFromQuaternion(orient) - * System.Numerics.Matrix4x4.CreateTranslation(origin); - reposed.Add(new AcDream.Core.World.MeshRef(src[i].GfxObjId, transform) { SurfaceOverrides = src[i].SurfaceOverrides }); - } - doll.MeshRefs = reposed; - } - private bool TryGetLoginWorldCell(out uint cell) { cell = 0; @@ -3363,10 +3323,6 @@ public sealed class GameWindow : IDisposable internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) => entity.Id; - // #131 [outstage-pt] probe state (throwaway — strip when #131 closes). - private string? _lastOutStagePtSig; - private readonly HashSet _outStageUnmatchedScratch = new(); - private readonly HashSet _outStageMatchedScratch = new(); private static System.Numerics.Vector3 SkyPesAnchor( AcDream.Core.World.SkyObjectData obj, @@ -3478,9 +3434,6 @@ public sealed class GameWindow : IDisposable // Performance overlay state — updated every ~0.5s and written to the // window title so there's zero rendering cost (no font/overlay needed). - private double _perfAccum; - private int _perfFrameCount; - private void OnRender(double deltaSeconds) { _gpuFrameFlights!.BeginFrame(); @@ -3559,8 +3512,8 @@ public sealed class GameWindow : IDisposable // §4 flap apparatus: GL-state tripwire — one [gl-state] line whenever the // state entering the world passes differs from the previous frame. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled) - EmitGlStateTripwireIfChanged(); + _worldRenderDiagnostics?.EmitGlStateTripwireIfChanged( + AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled); // Phase N.6 slice 1: one-shot surface-format histogram dump under // ACDREAM_DUMP_SURFACES=1. Zero cost when off. @@ -3589,14 +3542,15 @@ public sealed class GameWindow : IDisposable _particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); } if (_frameDiag) - FrameDiagPush(_entityUploadSamples, ref _entityUploadSampleCursor, + { + _entityUploadTiming.PushStopwatchTicks( System.Diagnostics.Stopwatch.GetTimestamp() - fdE0); + } // Phase D.2a — begin ImGui frame. Paired with the Render() call // after the scene draws (below). ImGuiController.Update() // consumes buffered Silk.NET input events and calls ImGui.NewFrame. - if (DevToolsEnabled && _imguiBootstrap is not null) - _imguiBootstrap.BeginFrame((float)deltaSeconds); + _devToolsFramePresenter?.BeginFrame((float)deltaSeconds); // Phase G.1: weather state machine — deterministic per-day roll // + transitions + lightning flash. @@ -3624,6 +3578,7 @@ public sealed class GameWindow : IDisposable int visibleLandblocks = 0; int totalLandblocks = 0; + bool normalWorldDrawn = false; _retailSelectionScene?.BeginFrame(); if (_cameraController is not null && !portalViewportVisible) @@ -4101,17 +4056,17 @@ public sealed class GameWindow : IDisposable // Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup. EnableClipDistances(); - _terrainCpuStopwatch.Restart(); + _worldRenderDiagnostics?.BeginTerrainDraw(); sigTerrainDrawn = true; _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); - _terrainCpuStopwatch.Stop(); - _terrainCpuSamples[_terrainCpuSampleCursor] = - (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0); - _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; - MaybeFlushTerrainDiag(); + PublishTerrainDrawDiagnostics(); } + // Reaching the retail normal-world decision below proves this is + // neither a portal replacement nor the sky-only login-wait path. + normalWorldDrawn = true; + // R1 — the binary render decision (retail RenderNormalMode @ 0x453aa0): // INDOOR root (clipRoot != null): run ONLY the per-cell DrawInside flood. The global // entity pass + global shell pass are NOT issued — visibility IS the cull, so the @@ -4225,13 +4180,18 @@ public sealed class GameWindow : IDisposable DrawDynamicsParticles = survivors => DrawRetailPViewDynamicsParticles(survivors, camera, camPos), EmitDiagnostics = result => - EmitRetailPViewDiagnostics( + _worldRenderDiagnostics?.EmitRetailPViewDiagnostics( + AcDream.Core.Rendering.RenderingDiagnostics.ProbeViewerEnabled, + AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled, + AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled, result, clipRoot, viewerCellId, playerCellId, camPos, - playerViewPos), + playerViewPos, + camera.View, + _cellVisibility.LastCameraCellResolution), }); pvFrame = pviewResult.PortalFrame; clipAssembly = pviewResult.ClipAssembly; @@ -4251,10 +4211,9 @@ public sealed class GameWindow : IDisposable // the resulting flood count. The live flap shows flood flipping 2↔6 at an eye/player // identical to cm; this answers whether the inputs differ sub-cm (jitter) or are // byte-identical (nondeterminism). See RenderingDiagnostics.ProbePvInputEnabled. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePvInputEnabled && pvFrame is not null) + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePvInputEnabled + && pvFrame is not null) { - var vp = envCellViewProj; - char pvOutRoot = clipRoot.IsOutdoorNode ? 'Y' : 'n'; // 2026-06-08: disambiguate the idle flap. eye=camera eye-point (drives the flood); // player=RenderPosition (Lerp of physics, what the eye chases); rawPlayer=raw physics // body Position; yaw=camera/player heading (F8 rad to catch micro-drift). If the flood @@ -4266,11 +4225,16 @@ public sealed class GameWindow : IDisposable // terrain triangle backface-culls and the view punches through the // world to the clear color. Pin or refute eye-under-terrain. float? pvTerrZ = _physicsEngine.SampleTerrainZ(camPos.X, camPos.Y); - string pvTerr = pvTerrZ is { } tz - ? System.FormattableString.Invariant($"terrZ={tz:F3} eyeAbove={camPos.Z - tz:F3}") - : "terrZ=n/a eyeAbove=n/a"; - Console.WriteLine(System.FormattableString.Invariant( - $"[pv-input] outRoot={pvOutRoot} flood={pvFrame.OrderedVisibleCells.Count} eye=({camPos.X:F6},{camPos.Y:F6},{camPos.Z:F6}) player=({playerViewPos.X:F6},{playerViewPos.Y:F6},{playerViewPos.Z:F6}) rawPlayer=({pvRawPlayer.X:F6},{pvRawPlayer.Y:F6},{pvRawPlayer.Z:F6}) yaw={pvYaw:F8} {pvTerr} vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]")); + _worldRenderDiagnostics?.EmitPViewInput( + enabled: true, + pvFrame, + envCellViewProj, + clipRoot.IsOutdoorNode, + camPos, + playerViewPos, + pvRawPlayer, + pvYaw, + pvTerrZ); } sigPvFrame = pviewResult.PortalFrame; @@ -4391,7 +4355,8 @@ public sealed class GameWindow : IDisposable AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); } - EmitRenderSignatureIfChanged( + _worldRenderDiagnostics?.EmitRenderSignatureIfChanged( + AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled, renderBranch, clipRoot, viewerRoot, @@ -4513,34 +4478,16 @@ public sealed class GameWindow : IDisposable // the hot path of an offline render. if (_debugVm is not null) { - _lastVisibleLandblocks = visibleLandblocks; - _lastTotalLandblocks = totalLandblocks; - - // Compute fly/orbit-mode camera position for the nearest- - // object scan when not in player mode. - System.Numerics.Vector3 nearOrigin; - if (_playerMode && _playerController is not null) - nearOrigin = _playerController.Position; - else - nearOrigin = camPos; - - const float playerRadius = 0.48f; - float bestDist = float.PositiveInfinity; - string bestLabel = "-"; - foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug()) - { - float dx = obj.Position.X - nearOrigin.X; - float dy = obj.Position.Y - nearOrigin.Y; - float d = MathF.Sqrt(dx * dx + dy * dy) - obj.Radius - playerRadius; - if (d < bestDist) - { - bestDist = d; - bestLabel = $"0x{obj.EntityId:X8} {obj.CollisionType}"; - } - } - _lastColliding = bestDist < 0.05f; - _lastNearestObjDist = bestDist < 0f ? 0f : bestDist; - _lastNearestObjLabel = bestLabel; + System.Numerics.Vector3 debugNearestOrigin = + _playerMode && _playerController is not null + ? _playerController.Position + : camPos; + _debugVmRenderFacts.PublishDebugVmFacts( + consumerActive: true, + visibleLandblocks, + totalLandblocks, + debugNearestOrigin, + _physicsEngine.ShadowObjects.AllEntriesForDebug()); } // K-fix1 (2026-04-26): jump target for IsLiveModeWaitingForLogin — @@ -4571,15 +4518,7 @@ public sealed class GameWindow : IDisposable // viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window // is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle). // The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state. - if (_options.RetailUi && _paperdollViewportRenderer is not null - && _retailUiRuntime?.PaperdollViewportWidget is { Visible: true } dollWidget - && _retailUiRuntime.InventoryFrame is { Visible: true }) - { - if (_paperdollDollDirty && RefreshPaperdollDoll()) - _paperdollDollDirty = false; - dollWidget.TextureHandle = _paperdollViewportRenderer.Render( - (int)dollWidget.Width, (int)dollWidget.Height); - } + _paperdollFramePresenter?.Render(); // Phase D.2b — retail-look UI tree (render-only; input integration deferred). // Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own @@ -4593,190 +4532,31 @@ public sealed class GameWindow : IDisposable _retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); } - // Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws - // so ImGui composites on top. ImGuiController save/restores the - // GL state it touches (blend, scissor, VAO, shader, texture); any - // state not in its save-list (e.g. GL_FRAMEBUFFER_SRGB, unused - // today) would need manual protection. - if (DevToolsEnabled && _imguiBootstrap is not null && _panelHost is not null) - { - // Phase I.3 — prefer the live command bus when a live session - // is up so panel-emitted SendChatCmd actually flows server-ward. - // Fall back to NullCommandBus for offline / pre-connect renders. - AcDream.UI.Abstractions.ICommandBus bus = - _liveSessionController?.Commands - ?? AcDream.UI.Abstractions.NullCommandBus.Instance; - var ctx = new AcDream.UI.Abstractions.PanelContext( - (float)deltaSeconds, - bus); - - // Phase K.3 — top-of-screen menu bar. Provides discoverable - // entries for users who don't memorize the F-keys (View → - // Settings / Vitals / Chat / Debug). Uses ImGuiNET directly - // here because the panel host doesn't own a menu-bar - // surface; the abstraction (BeginMainMenuBar / BeginMenu / - // MenuItem) exists for backend portability + tests but only - // gets exercised here once per frame. - if (ImGuiNET.ImGui.BeginMainMenuBar()) - { - if (ImGuiNET.ImGui.BeginMenu("View")) - { - if (_settingsPanel is not null - && ImGuiNET.ImGui.MenuItem("Settings", "F11")) - _settingsPanel.IsVisible = !_settingsPanel.IsVisible; - if (_vitalsPanel is not null - && ImGuiNET.ImGui.MenuItem("Vitals")) - _vitalsPanel.IsVisible = !_vitalsPanel.IsVisible; - if (_chatPanel is not null - && ImGuiNET.ImGui.MenuItem("Chat")) - _chatPanel.IsVisible = !_chatPanel.IsVisible; - if (_debugPanel is not null - && ImGuiNET.ImGui.MenuItem("Debug", "Ctrl+F1")) - _debugPanel.IsVisible = !_debugPanel.IsVisible; - ImGuiNET.ImGui.Separator(); - // L.0 Display tab: a manual reset for users whose - // imgui.ini has saved a panel position that's now - // off-screen (after a window shrink, monitor swap, - // or a malformed save). Force-resets every panel - // to its default landing position. The same code - // path runs automatically on FramebufferResize. - if (ImGuiNET.ImGui.MenuItem("Reset window layout")) - ResetPanelLayout(ImGuiNET.ImGuiCond.Always); - ImGuiNET.ImGui.EndMenu(); - } - // K-fix2 (2026-04-26): Camera submenu — discoverable - // free-fly toggle for users who don't know the - // Ctrl+Shift+F shortcut or the Debug-panel button. - if (ImGuiNET.ImGui.BeginMenu("Camera")) - { - if (_cameraController is not null) - { - string flyLabel = _cameraController.IsFlyMode - ? "Exit Free-Fly Mode" : "Enter Free-Fly Mode"; - if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F")) - _playerModeController?.ToggleFlyOrChase(); - } - // A8.F: spring-arm camera collision (live A/B toggle). - if (ImGuiNET.ImGui.MenuItem("Collide Camera (spring arm)", "", - AcDream.Core.Rendering.CameraDiagnostics.CollideCamera)) - AcDream.Core.Rendering.CameraDiagnostics.CollideCamera = - !AcDream.Core.Rendering.CameraDiagnostics.CollideCamera; - ImGuiNET.ImGui.EndMenu(); - } - ImGuiNET.ImGui.EndMainMenuBar(); - } - - _panelHost.RenderAll(ctx); - using (var _imguiStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui)) - { - _imguiBootstrap.Render(); - } - } + // Devtools remain above retained gameplay UI. The focused presenter + // owns menu policy, panels, and ImGui draw-data upload. + _devToolsFramePresenter?.Render( + deltaSeconds, + _window!.Size.X, + _window.Size.Y); // Explicit diagnostic captures are requested by the retained-UI // automation tick above and executed only after every presentation // layer has drawn into the back buffer on this render thread. - _frameScreenshots?.CapturePending(); + bool screenshotCaptured = _frameScreenshots?.CapturePending() == true; - // Update the window title with performance stats every ~0.5s. - _perfAccum += deltaSeconds; - _perfFrameCount++; - if (_perfAccum >= 0.5) - { - double avgFrameTime = _perfAccum / _perfFrameCount * 1000.0; - double fps = _perfFrameCount / _perfAccum; - int entityCount = _worldState.Entities.Count; - int animatedCount = _animatedEntities.Count; - _lastVisibleLandblocks = visibleLandblocks; - _lastTotalLandblocks = totalLandblocks; - - // Keep the developer diagnostic title independent from retail's - // /framerate switch. That command owns the in-world SmartBox readout; - // conflating it with window chrome was the original incomplete port. - // - // Also include the in-game calendar/time — - // matches retail's @timestamp output ("Date: , - // PY Time: "). Uses NowTicks (server-synced - // + wall-clock interpolation) so the user can read the same - // fields off both acdream and retail and confirm clock parity - // directly. Drift > 1 hour = real bug. - double tNow = WorldTime.NowTicks; - var titleCal = AcDream.Core.World.DerethDateTime.ToCalendar(tNow); - double df = WorldTime.DayFraction; - _window!.Title = - $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " - + $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | " - + $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})"; - // Script automation must remain performance-neutral. Only the - // explicit dump switch requests this allocation-heavy snapshot. - if (_options.UiProbeDump) - { - (int particleSets, long particleBytes) = - _particleRenderer?.DynamicBufferDiagnostics ?? default; - var meshManager = _wbMeshAdapter?.MeshManager; - var meshDiag = meshManager?.Diagnostics ?? default; - var globalMesh = meshManager?.GlobalBuffer; - var cpuMeshCache = _wbMeshAdapter?.CpuMeshCacheDiagnostics ?? default; - GCMemoryInfo gcMemory = GC.GetGCMemoryInfo(); - Console.WriteLine( - $"[gpu-stream] emit={_particleSystem?.ActiveEmitterCount ?? 0} " - + $"particles={_particleSystem?.ActiveParticleCount ?? 0} " - + $"bindings={_particleSink?.ActiveBindingCount ?? 0} " - + $"wbSets={_wbDrawDispatcher?.DynamicBufferSetCount ?? 0} " - + $"cellSets={_envCellRenderer?.DynamicBufferSetCount ?? 0} " - + $"particleSets={particleSets}/{particleBytes}B " - + $"uiBytes={_uiHost?.TextRenderer.DynamicBufferCapacityBytes ?? 0} " - + $"portalBytes={_portalDepthMask?.DynamicBufferCapacityBytes ?? 0} " - + $"clipSets={_clipFrame?.DynamicBufferSetCount ?? 0} " - + $"terrainBuffers={_terrain?.DynamicIndirectBufferCount ?? 0} " - + $"lightBuffers={_sceneLightingUbo?.DynamicBufferCount ?? 0} " - + $"mesh={meshDiag.RenderData}/{meshDiag.AtlasArrays}/{meshDiag.UnusedLru} " - + $"meshEst={meshDiag.EstimatedBytes} " - + $"meshUpload={globalMesh?.UploadCount ?? 0}/{globalMesh?.UploadedBytes ?? 0} " - + $"uploadFrame={_wbMeshAdapter?.LastUploadCount ?? 0}/" - + $"{_wbMeshAdapter?.LastUploadBytes ?? 0}/" - + $"{_wbMeshAdapter?.LastArrayAllocationBytes ?? 0}/" - + $"{_wbMeshAdapter?.LastPlannedMipmapBytes ?? 0}/" - + $"{_wbMeshAdapter?.LastNewArrayCount ?? 0} " - + $"bufferFrame={_wbMeshAdapter?.LastBufferUploadBytes ?? 0}/" - + $"{_wbMeshAdapter?.LastBufferAllocationBytes ?? 0}/" - + $"{_wbMeshAdapter?.LastBufferCopyBytes ?? 0}/" - + $"{_wbMeshAdapter?.LastNewBufferCount ?? 0} " - + $"staging={_wbMeshAdapter?.StagedUploadBacklog ?? 0}/" - + $"{_wbMeshAdapter?.StagedUploadBytes ?? 0}/" - + $"{_wbMeshAdapter?.StagingAtHighWater ?? false}/" - + $"{_wbMeshAdapter?.LastStaleDiscardCount ?? 0} " - + $"cpuMesh={cpuMeshCache.Count}/{cpuMeshCache.Bytes} " - + $"mipmapFrame={_wbMeshAdapter?.LastMipmapArrayCount ?? 0}/" - + $"{_wbMeshAdapter?.LastMipmapBytes ?? 0} " - + $"meshCap={globalMesh?.CapacityBytes ?? 0} " - + $"meshPhys={globalMesh?.PhysicalCapacityBytes ?? 0}/" - + $"{globalMesh?.IsMigrationInProgress ?? false} " - + $"gpuTrack={AcDream.App.Rendering.Wb.GpuMemoryTracker.AllocatedBytes}/" - + $"{AcDream.App.Rendering.Wb.GpuMemoryTracker.BufferCount}/" - + $"{AcDream.App.Rendering.Wb.GpuMemoryTracker.TextureCount} " - + $"managed={GC.GetTotalMemory(false)}/{gcMemory.TotalCommittedBytes} " - + $"ownedTextures={_textureCache?.OwnedBindlessTextureCount ?? 0}/" - + $"{_textureCache?.TextureOwnerCount ?? 0} " - + $"particleTextures={_textureCache?.ActiveParticleTextureCount ?? 0}/" - + $"{_textureCache?.ParticleTextureOwnerCount ?? 0}/" - + $"{_textureCache?.CachedParticleTextureCount ?? 0}/" - + $"{_textureCache?.CachedUnownedParticleTextureCount ?? 0}/" - + $"{_textureCache?.CachedUnownedParticleTextureBytes ?? 0} " - + $"compositeCache={_textureCache?.CachedCompositeTextureCount ?? 0}/" - + $"{_textureCache?.CachedUnownedCompositeCount ?? 0}/" - + $"{_textureCache?.CachedUnownedCompositeBytes ?? 0} " - + $"compositeAtlas={_textureCache?.CompositeAtlasCount ?? 0}/" - + $"{_textureCache?.CompositeAtlasBytes ?? 0} " - + $"compositeUpload={_textureCache?.CompositeFrameUploadCount ?? 0}/" - + $"{_textureCache?.CompositeFrameUploadBytes ?? 0}/" - + $"{_wbDrawDispatcher?.LastCompositeWarmupPendingCount ?? 0}"); - } - _lastFps = fps; - _lastFrameMs = avgFrameTime; - _perfAccum = 0; - _perfFrameCount = 0; - } + _renderFrameDiagnostics?.Publish( + new AcDream.App.Rendering.RenderFrameInput( + deltaSeconds, + _window!.Size.X, + _window.Size.Y), + new AcDream.App.Rendering.RenderFrameOutcome( + new AcDream.App.Rendering.WorldRenderFrameOutcome( + visibleLandblocks, + totalLandblocks, + normalWorldDrawn), + new AcDream.App.Rendering.PrivatePresentationFrameOutcome( + portalViewportVisible, + screenshotCaptured))); } catch (Exception ex) @@ -4805,13 +4585,16 @@ public sealed class GameWindow : IDisposable { var meshManager = _wbMeshAdapter?.MeshManager; var mesh = meshManager?.Diagnostics ?? default; + AcDream.App.Rendering.RenderFrameDiagnosticsSnapshot render = + _renderFrameDiagnostics?.Snapshot + ?? AcDream.App.Rendering.RenderFrameDiagnosticsSnapshot.Initial; GCMemoryInfo memory = GC.GetGCMemoryInfo(); return new AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot( LoadedLandblocks: _worldState.LoadedLandblockIds.Count, WorldEntities: _worldState.Entities.Count, AnimatedEntities: _animatedEntities.Count, - VisibleLandblocks: _lastVisibleLandblocks, - TotalLandblocks: _lastTotalLandblocks, + VisibleLandblocks: render.VisibleLandblocks, + TotalLandblocks: render.TotalLandblocks, LiveEntities: _liveEntities?.Count ?? 0, MaterializedLiveEntities: _liveEntities?.MaterializedCount ?? 0, PendingLiveTeardowns: _liveEntities?.PendingTeardownCount ?? 0, @@ -4840,8 +4623,8 @@ public sealed class GameWindow : IDisposable CompositeWarmupPending: _wbDrawDispatcher?.LastCompositeWarmupPendingCount ?? 0, ManagedBytes: GC.GetTotalMemory(forceFullCollection: false), ManagedCommittedBytes: memory.TotalCommittedBytes, - Fps: _lastFps, - FrameMilliseconds: _lastFrameMs, + Fps: render.Fps, + FrameMilliseconds: render.FrameMilliseconds, LastFrameProfile: _frameProfiler.LastReport); } @@ -4941,225 +4724,6 @@ public sealed class GameWindow : IDisposable } } - private void EmitRenderSignatureIfChanged( - string branch, - LoadedCell? clipRoot, - LoadedCell? viewerRoot, - LoadedCell? playerRoot, - uint viewerCellId, - uint playerCellId, - bool playerIndoorGate, - bool cameraInsideCell, - bool renderSkyGate, - bool drawSkyThisFrame, - bool terrainDrawn, - TerrainClipMode terrainClipMode, - bool skyDrawn, - bool depthClear, - bool outdoorSceneryDrawn, - bool outdoorPortalDrawn, - int outdoorRootObjectCount, - int liveDynamicDrawnCount, - string sceneParticles, - PortalVisibilityFrame? pvFrame, - ClipFrameAssembly? clipAssembly, - IReadOnlySet? drawableCells, - AcDream.App.Rendering.InteriorEntityPartition.Result? partition, - PortalVisibilityFrame? exteriorPvFrame, - ClipFrameAssembly? exteriorClipAssembly, - IReadOnlySet? exteriorDrawableCells, - AcDream.App.Rendering.InteriorEntityPartition.Result? exteriorPartition, - System.Numerics.Vector3 camPos, - System.Numerics.Vector3 playerViewPos) - { - if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) - return; - - _renderSignatureFrame++; - - bool eyeInRoot = clipRoot is not null && CellVisibility.PointInCell(camPos, clipRoot); - bool playerInRoot = clipRoot is not null && CellVisibility.PointInCell(playerViewPos, clipRoot); - - var sb = new System.Text.StringBuilder(512); - sb.Append("branch=").Append(branch); - sb.Append(" root=0x").Append((clipRoot?.CellId ?? 0u).ToString("X8")); - sb.Append(" viewerRoot=0x").Append((viewerRoot?.CellId ?? 0u).ToString("X8")); - sb.Append(" playerRoot=0x").Append((playerRoot?.CellId ?? 0u).ToString("X8")); - sb.Append(" viewerCell=0x").Append(viewerCellId.ToString("X8")); - sb.Append(" playerCell=0x").Append(playerCellId.ToString("X8")); - sb.Append(" gate=").Append(playerIndoorGate ? "in" : "out"); - sb.Append(" camIn=").Append(cameraInsideCell ? 'Y' : 'n'); - sb.Append(" eyeInRoot=").Append(eyeInRoot ? 'Y' : 'n'); - sb.Append(" playerInRoot=").Append(playerInRoot ? 'Y' : 'n'); - sb.Append(" eye=").Append(FormatVecForSignature(camPos)); - sb.Append(" player=").Append(FormatVecForSignature(playerViewPos)); - sb.Append(" terrain=").Append(terrainClipMode); - sb.Append('/').Append(terrainDrawn ? "draw" : "skip"); - sb.Append(" skyGate=").Append(renderSkyGate ? 'Y' : 'n'); - sb.Append(" sky=").Append(skyDrawn ? 'Y' : 'n'); - sb.Append(" skyFrame=").Append(drawSkyThisFrame ? 'Y' : 'n'); - sb.Append(" zclear=").Append(depthClear ? 'Y' : 'n'); - sb.Append(" sceneParticles=").Append(sceneParticles); - - if (clipAssembly is not null) - { - sb.Append(" outSlices=").Append(clipAssembly.OutsideViewSlices.Length); - sb.Append(" outPolys=").Append(pvFrame?.OutsideView.Polygons.Count ?? 0); - sb.Append(" outMode=").Append(clipAssembly.TerrainMode); - } - else - { - sb.Append(" outSlices=0 outPolys=0 outMode=none"); - } - - sb.Append(" ids=").Append(FormatIds(pvFrame?.OrderedVisibleCells, preserveOrder: true)); - sb.Append(" draw=").Append(FormatIds(drawableCells, preserveOrder: false)); - sb.Append(" miss=").Append(FormatMissingDrawableCells(pvFrame, drawableCells)); - sb.Append(" obj=").Append(FormatPartitionCounts(partition)); - sb.Append(" outdoorDoor=").Append(outdoorSceneryDrawn ? 'Y' : 'n'); - sb.Append(" outdoorRootObjs=").Append(outdoorRootObjectCount); - sb.Append(" liveDynDraw=").Append(liveDynamicDrawnCount); - - // Diagnosis 2026-06-07: draw-vs-occlude probe for the see-through residual. - // outRoot=Y means clipRoot is the synthetic outdoor node (eye outdoors). bshell=total/withMesh - // counts the building ModelId exterior shells queued in partition.Outdoor for this frame — the - // GfxObj exteriors that SHOULD draw the solid walls. Correlate with ids= (the flooded interior - // cells): if bshell=N/N and ids=[node only] but the wall is still see-through, the exterior is - // failing to rasterize (draw/clip bug, not EnvCell sidedness); if ids includes interior cells, - // the outdoor flood is drawing interiors over the exterior. - sb.Append(" outRoot=").Append(clipRoot is { IsOutdoorNode: true } ? 'Y' : 'n'); - if (partition is not null) - { - int shellTotal = 0, shellMesh = 0; - foreach (var e in partition.OutdoorStatic) - if (e.IsBuildingShell) { shellTotal++; if (e.MeshRefs.Count > 0) shellMesh++; } - sb.Append(" bshell=").Append(shellTotal).Append('/').Append(shellMesh); - } - - if (outdoorPortalDrawn || exteriorPvFrame is not null || exteriorClipAssembly is not null) - { - sb.Append(" extPortal=").Append(outdoorPortalDrawn ? 'Y' : 'n'); - sb.Append(" extSlices=").Append(exteriorClipAssembly?.OutsideViewSlices.Length ?? 0); - sb.Append(" extIds=").Append(FormatIds(exteriorPvFrame?.OrderedVisibleCells, preserveOrder: true)); - sb.Append(" extDraw=").Append(FormatIds(exteriorDrawableCells, preserveOrder: false)); - sb.Append(" extMiss=").Append(FormatMissingDrawableCells(exteriorPvFrame, exteriorDrawableCells)); - sb.Append(" extObj=").Append(FormatPartitionCounts(exteriorPartition)); - } - - string signature = sb.ToString(); - if (signature == _lastRenderSignature) - { - _renderSignatureStableFrames++; - return; - } - - Console.WriteLine( - $"[render-sig] frame={_renderSignatureFrame} stable={_renderSignatureStableFrames} {signature}"); - _lastRenderSignature = signature; - _renderSignatureStableFrames = 0; - } - - private static string FormatVecForSignature(System.Numerics.Vector3 value) - { - static float Q(float v) => System.MathF.Round(v * 20f) / 20f; - return $"({Q(value.X):F2},{Q(value.Y):F2},{Q(value.Z):F2})"; - } - - private static string FormatIds(IEnumerable? ids, bool preserveOrder) - { - if (ids is null) - return "[]"; - - var values = new List(); - foreach (uint id in ids) - values.Add(id); - - if (!preserveOrder) - values.Sort(); - - var sb = new System.Text.StringBuilder(96); - sb.Append('['); - const int MaxIds = 12; - for (int i = 0; i < values.Count && i < MaxIds; i++) - { - if (i > 0) - sb.Append(','); - sb.Append("0x").Append(values[i].ToString("X8")); - } - if (values.Count > MaxIds) - sb.Append(",..."); - sb.Append(']'); - return sb.ToString(); - } - - private static string FormatMissingDrawableCells( - PortalVisibilityFrame? pvFrame, - IReadOnlySet? drawableCells) - { - if (pvFrame is null || drawableCells is null) - return "[]"; - - var sb = new System.Text.StringBuilder(96); - sb.Append('['); - int written = 0; - const int MaxCells = 8; - foreach (uint id in pvFrame.OrderedVisibleCells) - { - if (drawableCells.Contains(id)) - continue; - - if (written > 0) - sb.Append(','); - sb.Append("0x").Append(id.ToString("X8")); - if (pvFrame.CellViews.TryGetValue(id, out var view)) - { - sb.Append(":p").Append(view.Polygons.Count); - if (view.IsEmpty) - sb.Append(":empty"); - } - else - { - sb.Append(":noView"); - } - - written++; - if (written >= MaxCells) - { - sb.Append(",..."); - break; - } - } - - sb.Append(']'); - return sb.ToString(); - } - - private static string FormatPartitionCounts( - AcDream.App.Rendering.InteriorEntityPartition.Result? partition) - { - if (partition is null) - return "cell=[] out=0 live=0"; - - var keys = new List(partition.ByCell.Keys); - keys.Sort(); - - var sb = new System.Text.StringBuilder(128); - sb.Append("cell=["); - const int MaxCells = 10; - for (int i = 0; i < keys.Count && i < MaxCells; i++) - { - uint id = keys[i]; - if (i > 0) - sb.Append(','); - sb.Append("0x").Append(id.ToString("X8")).Append(':').Append(partition.ByCell[id].Count); - } - if (keys.Count > MaxCells) - sb.Append(",..."); - sb.Append("] out=").Append(partition.OutdoorStatic.Count) - .Append(" live=").Append(partition.Dynamics.Count); - return sb.ToString(); - } - private void DrawRetailPViewLandscapeSlice( AcDream.App.Rendering.RetailPViewLandscapeSliceContext sliceCtx, ICamera camera, @@ -5174,8 +4738,10 @@ public sealed class GameWindow : IDisposable { var slice = sliceCtx.Slice; bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled) - EmitClipRouteScissorProbe(scissor, slice.NdcAabb); + _worldRenderDiagnostics?.EmitClipRouteScissorProbe( + AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled, + scissor, + slice.NdcAabb); _clipFrame!.BindTerrainClip(_gl!); @@ -5190,18 +4756,14 @@ public sealed class GameWindow : IDisposable AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); EnableClipDistances(); - _terrainCpuStopwatch.Restart(); + _worldRenderDiagnostics?.BeginTerrainDraw(); _terrain?.Draw( camera, frustum, neverCullLandblockId: playerLb, clipPlanes: slice.Planes, ndcClipAabb: slice.NdcAabb); - _terrainCpuStopwatch.Stop(); - _terrainCpuSamples[_terrainCpuSampleCursor] = - (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0); - _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; - MaybeFlushTerrainDiag(); + PublishTerrainDrawDiagnostics(); // T3 (BR-5): entities draw OUTSIDE the clip bracket — retail meshes // are viewcone-CHECKED, never hard-clipped (Ghidra 0x0054c250); the @@ -5280,39 +4842,10 @@ public sealed class GameWindow : IDisposable // UNMATCHED attached owner ids (the portal-identification handle — // an emitter whose owner never lands in the set draws nowhere // indoors). Print-on-change. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled - && _particleSystem is not null) - { - int matched = 0, attached = 0, unattached = 0; - _outStageUnmatchedScratch.Clear(); - _outStageMatchedScratch.Clear(); - foreach (var (emitter, _) in _particleSystem.EnumerateLive()) - { - if (emitter.AttachedObjectId == 0) { unattached++; continue; } - attached++; - if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)) - { - matched++; - if (_outStageMatchedScratch.Count < 48) - _outStageMatchedScratch.Add(emitter.AttachedObjectId); - } - else if (_outStageUnmatchedScratch.Count < 12) - _outStageUnmatchedScratch.Add(emitter.AttachedObjectId); - } - var unm = new System.Text.StringBuilder(96); - foreach (uint id in _outStageUnmatchedScratch) - unm.Append(System.FormattableString.Invariant($" 0x{id:X8}")); - var mat = new System.Text.StringBuilder(192); - foreach (uint id in _outStageMatchedScratch) - mat.Append(System.FormattableString.Invariant($" 0x{id:X8}")); - string ptSig = System.FormattableString.Invariant( - $"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} matchedIds=[{mat}] unmatchedIds=[{unm}]"); - if (ptSig != _lastOutStagePtSig) - { - _lastOutStagePtSig = ptSig; - Console.WriteLine("[outstage-pt] " + ptSig); - } - } + _worldRenderDiagnostics?.EmitOutStageParticles( + AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled, + _particleSystem, + _outdoorSceneParticleEntityIds); // #132 outdoor sibling: under an OUTDOOR root the merged building // interiors draw AFTER this stage (DrawEnvCellShells) — a flame drawn @@ -5493,171 +5026,6 @@ public sealed class GameWindow : IDisposable DisableClipDistances(); } - // #119-residual [viewer] probe state: print-on-change signature so a - // stable climb is silent and every flood/root flip emits exactly one line. - private string? _lastViewerProbeSig; - // #127: previous frame's admitted cell set — the [viewer-diff] line names - // exactly which cells entered/left the flood when the signature changes. - private readonly HashSet _lastViewerFloodCells = new(); - private readonly List _viewerDiffScratch = new(); - - private void EmitRetailPViewDiagnostics( - AcDream.App.Rendering.RetailPViewFrameResult result, - LoadedCell clipRoot, - uint viewerCellId, - uint playerCellId, - System.Numerics.Vector3 camPos, - System.Numerics.Vector3 playerViewPos) - { - // #119-residual: the capture half of the tower capture→replay loop. - // One line per change of (root, flood, outPolys, playerCell); the eye - // at mm precision rides every line so the harness can replay the - // exact production (eye, root) pairs. ACDREAM_PROBE_VIEWER=1. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeViewerEnabled) - { - string sig = System.FormattableString.Invariant( - $"root=0x{clipRoot.CellId:X8}{(clipRoot.IsOutdoorNode ? "(OUT)" : "")} flood={result.PortalFrame.OrderedVisibleCells.Count} outPolys={result.PortalFrame.OutsideView.Polygons.Count} pCell=0x{playerCellId:X8}"); - if (sig != _lastViewerProbeSig) - { - _lastViewerProbeSig = sig; - // fwd = camera forward (3rd view column negated) — required to - // REPLAY a captured frame headlessly: Build's clip results - // depend on the view-projection, not just the eye. - var v = _cameraController?.Active.View ?? System.Numerics.Matrix4x4.Identity; - Console.WriteLine(System.FormattableString.Invariant( - $"[viewer] {sig} eye=({camPos.X:F3},{camPos.Y:F3},{camPos.Z:F3}) fwd=({-v.M13:F4},{-v.M23:F4},{-v.M33:F4}) viewerCell=0x{viewerCellId:X8}")); - - // #127 [viewer-diff]: name the cells that entered/left since the - // last emitted signature — the bistable admission self-attributes. - _viewerDiffScratch.Clear(); - var cur = result.PortalFrame.OrderedVisibleCells; - var sb = new System.Text.StringBuilder(96); - sb.Append("[viewer-diff] added=["); - bool first = true; - foreach (uint c in cur) - { - if (_lastViewerFloodCells.Contains(c)) continue; - if (!first) sb.Append(','); - sb.Append("0x").Append(c.ToString("X8")); - first = false; - } - sb.Append("] removed=["); - first = true; - foreach (uint c in _lastViewerFloodCells) - { - bool present = false; - for (int ci = 0; ci < cur.Count; ci++) - if (cur[ci] == c) { present = true; break; } - if (present) continue; - if (!first) sb.Append(','); - sb.Append("0x").Append(c.ToString("X8")); - first = false; - } - sb.Append(']'); - Console.WriteLine(sb.ToString()); - _lastViewerFloodCells.Clear(); - foreach (uint c in cur) _lastViewerFloodCells.Add(c); - } - } - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) - AcDream.Core.Rendering.RenderingDiagnostics.EmitVis( - clipRoot.CellId, - result.PortalFrame.OrderedVisibleCells, - result.PortalFrame.OutsideView.Polygons.Count, - result.ClipAssembly.OutsidePlaneCount, - result.ClipAssembly.PerCellPlaneCounts, - result.ClipAssembly.ScissorFallbacks); - - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) - { - bool eyeInRoot = CellVisibility.PointInCell(camPos, clipRoot); - bool playerInRoot = CellVisibility.PointInCell(playerViewPos, clipRoot); - Console.WriteLine( - $"[flap-cam] root=0x{clipRoot.CellId:X8} viewerCell=0x{viewerCellId:X8} playerCell=0x{playerCellId:X8} " + - $"res={_cellVisibility.LastCameraCellResolution} " + - $"eyeInRoot={(eyeInRoot ? "Y" : "n")} playerInRoot={(playerInRoot ? "Y" : "n")} " + - $"eye=({camPos.X:F2},{camPos.Y:F2},{camPos.Z:F2}) " + - $"player=({playerViewPos.X:F2},{playerViewPos.Y:F2},{playerViewPos.Z:F2}) " + - $"terrain={result.ClipAssembly.TerrainMode} outVisible={result.ClipAssembly.OutdoorVisible}"); - } - } - - // §4 flap apparatus (2026-06-09): GL-state tripwire. Snapshots the fixed-function - // state entering the world passes; prints [gl-state] only when it CHANGES from the - // previous frame. Every CPU-side input is probe-exonerated for the outdoor - // full-world flap; this pins or refutes the leaked-GL-state family - // (feedback_render_self_contained_gl_state — 3 prior hits). Throwaway. - private string? _lastGlStateSig; - private long _glStateFrame; - private long _glStateStable; - - private void EmitGlStateTripwireIfChanged() - { - var gl = _gl; - if (gl is null) return; - - _glStateFrame++; - Span sbox = stackalloc int[4]; - Span vp = stackalloc int[4]; - gl.GetInteger(GetPName.ScissorBox, sbox); - gl.GetInteger(GetPName.Viewport, vp); - - int clipBits = 0; - for (int i = 0; i < ClipFrame.MaxPlanes; i++) - if (gl.IsEnabled(EnableCap.ClipDistance0 + i)) clipBits |= 1 << i; - - var err = gl.GetError(); - // All-integer fields — culture-safe with plain interpolation. - string sig = - $"depth={(gl.IsEnabled(EnableCap.DepthTest) ? 1 : 0)} " + - $"dmask={(gl.GetBoolean(GetPName.DepthWritemask) ? 1 : 0)} " + - $"dfunc=0x{gl.GetInteger(GetPName.DepthFunc):X} " + - $"blend={(gl.IsEnabled(EnableCap.Blend) ? 1 : 0)} " + - $"bsrc=0x{gl.GetInteger(GetPName.BlendSrcRgb):X} bdst=0x{gl.GetInteger(GetPName.BlendDstRgb):X} " + - $"cull={(gl.IsEnabled(EnableCap.CullFace) ? 1 : 0)} cmode=0x{gl.GetInteger(GetPName.CullFaceMode):X} " + - $"fface=0x{gl.GetInteger(GetPName.FrontFace):X} " + - $"scis={(gl.IsEnabled(EnableCap.ScissorTest) ? 1 : 0)} sbox=({sbox[0]},{sbox[1]},{sbox[2]},{sbox[3]}) " + - $"vp=({vp[0]},{vp[1]},{vp[2]},{vp[3]}) " + - $"fbo={gl.GetInteger(GetPName.DrawFramebufferBinding)} " + - $"a2c={(gl.IsEnabled(EnableCap.SampleAlphaToCoverage) ? 1 : 0)} " + - $"stencil={(gl.IsEnabled(EnableCap.StencilTest) ? 1 : 0)} " + - $"clip=0x{clipBits:X2} err=0x{(int)err:X}"; - - if (sig == _lastGlStateSig) - { - _glStateStable++; - return; - } - - Console.WriteLine($"[gl-state] frame={_glStateFrame} stable={_glStateStable} {sig}"); - _lastGlStateSig = sig; - _glStateStable = 0; - } - - // §4 flap [clip-route-scis] probe (2026-06-10, throwaway): the ACTUAL GL scissor state - // the landscape pass (sky + terrain + outdoor entities + the player) draws under, read - // back right after BeginDoorwayScissor. The whole pass is scissored to slice.NdcAabb — - // if the box reads doorway-sized here, the full-world flap is the scissor by - // construction, no RenderDoc needed. Print-on-change. - private string? _lastClipRouteScisSig; - private long _clipRouteScisSeq; - - private void EmitClipRouteScissorProbe(bool applied, System.Numerics.Vector4 ndcAabb) - { - var gl = _gl; - if (gl is null) return; - Span sbox = stackalloc int[4]; - gl.GetInteger(GetPName.ScissorBox, sbox); - bool enabled = gl.IsEnabled(EnableCap.ScissorTest); - string sig = System.FormattableString.Invariant( - $"applied={(applied ? 1 : 0)} scis={(enabled ? 1 : 0)} box=({sbox[0]},{sbox[1]},{sbox[2]},{sbox[3]}) ndc=({ndcAabb.X:F3},{ndcAabb.Y:F3},{ndcAabb.Z:F3},{ndcAabb.W:F3})"); - _clipRouteScisSeq++; - if (sig == _lastClipRouteScisSig) - return; - _lastClipRouteScisSig = sig; - Console.WriteLine($"[clip-route-scis] n={_clipRouteScisSeq} {sig}"); - } - private void EnableClipDistances() { for (int i = 0; i < ClipFrame.MaxPlanes; i++) @@ -5869,33 +5237,8 @@ public sealed class GameWindow : IDisposable _debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}"); } - /// - /// Yields the registered DebugPanel(s) so F1 can flip their - /// visibility. Returns nothing when devtools are off. - /// - private IEnumerable EnumerateDebugPanel() - { - // The current ImGuiPanelHost only exposes Register/Unregister, - // not enumerate. We track the DebugPanel through the VM presence - // — the panel is registered iff _debugVm is non-null. Look it - // up via the panel ID convention. - // Defer the actual lookup to the panel host once it grows an - // accessor; for now, no-op when devtools are off. - if (_debugPanel is not null) yield return _debugPanel; - } - - // Cached panel reference so EnumerateDebugPanel can return it. Set - // in the DevToolsEnabled construction block above; null otherwise. - private AcDream.UI.Abstractions.Panels.Debug.DebugPanel? _debugPanel; - - // Cached chat-panel reference so the dispatcher's ToggleChatEntry - // (Tab) handler can call FocusInput() programmatically. Set in the - // DevToolsEnabled construction block; null otherwise. - private AcDream.UI.Abstractions.Panels.Chat.ChatPanel? _chatPanel; - - // Phase K.3 — Settings panel (click-to-rebind keymap UI). Hidden by - // default; F11 / View → Settings toggles. Null when devtools are off. - private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel; + // Phase K.3 settings state remains runtime-owned; the presenter owns the + // optional SettingsPanel and its visibility/input operations. private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm; // L.0: settings.json store + active toon key. The store is held as // a field so ApplyLiveSessionEnteredWorld can re-load the chosen toon's @@ -6153,57 +5496,10 @@ public sealed class GameWindow : IDisposable // current pos+size, which ImGuiNET doesn't expose by name. // Force-reset is acceptable UX because resizing happens rarely // and the user can always drag panels back where they want. - if (DevToolsEnabled && _imguiBootstrap is not null) - ResetPanelLayout(ImGuiNET.ImGuiCond.Always); - } - - /// - /// L.0 Display tab: position every registered panel to its default - /// landing spot, computed relative to the current window size so - /// the layout adapts to any resolution. Called from: - /// - /// OnFramebufferResize (cond=Always — force-reset on resize). - /// The View → "Reset window layout" menu item (cond=Always). - /// OnLoad after panel registration (cond=FirstUseEver — only - /// applies when imgui.ini has no saved position for that - /// panel; on subsequent launches the saved positions win). - /// - /// - private void ResetPanelLayout(ImGuiNET.ImGuiCond cond) - { - if (_window is null) return; - float w = _window.Size.X; - float h = _window.Size.Y; - // Sane minimums so the math doesn't blow up on a tiny window. - if (w < 480) w = 480; - if (h < 320) h = 320; - - // Panel positions chosen to be classic-MMO discoverable on a - // 1280x720 window: vitals top-left under the menu bar, chat - // bottom-left, debug top-right, settings centered. All sizes - // are reasonable defaults the user can resize from. - SetPanelLayout(_vitalsPanel?.Title, new System.Numerics.Vector2(10f, 30f), - new System.Numerics.Vector2(220f, 110f), cond); - SetPanelLayout(_chatPanel?.Title, new System.Numerics.Vector2(10f, h - 320f), - new System.Numerics.Vector2(450f, 300f), cond); - SetPanelLayout(_debugPanel?.Title, new System.Numerics.Vector2(w - 380f, 30f), - new System.Numerics.Vector2(370f, 520f), cond); - SetPanelLayout(_settingsPanel?.Title, new System.Numerics.Vector2((w - 700f) * 0.5f, (h - 500f) * 0.5f), - new System.Numerics.Vector2(700f, 500f), cond); - } - - private static void SetPanelLayout( - string? title, - System.Numerics.Vector2 pos, - System.Numerics.Vector2 size, - ImGuiNET.ImGuiCond cond) - { - if (string.IsNullOrEmpty(title)) return; - // SetWindowPos/SetWindowSize by name work even when the window - // has never been Begin'd — ImGui stores the value for next - // appearance. - ImGuiNET.ImGui.SetWindowPos(title, pos, cond); - ImGuiNET.ImGui.SetWindowSize(title, size, cond); + _devToolsFramePresenter?.ResetLayout( + newSize.X, + newSize.Y, + AcDream.App.Rendering.DevToolsPanelLayoutCondition.Always); } /// @@ -6280,9 +5576,6 @@ public sealed class GameWindow : IDisposable && width > 0 && height > 0; } - // Vitals panel reference cached for the View menu's toggle entry. - private AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel? _vitalsPanel; - // ── K.1b: dispatcher action handler ────────────────────────────────── // // SINGLE place where every game-side keyboard/mouse-button reaction @@ -6386,11 +5679,7 @@ public sealed class GameWindow : IDisposable break; case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel: - foreach (var panel in EnumerateDebugPanel()) - { - panel.IsVisible = !panel.IsVisible; - _debugVm?.AddToast($"Debug panel {(panel.IsVisible ? "ON" : "OFF")}"); - } + _devToolsFramePresenter?.ToggleDebugPanel(); break; case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleCollisionWires: @@ -6434,19 +5723,18 @@ public sealed class GameWindow : IDisposable case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry: // K.2: Tab focuses the chat input. ChatPanel.FocusInput() // sets a one-shot flag that emits SetKeyboardFocusHere on - // the next render. No-op when devtools/_chatPanel is null + // the next render. No-op when the devtools presenter is absent // (offline / non-devtools build) — the dispatcher still // logs the action via the [input] diagnostic above so the // path is observable in either case. - _chatPanel?.FocusInput(); + _devToolsFramePresenter?.FocusChatInput(); break; case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel: // K.3: F11 toggles the Settings panel. Null-safe vs. // devtools-off / panel-not-registered — the [input] log // line above still records the press regardless. - if (_settingsPanel is not null) - _settingsPanel.IsVisible = !_settingsPanel.IsVisible; + _devToolsFramePresenter?.ToggleSettingsPanel(); break; case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat: @@ -6687,33 +5975,27 @@ public sealed class GameWindow : IDisposable } } - private void MaybeFlushTerrainDiag() + private void PublishTerrainDrawDiagnostics() { - if (!string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal)) + long now = _frameDiag ? Environment.TickCount64 : 0L; + _worldRenderDiagnostics?.EndTerrainDraw(); + if (!_frameDiag || now - _frameDiagLastPublicationMilliseconds <= 5000) return; - long now = Environment.TickCount64; - if (now - _terrainLastDiagTick <= 5000) return; + // Preserve the original one-timestamp diagnostic transaction. A + // failure in either write leaves the shared cadence uncommitted, so + // the next terrain draw retries both TERRAIN and FRAME diagnostics. + _worldRenderDiagnostics?.PublishTerrainDiagnostics( + new AcDream.App.Rendering.TerrainRenderDiagnosticFacts( + _terrain?.VisibleSlots ?? 0, + _terrain?.LoadedSlots ?? 0, + _terrain?.CapacitySlots ?? 0)); + PublishFrameDiagnostics(); + _frameDiagLastPublicationMilliseconds = now; + } - // Samples are stored as microseconds × 100 (so 1.23 µs becomes 123 long). - long cpuMedHundredthsUs = TerrainDiagMedianMicros(_terrainCpuSamples); - long cpuP95HundredthsUs = TerrainDiagPercentile95Micros(_terrainCpuSamples); - double cpuMedUs = cpuMedHundredthsUs / 100.0; - double cpuP95Us = cpuP95HundredthsUs / 100.0; - // A.5 T23: flag when terrain dispatcher median exceeds 1.0ms budget - // (Phase A.5 spec §2 acceptance criterion 6). Grep-friendly prefix. - const double TerrainBudgetUs = 1000.0; - string terrainBudgetFlag = cpuMedUs > TerrainBudgetUs ? " BUDGET_OVER" : ""; - Console.WriteLine( - $"[TERRAIN-DIAG]{terrainBudgetFlag} cpu_us={cpuMedUs:F2}m/{cpuP95Us:F2}p95 " + - $"draws={_terrain?.VisibleSlots ?? 0}/frame " + - $"visible={_terrain?.VisibleSlots ?? 0} " + - $"loaded={_terrain?.LoadedSlots ?? 0} " + - $"capacity={_terrain?.CapacitySlots ?? 0}"); - - // Publication metrics are cumulative typed snapshots from the focused - // owners. The only GameWindow-local distribution left is the render- - // thread entity upload drain. + private void PublishFrameDiagnostics() + { AcDream.App.Streaming.LandblockPresentationDiagnostics presentation = _landblockPresentationPipeline?.Diagnostics ?? default; AcDream.App.Streaming.LandblockRenderPublisherDiagnostics render = @@ -6724,10 +6006,12 @@ public sealed class GameWindow : IDisposable presentation.Statics; double ticksToMicros = 1_000_000.0 / System.Diagnostics.Stopwatch.Frequency; - double entUplMedUs = TerrainDiagMedianMicros(_entityUploadSamples) / 100.0; - double entUplP95Us = TerrainDiagPercentile95Micros(_entityUploadSamples) / 100.0; + AcDream.App.Rendering.RollingTimingPercentiles upload = + _entityUploadTiming.Snapshot(); + double uploadMedian = upload.MedianHundredthsMicroseconds / 100.0; + double uploadP95 = upload.Percentile95HundredthsMicroseconds / 100.0; int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0; - Console.WriteLine( + _renderDiagnosticLog.WriteLine( $"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} " + $"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} " + $"begin={render.BeginPublishTicks * ticksToMicros:F1} " + @@ -6737,53 +6021,12 @@ public sealed class GameWindow : IDisposable $"complete={physics.CompletePublishTicks * ticksToMicros:F1}] " + $"static={statics.BeginCount}/{statics.CompleteCount}" + $"(active={statics.ActiveEntityCount}) " + - $"entUpl_us={entUplMedUs:F1}m/{entUplP95Us:F1}p95 " + + $"entUpl_us={uploadMedian:F1}m/{uploadP95:F1}p95 " + $"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " + $"fullRetire={_streamingController?.FullWindowRetirementCount ?? 0}" + $"(landblocks={_streamingController?.LastFullWindowRetirementLandblockCount ?? 0}) " + $"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " + $"resident={_worldState.Entities.Count} walked={walked}"); - - _terrainLastDiagTick = now; - } - - private static long TerrainDiagMedianMicros(long[] samples) - { - var copy = (long[])samples.Clone(); - Array.Sort(copy); - int nz = 0; - foreach (var v in copy) if (v > 0) nz++; - if (nz == 0) return 0; - // Sorted ascending: zero-padding at the front, samples at the back. - // Median of nz samples is the middle of the last nz entries; using - // (nz - 1) / 2 from the end keeps the offset >= 0 for all nz >= 1 - // (the original nz / 2 form underflowed to copy.Length on first - // diag-flush when only 1 sample had been recorded). - return copy[copy.Length - 1 - (nz - 1) / 2]; - } - - private static long TerrainDiagPercentile95Micros(long[] samples) - { - var copy = (long[])samples.Clone(); - Array.Sort(copy); - int nz = 0; - foreach (var v in copy) if (v > 0) nz++; - if (nz == 0) return 0; - // 95th percentile = upper end of the sorted samples; clamp the - // offset to stay inside the populated tail when nz < 20. - int offset = (int)((nz - 1) * 0.05); - return copy[copy.Length - 1 - offset]; - } - - /// [FRAME-DIAG] helper: convert a - /// timestamp-tick delta to the microseconds×100 fixed-point used by the rolling - /// sample rings and store it (zeros are ignored by the median/p95 helpers, so an - /// idle frame's 0-sample doesn't dilute the non-zero cost distribution). - private static void FrameDiagPush(long[] samples, ref int cursor, long ticks) - { - // µs×100 = ticks × 1e8 / Stopwatch.Frequency. - samples[cursor] = (long)(ticks * 100_000_000.0 / System.Diagnostics.Stopwatch.Frequency); - cursor = (cursor + 1) % samples.Length; } /// A.5 T22: parse a float environment variable, returning @@ -6922,6 +6165,7 @@ public sealed class GameWindow : IDisposable { _paperdollViewportRenderer?.Dispose(); _paperdollViewportRenderer = null; + _paperdollFramePresenter = null; }), new("mesh draw dispatcher", () => _wbDrawDispatcher?.Dispose()), new("environment cells", () => _envCellRenderer?.Dispose()), diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs new file mode 100644 index 00000000..1f8dc509 --- /dev/null +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -0,0 +1,309 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.UI; +using AcDream.App.World; +using AcDream.Content; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter; + +namespace AcDream.App.Rendering; + +internal interface IPaperdollDollRenderer +{ + void SetDoll(WorldEntity? doll); + + uint Render(int width, int height); +} + +internal interface IPaperdollFrameView +{ + bool TryGetVisibleSize(out int width, out int height); + + void SetTextureHandle(uint textureHandle); +} + +internal interface IPaperdollInventoryVisibility +{ + bool IsVisible { get; } +} + +internal interface IPaperdollDollFactory +{ + bool TryBuild(out WorldEntity? doll); +} + +internal interface IPaperdollEntityLookup +{ + bool TryGet(uint serverGuid, out WorldEntity player); +} + +internal interface IPaperdollPoseApplicator +{ + void Apply(WorldEntity doll, uint setupId); +} + +/// +/// Owns paperdoll dirty/rebuild state and the private FBO presentation edge. +/// The GL renderer remains a borrowed resource disposed by the existing window +/// shutdown transaction. +/// +internal sealed class PaperdollFramePresenter +{ + private readonly IPaperdollDollRenderer _renderer; + private readonly IPaperdollFrameView _view; + private readonly IPaperdollDollFactory _factory; + private bool _dirty = true; + + public PaperdollFramePresenter( + IPaperdollDollRenderer renderer, + IPaperdollFrameView view, + IPaperdollDollFactory factory) + { + _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); + _view = view ?? throw new ArgumentNullException(nameof(view)); + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + internal bool IsDirty => _dirty; + + public void MarkDirty() => _dirty = true; + + public void Render() + { + if (!_view.TryGetVisibleSize(out int width, out int height)) + return; + + if (_dirty) + { + if (_factory.TryBuild(out WorldEntity? doll)) + { + _renderer.SetDoll(doll); + _dirty = false; + } + else + { + // Retail's private viewport remains present while the live + // player projection is still materializing. Keep the dirty + // edge armed and clear any doll from the prior session. + _renderer.SetDoll(null); + } + } + + _view.SetTextureHandle(_renderer.Render(width, height)); + } +} + +/// Retained-UI visibility and texture publication for the doll view. +internal sealed class RetailPaperdollFrameView : IPaperdollFrameView +{ + private readonly UiViewport _viewport; + private readonly IPaperdollInventoryVisibility _inventory; + + public RetailPaperdollFrameView( + UiViewport viewport, + IPaperdollInventoryVisibility inventory) + { + _viewport = viewport ?? throw new ArgumentNullException(nameof(viewport)); + _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); + } + + public bool TryGetVisibleSize(out int width, out int height) + { + width = 0; + height = 0; + if (!_viewport.Visible || !_inventory.IsVisible) + { + return false; + } + + width = (int)_viewport.Width; + height = (int)_viewport.Height; + return true; + } + + public void SetTextureHandle(uint textureHandle) => + _viewport.TextureHandle = textureHandle; +} + +/// Narrow visibility adapter for the paperdoll's inventory host. +internal sealed class PaperdollInventoryVisibility : IPaperdollInventoryVisibility +{ + private readonly UiElement _inventoryFrame; + + public PaperdollInventoryVisibility(UiElement inventoryFrame) + { + _inventoryFrame = inventoryFrame + ?? throw new ArgumentNullException(nameof(inventoryFrame)); + } + + public bool IsVisible => _inventoryFrame.Visible; +} + +/// Canonical live-entity lookup used by the paperdoll factory. +internal sealed class LivePaperdollEntityLookup : IPaperdollEntityLookup +{ + private readonly LiveEntityRuntime _liveEntities; + + public LivePaperdollEntityLookup(LiveEntityRuntime liveEntities) + { + _liveEntities = liveEntities + ?? throw new ArgumentNullException(nameof(liveEntities)); + } + + public bool TryGet(uint serverGuid, out WorldEntity player) => + _liveEntities.TryGetWorldEntity(serverGuid, out player); +} + +/// +/// Builds the static retail paperdoll clone from the canonical live player +/// projection and applies the DAT-defined held pose. +/// +internal sealed class RetailPaperdollDollFactory : IPaperdollDollFactory +{ + private readonly IPaperdollEntityLookup _entities; + private readonly ILocalPlayerIdentitySource _identity; + private readonly IPaperdollPoseApplicator _pose; + + public RetailPaperdollDollFactory( + IPaperdollEntityLookup entities, + ILocalPlayerIdentitySource identity, + IPaperdollPoseApplicator pose) + { + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + _pose = pose ?? throw new ArgumentNullException(nameof(pose)); + } + + public bool TryBuild(out WorldEntity? doll) + { + doll = null; + if (!_entities.TryGet(_identity.ServerGuid, out WorldEntity player) + || player.MeshRefs.Count == 0) + { + return false; + } + + uint? basePalette = null; + List<(uint, byte, byte)>? subPalettes = null; + if (player.PaletteOverride is { } palette) + { + basePalette = palette.BasePaletteId; + subPalettes = new List<(uint, byte, byte)>(); + foreach (var range in palette.SubPalettes) + { + subPalettes.Add(( + range.SubPaletteId, + range.Offset, + range.Length)); + } + } + + List<(byte, uint)>? partOverrides = null; + if (player.PartOverrides.Count > 0) + { + partOverrides = new List<(byte, uint)>(player.PartOverrides.Count); + foreach (var part in player.PartOverrides) + partOverrides.Add((part.PartIndex, part.GfxObjId)); + } + + doll = DollEntityBuilder.Build( + player.SourceGfxObjOrSetupId, + new List(player.MeshRefs), + basePalette, + subPalettes, + partOverrides); + _pose.Apply(doll, player.SourceGfxObjOrSetupId); + return true; + } +} + +/// Applies retail's DAT-defined settled paperdoll stance. +internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator +{ + private readonly IDatReaderWriter _dats; + private readonly IAnimationLoader _animations; + private readonly object _datLock; + + public RetailPaperdollPoseApplicator( + IDatReaderWriter dats, + IAnimationLoader animations, + object datLock) + { + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _animations = animations ?? throw new ArgumentNullException(nameof(animations)); + _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); + } + + /// + /// Retail gmPaperDollUI resolves its held pose with + /// DBCache::GetDIDFromEnumStatic(0x10000005, 7). The master map + /// therefore resolves key 7 to a sub-map, then key 0x10000005 to the + /// Animation DID. + /// + private uint ResolvePoseDid() + { + uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId; + if (masterDid == 0 + || !_dats.Portal.TryGet( + masterDid, + out var master) + || !master.ClientEnumToID.TryGetValue(7u, out uint subDid) + || !_dats.Portal.TryGet( + subDid, + out var sub)) + { + return 0u; + } + + return sub.ClientEnumToID.TryGetValue(0x10000005u, out uint did) + ? did + : 0u; + } + + public void Apply(WorldEntity doll, uint setupId) + { + DatReaderWriter.DBObjs.Animation? animation; + DatReaderWriter.DBObjs.Setup? setup; + lock (_datLock) + { + uint poseDid = ResolvePoseDid(); + if ((poseDid >> 24) != 0x03u) + return; + + animation = _animations.LoadAnimation(poseDid); + setup = _dats.Get(setupId); + } + if (animation is null || setup is null || animation.PartFrames.Count == 0) + return; + + // RedressCreature @ 0x004A3C22 installs the pose with zero frame rate + // and holds the settled final frame. + var frame = animation.PartFrames[^1]; + var reposed = new List(doll.MeshRefs.Count); + for (int index = 0; index < doll.MeshRefs.Count; index++) + { + Vector3 scale = index < setup.DefaultScale.Count + ? setup.DefaultScale[index] + : Vector3.One; + Vector3 origin = Vector3.Zero; + Quaternion orientation = Quaternion.Identity; + if (index < frame.Frames.Count) + { + origin = frame.Frames[index].Origin; + orientation = frame.Frames[index].Orientation; + } + + Matrix4x4 transform = Matrix4x4.CreateScale(scale) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin); + MeshRef source = doll.MeshRefs[index]; + reposed.Add(new MeshRef(source.GfxObjId, transform) + { + SurfaceOverrides = source.SurfaceOverrides, + }); + } + + doll.MeshRefs = reposed; + } +} diff --git a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs index d432eba7..258d79bb 100644 --- a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs @@ -12,22 +12,24 @@ namespace AcDream.App.Rendering; /// /// Render-to-texture renderer for the paperdoll 3-D doll (the C# analog of retail /// CreatureMode::Render). Draws ONE re-dressed player clone (a , -/// built by + wired by GameWindow) into a private off-screen +/// built by + wired by ) into a private off-screen /// framebuffer with a fixed + one distant light, then hands the color /// texture to the widget which blits it as a normal 2-D sprite. /// /// The whole 3-D pass is sealed in a so it can't disturb the /// surrounding world / UI GL state ([[feedback_render_self_contained_gl_state]]). It runs in the -/// per-frame pre-UI hook (GameWindow), gated on inventory-open ∧ doll-view — NOT from the widget's +/// per-frame pre-UI hook, gated on inventory-open ∧ doll-view — NOT from the widget's /// 2-D OnDraw. /// /// Reuses the existing + the player's already-resolved, /// already-GPU-resident MeshRefs (the doll clones them), so no separate mesh/texture upload is /// needed. With frustum: null the doll's synthetic landblock is always "visible" and the -/// entity is walked from entry.Entities and drawn from its current MeshRefs — a STATIC pose -/// for now; idle animation (TickAnimations rebuilding the doll's MeshRefs) is a later slice. +/// entity is walked from entry.Entities and drawn from its current MeshRefs — a static held pose. /// -public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDisposable +public sealed unsafe class PaperdollViewportRenderer : + IUiViewportRenderer, + IPaperdollDollRenderer, + IDisposable { private readonly GL _gl; private readonly WbDrawDispatcher _dispatcher; @@ -42,8 +44,7 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis private int _fbW; private int _fbH; - // The doll WorldEntity (held by reference: when GameWindow's TickAnimations later rebuilds its - // MeshRefs each frame, this renderer sees the updated pose automatically). null = nothing to draw. + // The static held-pose doll. null = nothing to draw. private WorldEntity? _doll; // Synthetic landblock for the doll's one-entry Draw tuple. With frustum:null + this as @@ -70,7 +71,7 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis DollEntityBuilder.DollRenderId); } - /// Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player. + /// Set (or clear) the doll entity built from the live player projection. public void SetDoll(WorldEntity? doll) { if (ReferenceEquals(_doll, doll)) diff --git a/src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs b/src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs new file mode 100644 index 00000000..49cdcbdc --- /dev/null +++ b/src/AcDream.App/Rendering/RenderFrameDiagnosticSources.cs @@ -0,0 +1,168 @@ +using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using Silk.NET.Windowing; + +namespace AcDream.App.Rendering; + +internal sealed class RuntimeRenderFrameTitleFactsSource : IRenderFrameTitleFactsSource +{ + private readonly GpuWorldState _world; + private readonly LiveEntityAnimationRuntimeView _animations; + private readonly WorldTimeService _time; + + public RuntimeRenderFrameTitleFactsSource( + GpuWorldState world, + LiveEntityAnimationRuntimeView animations, + WorldTimeService time) + { + _world = world ?? throw new ArgumentNullException(nameof(world)); + _animations = animations ?? throw new ArgumentNullException(nameof(animations)); + _time = time ?? throw new ArgumentNullException(nameof(time)); + } + + public RenderFrameTitleFacts Capture() => new( + _world.Entities.Count, + _animations.Count, + DerethDateTime.ToCalendar(_time.NowTicks), + _time.DayFraction); +} + +internal sealed class SilkRenderFrameTitleSink : IRenderFrameTitleSink +{ + private readonly IWindow _window; + + public SilkRenderFrameTitleSink(IWindow window) => + _window = window ?? throw new ArgumentNullException(nameof(window)); + + public void SetTitle(string title) => _window.Title = title; +} + +/// +/// Read-only sampler for the explicit, low-frequency GPU-stream diagnostic. +/// It borrows canonical owners and returns one immutable value snapshot. +/// +internal sealed class RuntimeRenderFrameResourceDiagnosticsSource : + IRenderFrameResourceDiagnosticsSource +{ + private readonly ParticleSystem? _particles; + private readonly ParticleHookSink? _particleBindings; + private readonly WbDrawDispatcher? _worldDispatcher; + private readonly Wb.EnvCellRenderer? _environmentCells; + private readonly ParticleRenderer? _particleRenderer; + private readonly TextRenderer? _uiTextRenderer; + private readonly PortalDepthMaskRenderer? _portalDepthMask; + private readonly ClipFrame? _clipFrame; + private readonly TerrainModernRenderer? _terrain; + private readonly SceneLightingUboBinding? _lighting; + private readonly WbMeshAdapter? _meshes; + private readonly TextureCache? _textures; + + public RuntimeRenderFrameResourceDiagnosticsSource( + ParticleSystem? particles, + ParticleHookSink? particleBindings, + WbDrawDispatcher? worldDispatcher, + Wb.EnvCellRenderer? environmentCells, + ParticleRenderer? particleRenderer, + TextRenderer? uiTextRenderer, + PortalDepthMaskRenderer? portalDepthMask, + ClipFrame? clipFrame, + TerrainModernRenderer? terrain, + SceneLightingUboBinding? lighting, + WbMeshAdapter? meshes, + TextureCache? textures) + { + _particles = particles; + _particleBindings = particleBindings; + _worldDispatcher = worldDispatcher; + _environmentCells = environmentCells; + _particleRenderer = particleRenderer; + _uiTextRenderer = uiTextRenderer; + _portalDepthMask = portalDepthMask; + _clipFrame = clipFrame; + _terrain = terrain; + _lighting = lighting; + _meshes = meshes; + _textures = textures; + } + + public RenderFrameResourceDiagnosticsSnapshot Capture() + { + (int particleSets, long particleBytes) = + _particleRenderer?.DynamicBufferDiagnostics ?? default; + var meshManager = _meshes?.MeshManager; + var mesh = meshManager?.Diagnostics ?? default; + var globalMesh = meshManager?.GlobalBuffer; + var cpuMesh = _meshes?.CpuMeshCacheDiagnostics ?? default; + GCMemoryInfo gc = GC.GetGCMemoryInfo(); + + return new RenderFrameResourceDiagnosticsSnapshot( + new VfxStreamResourceDiagnostics( + _particles?.ActiveEmitterCount ?? 0, + _particles?.ActiveParticleCount ?? 0, + _particleBindings?.ActiveBindingCount ?? 0), + new DynamicBufferResourceDiagnostics( + _worldDispatcher?.DynamicBufferSetCount ?? 0, + _environmentCells?.DynamicBufferSetCount ?? 0, + particleSets, + particleBytes, + _uiTextRenderer?.DynamicBufferCapacityBytes ?? 0, + _portalDepthMask?.DynamicBufferCapacityBytes ?? 0, + _clipFrame?.DynamicBufferSetCount ?? 0, + _terrain?.DynamicIndirectBufferCount ?? 0, + _lighting?.DynamicBufferCount ?? 0), + new MeshStreamResourceDiagnostics( + mesh.RenderData, + mesh.AtlasArrays, + mesh.UnusedLru, + mesh.EstimatedBytes, + globalMesh?.UploadCount ?? 0, + globalMesh?.UploadedBytes ?? 0, + _meshes?.LastUploadCount ?? 0, + _meshes?.LastUploadBytes ?? 0, + _meshes?.LastArrayAllocationBytes ?? 0, + _meshes?.LastPlannedMipmapBytes ?? 0, + _meshes?.LastNewArrayCount ?? 0, + _meshes?.LastBufferUploadBytes ?? 0, + _meshes?.LastBufferAllocationBytes ?? 0, + _meshes?.LastBufferCopyBytes ?? 0, + _meshes?.LastNewBufferCount ?? 0, + _meshes?.StagedUploadBacklog ?? 0, + _meshes?.StagedUploadBytes ?? 0, + _meshes?.StagingAtHighWater ?? false, + _meshes?.LastStaleDiscardCount ?? 0, + cpuMesh.Count, + cpuMesh.Bytes, + _meshes?.LastMipmapArrayCount ?? 0, + _meshes?.LastMipmapBytes ?? 0, + globalMesh?.CapacityBytes ?? 0, + globalMesh?.PhysicalCapacityBytes ?? 0, + globalMesh?.IsMigrationInProgress ?? false), + new TextureStreamResourceDiagnostics( + _textures?.OwnedBindlessTextureCount ?? 0, + _textures?.TextureOwnerCount ?? 0, + _textures?.ActiveParticleTextureCount ?? 0, + _textures?.ParticleTextureOwnerCount ?? 0, + _textures?.CachedParticleTextureCount ?? 0, + _textures?.CachedUnownedParticleTextureCount ?? 0, + _textures?.CachedUnownedParticleTextureBytes ?? 0, + _textures?.CachedCompositeTextureCount ?? 0, + _textures?.CachedUnownedCompositeCount ?? 0, + _textures?.CachedUnownedCompositeBytes ?? 0, + _textures?.CompositeAtlasCount ?? 0, + _textures?.CompositeAtlasBytes ?? 0, + _textures?.CompositeFrameUploadCount ?? 0, + _textures?.CompositeFrameUploadBytes ?? 0, + _worldDispatcher?.LastCompositeWarmupPendingCount ?? 0), + new ProcessResourceDiagnostics( + GC.GetTotalMemory(false), + gc.TotalCommittedBytes, + GpuMemoryTracker.AllocatedBytes, + GpuMemoryTracker.BufferCount, + GpuMemoryTracker.TextureCount)); + } +} diff --git a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs new file mode 100644 index 00000000..bb78cc1c --- /dev/null +++ b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs @@ -0,0 +1,269 @@ +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// Current values published by the successful render-frame cadence. +internal readonly record struct RenderFrameDiagnosticsSnapshot( + double Fps, + double FrameMilliseconds, + int VisibleLandblocks, + int TotalLandblocks, + int EntityCount, + int AnimatedEntityCount) +{ + public static RenderFrameDiagnosticsSnapshot Initial => new( + Fps: 60.0, + FrameMilliseconds: 16.7, + VisibleLandblocks: 0, + TotalLandblocks: 0, + EntityCount: 0, + AnimatedEntityCount: 0); +} + +/// Non-render facts sampled only when the title cadence publishes. +internal readonly record struct RenderFrameTitleFacts( + int EntityCount, + int AnimatedEntityCount, + DerethDateTime.Calendar Calendar, + double DayFraction); + +internal interface IRenderFrameTitleFactsSource +{ + RenderFrameTitleFacts Capture(); +} + +/// Narrow host seam; diagnostics can publish chrome without retaining a window. +internal interface IRenderFrameTitleSink +{ + void SetTitle(string title); +} + +internal interface IRenderFrameDiagnosticLog +{ + void WriteLine(string message); +} + +internal sealed class ConsoleRenderFrameDiagnosticLog : IRenderFrameDiagnosticLog +{ + public void WriteLine(string message) => Console.WriteLine(message); +} + +internal interface IRenderFrameResourceDiagnosticsSource +{ + RenderFrameResourceDiagnosticsSnapshot Capture(); +} + +internal readonly record struct VfxStreamResourceDiagnostics( + int ActiveEmitters, + int ActiveParticles, + int ActiveBindings); + +internal readonly record struct DynamicBufferResourceDiagnostics( + int WorldBufferSets, + int EnvCellBufferSets, + int ParticleBufferSets, + long ParticleBufferBytes, + long UiTextBufferBytes, + long PortalDepthMaskBufferBytes, + int ClipBufferSets, + int TerrainIndirectBuffers, + int SceneLightingBuffers); + +internal readonly record struct MeshStreamResourceDiagnostics( + int RenderData, + int AtlasArrays, + int UnusedLru, + long EstimatedBytes, + long GlobalUploadCount, + long GlobalUploadedBytes, + int FrameUploadCount, + long FrameUploadBytes, + long FrameArrayAllocationBytes, + long FramePlannedMipmapBytes, + int FrameNewArrayCount, + long FrameBufferUploadBytes, + long FrameBufferAllocationBytes, + long FrameBufferCopyBytes, + int FrameNewBufferCount, + int StagedUploadBacklog, + long StagedUploadBytes, + bool StagingAtHighWater, + int FrameStaleDiscardCount, + int CpuMeshCacheCount, + long CpuMeshCacheBytes, + int FrameMipmapArrayCount, + long FrameMipmapBytes, + long GlobalCapacityBytes, + long GlobalPhysicalCapacityBytes, + bool GlobalMigrationInProgress); + +internal readonly record struct TextureStreamResourceDiagnostics( + int OwnedBindlessTextures, + int TextureOwners, + int ActiveParticleTextures, + int ParticleTextureOwners, + int CachedParticleTextures, + int CachedUnownedParticleTextures, + long CachedUnownedParticleTextureBytes, + int CachedCompositeTextures, + int CachedUnownedComposites, + long CachedUnownedCompositeBytes, + int CompositeAtlases, + long CompositeAtlasBytes, + int FrameCompositeUploadCount, + long FrameCompositeUploadBytes, + int CompositeWarmupPending); + +internal readonly record struct ProcessResourceDiagnostics( + long ManagedBytes, + long ManagedCommittedBytes, + long TrackedGpuBytes, + int TrackedGpuBuffers, + int TrackedGpuTextures); + +/// +/// Immutable resource facts captured only when explicit UI-probe dumping is enabled. +/// Grouping keeps the diagnostics controller independent from every canonical renderer, +/// VFX, mesh, texture, and process owner used to produce the values. +/// +internal readonly record struct RenderFrameResourceDiagnosticsSnapshot( + VfxStreamResourceDiagnostics Vfx, + DynamicBufferResourceDiagnostics DynamicBuffers, + MeshStreamResourceDiagnostics Mesh, + TextureStreamResourceDiagnostics Textures, + ProcessResourceDiagnostics Process); + +/// +/// Publishes post-presentation frame statistics. The caller invokes this only after +/// screenshot capture, preserving the accepted screenshot-before-title/resource order. +/// +internal sealed class RenderFrameDiagnosticsController : IRenderFrameDiagnosticsPhase +{ + internal const double PublicationIntervalSeconds = 0.5; + + private readonly IRenderFrameTitleFactsSource _titleFacts; + private readonly IRenderFrameTitleSink _titleSink; + private readonly IRenderFrameResourceDiagnosticsSource? _resources; + private readonly IRenderFrameDiagnosticLog _log; + private readonly bool _publishResourceDiagnostics; + + private double _elapsedSeconds; + private int _frameCount; + + public RenderFrameDiagnosticsSnapshot Snapshot { get; private set; } = + RenderFrameDiagnosticsSnapshot.Initial; + + public RenderFrameDiagnosticsController( + IRenderFrameTitleFactsSource titleFacts, + IRenderFrameTitleSink titleSink, + IRenderFrameDiagnosticLog log, + bool publishResourceDiagnostics, + IRenderFrameResourceDiagnosticsSource? resources = null) + { + _titleFacts = titleFacts ?? throw new ArgumentNullException(nameof(titleFacts)); + _titleSink = titleSink ?? throw new ArgumentNullException(nameof(titleSink)); + _log = log ?? throw new ArgumentNullException(nameof(log)); + _publishResourceDiagnostics = publishResourceDiagnostics; + _resources = publishResourceDiagnostics + ? resources ?? throw new ArgumentNullException(nameof(resources)) + : resources; + } + + public void Publish(RenderFrameInput input, RenderFrameOutcome outcome) + { + _elapsedSeconds += input.DeltaSeconds; + _frameCount++; + if (_elapsedSeconds < PublicationIntervalSeconds) + return; + + double averageFrameMilliseconds = _elapsedSeconds / _frameCount * 1000.0; + double fps = _frameCount / _elapsedSeconds; + RenderFrameTitleFacts facts = _titleFacts.Capture(); + + // Keep external publication ahead of the cached values and accumulator reset. + // If either sink fails, the controller retains the accumulated window exactly as + // the previous GameWindow body did and a later successful frame can retry it. + _titleSink.SetTitle(FormatTitle( + fps, + averageFrameMilliseconds, + outcome.World.VisibleLandblocks, + outcome.World.TotalLandblocks, + facts)); + + if (_publishResourceDiagnostics) + { + RenderFrameResourceDiagnosticsSnapshot resources = _resources!.Capture(); + _log.WriteLine(FormatGpuStream(resources)); + } + + Snapshot = new RenderFrameDiagnosticsSnapshot( + fps, + averageFrameMilliseconds, + outcome.World.VisibleLandblocks, + outcome.World.TotalLandblocks, + facts.EntityCount, + facts.AnimatedEntityCount); + _elapsedSeconds = 0; + _frameCount = 0; + } + + internal static string FormatTitle( + double fps, + double averageFrameMilliseconds, + int visibleLandblocks, + int totalLandblocks, + RenderFrameTitleFacts facts) => + $"acdream | {fps:F0} fps | {averageFrameMilliseconds:F1} ms | " + + $"lb {visibleLandblocks}/{totalLandblocks} | " + + $"ent {facts.EntityCount}/anim {facts.AnimatedEntityCount} | " + + $"PY{facts.Calendar.Year} {facts.Calendar.Month} {facts.Calendar.Day} " + + $"{facts.Calendar.Hour} (df={facts.DayFraction:F4})"; + + internal static string FormatGpuStream(RenderFrameResourceDiagnosticsSnapshot snapshot) + { + VfxStreamResourceDiagnostics vfx = snapshot.Vfx; + DynamicBufferResourceDiagnostics buffers = snapshot.DynamicBuffers; + MeshStreamResourceDiagnostics mesh = snapshot.Mesh; + TextureStreamResourceDiagnostics textures = snapshot.Textures; + ProcessResourceDiagnostics process = snapshot.Process; + return + $"[gpu-stream] emit={vfx.ActiveEmitters} " + + $"particles={vfx.ActiveParticles} " + + $"bindings={vfx.ActiveBindings} " + + $"wbSets={buffers.WorldBufferSets} " + + $"cellSets={buffers.EnvCellBufferSets} " + + $"particleSets={buffers.ParticleBufferSets}/{buffers.ParticleBufferBytes}B " + + $"uiBytes={buffers.UiTextBufferBytes} " + + $"portalBytes={buffers.PortalDepthMaskBufferBytes} " + + $"clipSets={buffers.ClipBufferSets} " + + $"terrainBuffers={buffers.TerrainIndirectBuffers} " + + $"lightBuffers={buffers.SceneLightingBuffers} " + + $"mesh={mesh.RenderData}/{mesh.AtlasArrays}/{mesh.UnusedLru} " + + $"meshEst={mesh.EstimatedBytes} " + + $"meshUpload={mesh.GlobalUploadCount}/{mesh.GlobalUploadedBytes} " + + $"uploadFrame={mesh.FrameUploadCount}/{mesh.FrameUploadBytes}/" + + $"{mesh.FrameArrayAllocationBytes}/{mesh.FramePlannedMipmapBytes}/" + + $"{mesh.FrameNewArrayCount} " + + $"bufferFrame={mesh.FrameBufferUploadBytes}/{mesh.FrameBufferAllocationBytes}/" + + $"{mesh.FrameBufferCopyBytes}/{mesh.FrameNewBufferCount} " + + $"staging={mesh.StagedUploadBacklog}/{mesh.StagedUploadBytes}/" + + $"{mesh.StagingAtHighWater}/{mesh.FrameStaleDiscardCount} " + + $"cpuMesh={mesh.CpuMeshCacheCount}/{mesh.CpuMeshCacheBytes} " + + $"mipmapFrame={mesh.FrameMipmapArrayCount}/{mesh.FrameMipmapBytes} " + + $"meshCap={mesh.GlobalCapacityBytes} " + + $"meshPhys={mesh.GlobalPhysicalCapacityBytes}/{mesh.GlobalMigrationInProgress} " + + $"gpuTrack={process.TrackedGpuBytes}/{process.TrackedGpuBuffers}/" + + $"{process.TrackedGpuTextures} " + + $"managed={process.ManagedBytes}/{process.ManagedCommittedBytes} " + + $"ownedTextures={textures.OwnedBindlessTextures}/{textures.TextureOwners} " + + $"particleTextures={textures.ActiveParticleTextures}/" + + $"{textures.ParticleTextureOwners}/{textures.CachedParticleTextures}/" + + $"{textures.CachedUnownedParticleTextures}/" + + $"{textures.CachedUnownedParticleTextureBytes} " + + $"compositeCache={textures.CachedCompositeTextures}/" + + $"{textures.CachedUnownedComposites}/{textures.CachedUnownedCompositeBytes} " + + $"compositeAtlas={textures.CompositeAtlases}/{textures.CompositeAtlasBytes} " + + $"compositeUpload={textures.FrameCompositeUploadCount}/" + + $"{textures.FrameCompositeUploadBytes}/{textures.CompositeWarmupPending}"; + } +} diff --git a/src/AcDream.App/Rendering/RollingTimingSampleWindow.cs b/src/AcDream.App/Rendering/RollingTimingSampleWindow.cs new file mode 100644 index 00000000..c8eb62ce --- /dev/null +++ b/src/AcDream.App/Rendering/RollingTimingSampleWindow.cs @@ -0,0 +1,54 @@ +namespace AcDream.App.Rendering; + +internal readonly record struct RollingTimingPercentiles( + long MedianHundredthsMicroseconds, + long Percentile95HundredthsMicroseconds); + +/// +/// Fixed-capacity rolling timing distribution. Zero and negative values remain +/// empty padding and do not dilute the diagnostic percentiles. +/// +internal sealed class RollingTimingSampleWindow +{ + private readonly long[] _samples; + private int _cursor; + + public RollingTimingSampleWindow(int capacity) + { + if (capacity <= 0) + throw new ArgumentOutOfRangeException(nameof(capacity)); + _samples = new long[capacity]; + } + + public int Capacity => _samples.Length; + + public void PushHundredthsMicroseconds(long sample) + { + _samples[_cursor] = sample; + _cursor = (_cursor + 1) % _samples.Length; + } + + public void PushStopwatchTicks(long ticks) => + PushHundredthsMicroseconds( + (long)(ticks * 100_000_000.0 / System.Diagnostics.Stopwatch.Frequency)); + + public RollingTimingPercentiles Snapshot() + { + var copy = (long[])_samples.Clone(); + Array.Sort(copy); + int populated = 0; + foreach (long sample in copy) + { + if (sample > 0) + populated++; + } + + if (populated == 0) + return default; + + long median = copy[copy.Length - 1 - (populated - 1) / 2]; + int p95Offset = (int)((populated - 1) * 0.05); + long percentile95 = copy[copy.Length - 1 - p95Offset]; + return new RollingTimingPercentiles(median, percentile95); + } +} diff --git a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs new file mode 100644 index 00000000..57da6334 --- /dev/null +++ b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs @@ -0,0 +1,593 @@ +using System.Numerics; +using System.Diagnostics; +using System.Text; +using AcDream.Core.Vfx; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +internal readonly record struct IntRenderRectangle(int X, int Y, int Width, int Height); + +internal readonly record struct RenderGlStateSnapshot( + bool DepthTest, + bool DepthWrite, + int DepthFunction, + bool Blend, + int BlendSource, + int BlendDestination, + bool CullFace, + int CullMode, + int FrontFace, + bool Scissor, + IntRenderRectangle ScissorBox, + IntRenderRectangle Viewport, + int DrawFramebuffer, + bool AlphaToCoverage, + bool Stencil, + int ClipBits, + int Error); + +internal readonly record struct RenderGlScissorSnapshot( + bool Enabled, + IntRenderRectangle Box); + +internal readonly record struct TerrainRenderDiagnosticFacts( + int VisibleSlots, + int LoadedSlots, + int CapacitySlots); + +internal interface IRenderGlStateReader +{ + RenderGlStateSnapshot CaptureState(); + + RenderGlScissorSnapshot CaptureScissor(); +} + +/// Render-thread GL state reader used only by explicitly enabled probes. +internal sealed class SilkRenderGlStateReader : IRenderGlStateReader +{ + private readonly GL _gl; + + public SilkRenderGlStateReader(GL gl) => + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + + public RenderGlStateSnapshot CaptureState() + { + Span scissor = stackalloc int[4]; + Span viewport = stackalloc int[4]; + _gl.GetInteger(GetPName.ScissorBox, scissor); + _gl.GetInteger(GetPName.Viewport, viewport); + + int clipBits = 0; + for (int index = 0; index < ClipFrame.MaxPlanes; index++) + { + if (_gl.IsEnabled(EnableCap.ClipDistance0 + index)) + clipBits |= 1 << index; + } + + // Preserve the old tripwire boundary: consume the error that existed + // after the scissor/viewport/clip reads, before any of the state reads + // below can produce a probe-owned error. + int error = (int)_gl.GetError(); + + return new RenderGlStateSnapshot( + _gl.IsEnabled(EnableCap.DepthTest), + _gl.GetBoolean(GetPName.DepthWritemask), + _gl.GetInteger(GetPName.DepthFunc), + _gl.IsEnabled(EnableCap.Blend), + _gl.GetInteger(GetPName.BlendSrcRgb), + _gl.GetInteger(GetPName.BlendDstRgb), + _gl.IsEnabled(EnableCap.CullFace), + _gl.GetInteger(GetPName.CullFaceMode), + _gl.GetInteger(GetPName.FrontFace), + _gl.IsEnabled(EnableCap.ScissorTest), + new IntRenderRectangle(scissor[0], scissor[1], scissor[2], scissor[3]), + new IntRenderRectangle(viewport[0], viewport[1], viewport[2], viewport[3]), + _gl.GetInteger(GetPName.DrawFramebufferBinding), + _gl.IsEnabled(EnableCap.SampleAlphaToCoverage), + _gl.IsEnabled(EnableCap.StencilTest), + clipBits, + error); + } + + public RenderGlScissorSnapshot CaptureScissor() + { + Span box = stackalloc int[4]; + _gl.GetInteger(GetPName.ScissorBox, box); + return new RenderGlScissorSnapshot( + _gl.IsEnabled(EnableCap.ScissorTest), + new IntRenderRectangle(box[0], box[1], box[2], box[3])); + } +} + +/// +/// Owns print-on-change world-render probes and their reusable scratch. Inputs +/// are borrowed for one call; the owner retains only copied signatures and IDs. +/// +internal sealed class WorldRenderDiagnostics +{ + private readonly IRenderGlStateReader _gl; + private readonly IRenderFrameDiagnosticLog _log; + private readonly HashSet _lastViewerFloodCells = []; + private readonly HashSet _outStageUnmatched = []; + private readonly HashSet _outStageMatched = []; + private readonly Stopwatch _terrainStopwatch = new(); + private readonly RollingTimingSampleWindow _terrainSamples = new(256); + private string? _lastRenderSignature; + private int _renderSignatureFrame; + private int _renderSignatureStableFrames; + private string? _lastViewerSignature; + private string? _lastGlStateSignature; + private long _glStateFrame; + private long _glStateStableFrames; + private string? _lastScissorSignature; + private long _scissorSequence; + private string? _lastOutStageSignature; + + public WorldRenderDiagnostics( + IRenderGlStateReader gl, + IRenderFrameDiagnosticLog log) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _log = log ?? throw new ArgumentNullException(nameof(log)); + } + + public void BeginTerrainDraw() => _terrainStopwatch.Restart(); + + public void EndTerrainDraw() + { + _terrainStopwatch.Stop(); + _terrainSamples.PushHundredthsMicroseconds( + (long)(_terrainStopwatch.Elapsed.TotalMicroseconds * 100.0)); + } + + public void PublishTerrainDiagnostics(TerrainRenderDiagnosticFacts facts) + { + RollingTimingPercentiles timing = _terrainSamples.Snapshot(); + double medianMicroseconds = timing.MedianHundredthsMicroseconds / 100.0; + double p95Microseconds = timing.Percentile95HundredthsMicroseconds / 100.0; + string budget = medianMicroseconds > 1000.0 ? " BUDGET_OVER" : string.Empty; + _log.WriteLine( + $"[TERRAIN-DIAG]{budget} cpu_us={medianMicroseconds:F2}m/" + + $"{p95Microseconds:F2}p95 draws={facts.VisibleSlots}/frame " + + $"visible={facts.VisibleSlots} loaded={facts.LoadedSlots} " + + $"capacity={facts.CapacitySlots}"); + } + + public void EmitGlStateTripwireIfChanged(bool enabled) + { + if (!enabled) + return; + + _glStateFrame++; + string signature = FormatGlState(_gl.CaptureState()); + if (signature == _lastGlStateSignature) + { + _glStateStableFrames++; + return; + } + + _log.WriteLine( + $"[gl-state] frame={_glStateFrame} stable={_glStateStableFrames} {signature}"); + _lastGlStateSignature = signature; + _glStateStableFrames = 0; + } + + public void EmitClipRouteScissorProbe( + bool enabled, + bool applied, + Vector4 ndcAabb) + { + if (!enabled) + return; + + RenderGlScissorSnapshot snapshot = _gl.CaptureScissor(); + string signature = FormattableString.Invariant( + $"applied={(applied ? 1 : 0)} scis={(snapshot.Enabled ? 1 : 0)} box=({snapshot.Box.X},{snapshot.Box.Y},{snapshot.Box.Width},{snapshot.Box.Height}) ndc=({ndcAabb.X:F3},{ndcAabb.Y:F3},{ndcAabb.Z:F3},{ndcAabb.W:F3})"); + _scissorSequence++; + if (signature == _lastScissorSignature) + return; + + _lastScissorSignature = signature; + _log.WriteLine($"[clip-route-scis] n={_scissorSequence} {signature}"); + } + + public void EmitPViewInput( + bool enabled, + PortalVisibilityFrame portalFrame, + Matrix4x4 viewProjection, + bool outdoorRoot, + Vector3 eye, + Vector3 player, + Vector3 rawPlayer, + float yaw, + float? terrainHeight) + { + if (!enabled) + return; + + string terrain = terrainHeight is { } height + ? FormattableString.Invariant( + $"terrZ={height:F3} eyeAbove={eye.Z - height:F3}") + : "terrZ=n/a eyeAbove=n/a"; + char root = outdoorRoot ? 'Y' : 'n'; + Matrix4x4 vp = viewProjection; + _log.WriteLine(FormattableString.Invariant( + $"[pv-input] outRoot={root} flood={portalFrame.OrderedVisibleCells.Count} eye=({eye.X:F6},{eye.Y:F6},{eye.Z:F6}) player=({player.X:F6},{player.Y:F6},{player.Z:F6}) rawPlayer=({rawPlayer.X:F6},{rawPlayer.Y:F6},{rawPlayer.Z:F6}) yaw={yaw:F8} {terrain} vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]")); + } + + public void EmitOutStageParticles( + bool enabled, + ParticleSystem? particles, + IReadOnlySet ownerIds) + { + if (!enabled || particles is null) + return; + + int matched = 0; + int attached = 0; + int unattached = 0; + _outStageUnmatched.Clear(); + _outStageMatched.Clear(); + foreach (var (emitter, _) in particles.EnumerateLive()) + { + if (emitter.AttachedObjectId == 0) + { + unattached++; + continue; + } + + attached++; + if (ownerIds.Contains(emitter.AttachedObjectId)) + { + matched++; + if (_outStageMatched.Count < 48) + _outStageMatched.Add(emitter.AttachedObjectId); + } + else if (_outStageUnmatched.Count < 12) + { + _outStageUnmatched.Add(emitter.AttachedObjectId); + } + } + + var unmatched = new StringBuilder(96); + foreach (uint id in _outStageUnmatched) + unmatched.Append(FormattableString.Invariant($" 0x{id:X8}")); + var matchedIds = new StringBuilder(192); + foreach (uint id in _outStageMatched) + matchedIds.Append(FormattableString.Invariant($" 0x{id:X8}")); + string signature = FormattableString.Invariant( + $"ids={ownerIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} matchedIds=[{matchedIds}] unmatchedIds=[{unmatched}]"); + if (signature == _lastOutStageSignature) + return; + + _lastOutStageSignature = signature; + _log.WriteLine("[outstage-pt] " + signature); + } + + public void EmitRetailPViewDiagnostics( + bool viewerEnabled, + bool visibilityEnabled, + bool flapEnabled, + RetailPViewFrameResult result, + LoadedCell clipRoot, + uint viewerCellId, + uint playerCellId, + Vector3 cameraPosition, + Vector3 playerPosition, + Matrix4x4 cameraView, + CameraCellResolution cameraCellResolution) + { + if (viewerEnabled) + { + string signature = FormattableString.Invariant( + $"root=0x{clipRoot.CellId:X8}{(clipRoot.IsOutdoorNode ? "(OUT)" : string.Empty)} flood={result.PortalFrame.OrderedVisibleCells.Count} outPolys={result.PortalFrame.OutsideView.Polygons.Count} pCell=0x{playerCellId:X8}"); + if (signature != _lastViewerSignature) + { + _lastViewerSignature = signature; + _log.WriteLine(FormattableString.Invariant( + $"[viewer] {signature} eye=({cameraPosition.X:F3},{cameraPosition.Y:F3},{cameraPosition.Z:F3}) fwd=({-cameraView.M13:F4},{-cameraView.M23:F4},{-cameraView.M33:F4}) viewerCell=0x{viewerCellId:X8}")); + EmitViewerDiff(result.PortalFrame.OrderedVisibleCells); + } + } + + if (visibilityEnabled) + { + AcDream.Core.Rendering.RenderingDiagnostics.EmitVis( + clipRoot.CellId, + result.PortalFrame.OrderedVisibleCells, + result.PortalFrame.OutsideView.Polygons.Count, + result.ClipAssembly.OutsidePlaneCount, + result.ClipAssembly.PerCellPlaneCounts, + result.ClipAssembly.ScissorFallbacks); + } + + if (flapEnabled) + { + bool eyeInRoot = CellVisibility.PointInCell(cameraPosition, clipRoot); + bool playerInRoot = CellVisibility.PointInCell(playerPosition, clipRoot); + _log.WriteLine( + $"[flap-cam] root=0x{clipRoot.CellId:X8} " + + $"viewerCell=0x{viewerCellId:X8} playerCell=0x{playerCellId:X8} " + + $"res={cameraCellResolution} " + + $"eyeInRoot={(eyeInRoot ? "Y" : "n")} " + + $"playerInRoot={(playerInRoot ? "Y" : "n")} " + + $"eye=({cameraPosition.X:F2},{cameraPosition.Y:F2},{cameraPosition.Z:F2}) " + + $"player=({playerPosition.X:F2},{playerPosition.Y:F2},{playerPosition.Z:F2}) " + + $"terrain={result.ClipAssembly.TerrainMode} " + + $"outVisible={result.ClipAssembly.OutdoorVisible}"); + } + } + + public void EmitRenderSignatureIfChanged( + bool enabled, + string branch, + LoadedCell? clipRoot, + LoadedCell? viewerRoot, + LoadedCell? playerRoot, + uint viewerCellId, + uint playerCellId, + bool playerIndoorGate, + bool cameraInsideCell, + bool renderSkyGate, + bool drawSkyThisFrame, + bool terrainDrawn, + TerrainClipMode terrainClipMode, + bool skyDrawn, + bool depthClear, + bool outdoorSceneryDrawn, + bool outdoorPortalDrawn, + int outdoorRootObjectCount, + int liveDynamicDrawnCount, + string sceneParticles, + PortalVisibilityFrame? portalFrame, + ClipFrameAssembly? clipAssembly, + IReadOnlySet? drawableCells, + InteriorEntityPartition.Result? partition, + PortalVisibilityFrame? exteriorPortalFrame, + ClipFrameAssembly? exteriorClipAssembly, + IReadOnlySet? exteriorDrawableCells, + InteriorEntityPartition.Result? exteriorPartition, + Vector3 cameraPosition, + Vector3 playerPosition) + { + if (!enabled) + return; + + _renderSignatureFrame++; + bool eyeInRoot = clipRoot is not null + && CellVisibility.PointInCell(cameraPosition, clipRoot); + bool playerInRoot = clipRoot is not null + && CellVisibility.PointInCell(playerPosition, clipRoot); + + var text = new StringBuilder(512); + text.Append("branch=").Append(branch); + text.Append(" root=0x").Append((clipRoot?.CellId ?? 0u).ToString("X8")); + text.Append(" viewerRoot=0x").Append((viewerRoot?.CellId ?? 0u).ToString("X8")); + text.Append(" playerRoot=0x").Append((playerRoot?.CellId ?? 0u).ToString("X8")); + text.Append(" viewerCell=0x").Append(viewerCellId.ToString("X8")); + text.Append(" playerCell=0x").Append(playerCellId.ToString("X8")); + text.Append(" gate=").Append(playerIndoorGate ? "in" : "out"); + text.Append(" camIn=").Append(cameraInsideCell ? 'Y' : 'n'); + text.Append(" eyeInRoot=").Append(eyeInRoot ? 'Y' : 'n'); + text.Append(" playerInRoot=").Append(playerInRoot ? 'Y' : 'n'); + text.Append(" eye=").Append(FormatVector(cameraPosition)); + text.Append(" player=").Append(FormatVector(playerPosition)); + text.Append(" terrain=").Append(terrainClipMode); + text.Append('/').Append(terrainDrawn ? "draw" : "skip"); + text.Append(" skyGate=").Append(renderSkyGate ? 'Y' : 'n'); + text.Append(" sky=").Append(skyDrawn ? 'Y' : 'n'); + text.Append(" skyFrame=").Append(drawSkyThisFrame ? 'Y' : 'n'); + text.Append(" zclear=").Append(depthClear ? 'Y' : 'n'); + text.Append(" sceneParticles=").Append(sceneParticles); + if (clipAssembly is not null) + { + text.Append(" outSlices=").Append(clipAssembly.OutsideViewSlices.Length); + text.Append(" outPolys=").Append(portalFrame?.OutsideView.Polygons.Count ?? 0); + text.Append(" outMode=").Append(clipAssembly.TerrainMode); + } + else + { + text.Append(" outSlices=0 outPolys=0 outMode=none"); + } + + text.Append(" ids=").Append(FormatIds(portalFrame?.OrderedVisibleCells, true)); + text.Append(" draw=").Append(FormatIds(drawableCells, false)); + text.Append(" miss=").Append(FormatMissingDrawableCells(portalFrame, drawableCells)); + text.Append(" obj=").Append(FormatPartitionCounts(partition)); + text.Append(" outdoorDoor=").Append(outdoorSceneryDrawn ? 'Y' : 'n'); + text.Append(" outdoorRootObjs=").Append(outdoorRootObjectCount); + text.Append(" liveDynDraw=").Append(liveDynamicDrawnCount); + text.Append(" outRoot=").Append(clipRoot is { IsOutdoorNode: true } ? 'Y' : 'n'); + if (partition is not null) + { + int totalShells = 0; + int shellsWithMeshes = 0; + foreach (var entity in partition.OutdoorStatic) + { + if (!entity.IsBuildingShell) + continue; + totalShells++; + if (entity.MeshRefs.Count > 0) + shellsWithMeshes++; + } + text.Append(" bshell=").Append(totalShells).Append('/').Append(shellsWithMeshes); + } + + if (outdoorPortalDrawn || exteriorPortalFrame is not null + || exteriorClipAssembly is not null) + { + text.Append(" extPortal=").Append(outdoorPortalDrawn ? 'Y' : 'n'); + text.Append(" extSlices=").Append(exteriorClipAssembly?.OutsideViewSlices.Length ?? 0); + text.Append(" extIds=").Append(FormatIds( + exteriorPortalFrame?.OrderedVisibleCells, + true)); + text.Append(" extDraw=").Append(FormatIds(exteriorDrawableCells, false)); + text.Append(" extMiss=").Append(FormatMissingDrawableCells( + exteriorPortalFrame, + exteriorDrawableCells)); + text.Append(" extObj=").Append(FormatPartitionCounts(exteriorPartition)); + } + + string signature = text.ToString(); + if (signature == _lastRenderSignature) + { + _renderSignatureStableFrames++; + return; + } + + _log.WriteLine( + $"[render-sig] frame={_renderSignatureFrame} " + + $"stable={_renderSignatureStableFrames} {signature}"); + _lastRenderSignature = signature; + _renderSignatureStableFrames = 0; + } + + internal static string FormatGlState(RenderGlStateSnapshot state) => + $"depth={(state.DepthTest ? 1 : 0)} " + + $"dmask={(state.DepthWrite ? 1 : 0)} " + + $"dfunc=0x{state.DepthFunction:X} " + + $"blend={(state.Blend ? 1 : 0)} " + + $"bsrc=0x{state.BlendSource:X} bdst=0x{state.BlendDestination:X} " + + $"cull={(state.CullFace ? 1 : 0)} cmode=0x{state.CullMode:X} " + + $"fface=0x{state.FrontFace:X} " + + $"scis={(state.Scissor ? 1 : 0)} " + + $"sbox=({state.ScissorBox.X},{state.ScissorBox.Y}," + + $"{state.ScissorBox.Width},{state.ScissorBox.Height}) " + + $"vp=({state.Viewport.X},{state.Viewport.Y}," + + $"{state.Viewport.Width},{state.Viewport.Height}) " + + $"fbo={state.DrawFramebuffer} " + + $"a2c={(state.AlphaToCoverage ? 1 : 0)} " + + $"stencil={(state.Stencil ? 1 : 0)} " + + $"clip=0x{state.ClipBits:X2} err=0x{state.Error:X}"; + + private void EmitViewerDiff(IReadOnlyList current) + { + var text = new StringBuilder(96); + text.Append("[viewer-diff] added=["); + bool first = true; + foreach (uint cell in current) + { + if (_lastViewerFloodCells.Contains(cell)) + continue; + if (!first) + text.Append(','); + text.Append("0x").Append(cell.ToString("X8")); + first = false; + } + + text.Append("] removed=["); + first = true; + foreach (uint cell in _lastViewerFloodCells) + { + bool present = false; + for (int index = 0; index < current.Count; index++) + { + if (current[index] == cell) + { + present = true; + break; + } + } + if (present) + continue; + if (!first) + text.Append(','); + text.Append("0x").Append(cell.ToString("X8")); + first = false; + } + text.Append(']'); + _log.WriteLine(text.ToString()); + + _lastViewerFloodCells.Clear(); + foreach (uint cell in current) + _lastViewerFloodCells.Add(cell); + } + + private static string FormatVector(Vector3 value) + { + static float Quantize(float component) => MathF.Round(component * 20f) / 20f; + return $"({Quantize(value.X):F2},{Quantize(value.Y):F2},{Quantize(value.Z):F2})"; + } + + private static string FormatIds(IEnumerable? ids, bool preserveOrder) + { + if (ids is null) + return "[]"; + + var values = new List(ids); + if (!preserveOrder) + values.Sort(); + var text = new StringBuilder(96).Append('['); + const int MaximumIds = 12; + for (int index = 0; index < values.Count && index < MaximumIds; index++) + { + if (index > 0) + text.Append(','); + text.Append("0x").Append(values[index].ToString("X8")); + } + if (values.Count > MaximumIds) + text.Append(",..."); + return text.Append(']').ToString(); + } + + private static string FormatMissingDrawableCells( + PortalVisibilityFrame? portalFrame, + IReadOnlySet? drawableCells) + { + if (portalFrame is null || drawableCells is null) + return "[]"; + + var text = new StringBuilder(96).Append('['); + int written = 0; + const int MaximumCells = 8; + foreach (uint id in portalFrame.OrderedVisibleCells) + { + if (drawableCells.Contains(id)) + continue; + if (written > 0) + text.Append(','); + text.Append("0x").Append(id.ToString("X8")); + if (portalFrame.CellViews.TryGetValue(id, out var view)) + { + text.Append(":p").Append(view.Polygons.Count); + if (view.IsEmpty) + text.Append(":empty"); + } + else + { + text.Append(":noView"); + } + if (++written >= MaximumCells) + { + text.Append(",..."); + break; + } + } + return text.Append(']').ToString(); + } + + private static string FormatPartitionCounts(InteriorEntityPartition.Result? partition) + { + if (partition is null) + return "cell=[] out=0 live=0"; + + var keys = new List(partition.ByCell.Keys); + keys.Sort(); + var text = new StringBuilder(128).Append("cell=["); + const int MaximumCells = 10; + for (int index = 0; index < keys.Count && index < MaximumCells; index++) + { + uint id = keys[index]; + if (index > 0) + text.Append(','); + text.Append("0x").Append(id.ToString("X8")) + .Append(':').Append(partition.ByCell[id].Count); + } + if (keys.Count > MaximumCells) + text.Append(",..."); + return text.Append("] out=").Append(partition.OutdoorStatic.Count) + .Append(" live=").Append(partition.Dynamics.Count) + .ToString(); + } +} diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index 72a1842d..09f7b590 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -29,7 +29,7 @@ public sealed class WorldLifecycleAutomationControllerTests Assert.True(controller.TryRequest("login_stable", out string error), error); Assert.False(controller.IsComplete("login_stable")); - controller.CapturePending(); + Assert.True(controller.CapturePending()); Assert.True(controller.IsComplete("login_stable")); string path = Path.Combine(directory, "login_stable.png"); @@ -48,6 +48,29 @@ public sealed class WorldLifecycleAutomationControllerTests } } + [Fact] + public void ScreenshotCapture_ReportsNoWorkAndFailedCapture() + { + string directory = NewDirectory(); + var controller = new FrameScreenshotController( + () => (0, 0), + (_, _) => [], + directory); + + try + { + Assert.False(controller.CapturePending()); + Assert.True(controller.TryRequest("bad_frame", out string error), error); + Assert.False(controller.CapturePending()); + Assert.False(controller.IsComplete("bad_frame")); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + } + [Theory] [InlineData("")] [InlineData("../escape")] diff --git a/tests/AcDream.App.Tests/Rendering/DebugVmRenderFactsPublisherTests.cs b/tests/AcDream.App.Tests/Rendering/DebugVmRenderFactsPublisherTests.cs new file mode 100644 index 00000000..dc07f0e7 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/DebugVmRenderFactsPublisherTests.cs @@ -0,0 +1,167 @@ +using System.Numerics; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.Core.Physics; + +namespace AcDream.App.Tests.Rendering; + +public sealed class DebugVmRenderFactsPublisherTests +{ + [Fact] + public void Defaults_MatchThePriorGameWindowCache() + { + var publisher = new DebugVmRenderFactsPublisher(); + + Assert.Equal(DebugVmRenderFacts.Initial, publisher.DebugVmFacts); + Assert.Equal(float.PositiveInfinity, publisher.DebugVmFacts.NearestObjectDistance); + Assert.Equal("-", publisher.DebugVmFacts.NearestObjectLabel); + Assert.False(publisher.DebugVmFacts.Colliding); + } + + [Fact] + public void InactiveConsumer_DoesNotEnumerateOrReplacePriorFacts() + { + var publisher = new DebugVmRenderFactsPublisher(); + publisher.PublishDebugVmFacts( + true, + 3, + 8, + Vector3.Zero, + [Entry(1, new Vector3(5, 0, 100), radius: 1)]); + DebugVmRenderFacts prior = publisher.DebugVmFacts; + var shadows = new CountingEnumerable([Entry(2, Vector3.One, 1)]); + + publisher.PublishDebugVmFacts( + false, + 99, + 100, + new Vector3(90, 90, 90), + shadows); + + Assert.Equal(0, shadows.EnumerationCount); + Assert.Equal(prior, publisher.DebugVmFacts); + } + + [Fact] + public void Publish_UsesHorizontalDistanceAndSubtractsBothRadii() + { + var publisher = new DebugVmRenderFactsPublisher(); + var origin = new Vector3(10, 20, -500); + + publisher.PublishDebugVmFacts( + true, + 7, + 11, + origin, + [ + Entry(0xA, new Vector3(16, 28, 500), radius: 1.52f), + Entry(0xB, new Vector3(30, 20, -500), radius: 2f), + ]); + + DebugVmRenderFacts facts = publisher.DebugVmFacts; + Assert.Equal(7, facts.VisibleLandblocks); + Assert.Equal(11, facts.TotalLandblocks); + Assert.Equal(8.0f, facts.NearestObjectDistance, precision: 5); + Assert.Equal("0x0000000A Cylinder", facts.NearestObjectLabel); + Assert.False(facts.Colliding); + } + + [Fact] + public void Publish_ClampsOverlapToZeroAndUsesUnclampedDistanceForCollision() + { + var publisher = new DebugVmRenderFactsPublisher(); + + publisher.PublishDebugVmFacts( + true, + 1, + 2, + Vector3.Zero, + [Entry(0x1234, new Vector3(1, 0, 200), radius: 0.6f)]); + + Assert.Equal(0f, publisher.DebugVmFacts.NearestObjectDistance); + Assert.True(publisher.DebugVmFacts.Colliding); + Assert.Equal("0x00001234 Cylinder", publisher.DebugVmFacts.NearestObjectLabel); + } + + [Fact] + public void Publish_DistanceAboveContactThresholdIsClearAndTieKeepsFirstEntry() + { + var publisher = new DebugVmRenderFactsPublisher(); + float centerDistance = 1f + DebugVmRenderFactsPublisher.PlayerCollisionRadius + + DebugVmRenderFactsPublisher.ContactThreshold + 0.001f; + + publisher.PublishDebugVmFacts( + true, + 0, + 0, + Vector3.Zero, + [ + Entry(0x11, new Vector3(centerDistance, 0, 0), 1f), + Entry(0x22, new Vector3(0, centerDistance, 0), 1f), + ]); + + Assert.True(publisher.DebugVmFacts.NearestObjectDistance + > DebugVmRenderFactsPublisher.ContactThreshold); + Assert.False(publisher.DebugVmFacts.Colliding); + Assert.Equal("0x00000011 Cylinder", publisher.DebugVmFacts.NearestObjectLabel); + } + + [Fact] + public void Publish_EmptySequenceRestoresTheEmptyNearestFacts() + { + var publisher = new DebugVmRenderFactsPublisher(); + publisher.PublishDebugVmFacts( + true, + 1, + 1, + Vector3.Zero, + [Entry(1, Vector3.Zero, 1)]); + + publisher.PublishDebugVmFacts(true, 4, 9, Vector3.Zero, []); + + Assert.Equal(4, publisher.DebugVmFacts.VisibleLandblocks); + Assert.Equal(9, publisher.DebugVmFacts.TotalLandblocks); + Assert.Equal(float.PositiveInfinity, publisher.DebugVmFacts.NearestObjectDistance); + Assert.Equal("-", publisher.DebugVmFacts.NearestObjectLabel); + Assert.False(publisher.DebugVmFacts.Colliding); + } + + [Fact] + public void Publisher_RetainsNoRuntimeOwnerDelegateOrBorrowedSequence() + { + FieldInfo[] fields = typeof(DebugVmRenderFactsPublisher).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.Single(fields); + Assert.Equal(typeof(DebugVmRenderFacts), fields[0].FieldType); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain(fields, field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + Assert.DoesNotContain(fields, field => + typeof(IEnumerable).IsAssignableFrom(field.FieldType)); + Assert.True(typeof(IDebugVmRenderFactsSource).IsAssignableFrom( + typeof(DebugVmRenderFactsPublisher))); + } + + private static ShadowEntry Entry(uint id, Vector3 position, float radius) => new( + EntityId: id, + GfxObjId: 0, + Position: position, + Rotation: Quaternion.Identity, + Radius: radius, + CollisionType: ShadowCollisionType.Cylinder); + + private sealed class CountingEnumerable(IReadOnlyList entries) + : IEnumerable + { + public int EnumerationCount { get; private set; } + + public IEnumerator GetEnumerator() + { + EnumerationCount++; + return entries.GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => + GetEnumerator(); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs new file mode 100644 index 00000000..b28279a3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs @@ -0,0 +1,333 @@ +using System.Numerics; +using System.Reflection; +using AcDream.App.Diagnostics; +using AcDream.App.Rendering; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Tests.Rendering; + +public sealed class DevToolsFramePresenterTests +{ + [Fact] + public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder() + { + var calls = new List(); + var backend = new RecordingBackend(calls); + var commands = new RecordingCommandSource(); + var presenter = Create(backend, commands: commands); + + presenter.BeginFrame(0.25f); + presenter.Render(0.5, 1280, 720); + + Assert.Equal( + [ + "begin:0.25", + "begin-bar", + "begin-menu:View", + "begin-menu:Camera", + "end-bar", + "panels:0.5", + "draw-data", + ], + calls); + Assert.Same(commands.Current, backend.Contexts.Single().Commands); + } + + [Fact] + public void Render_ResolvesTheCurrentCommandBusEveryFrame() + { + var backend = new RecordingBackend([]); + var first = new RecordingCommandBus(); + var second = new RecordingCommandBus(); + var commands = new RecordingCommandSource { Current = first }; + var presenter = Create(backend, commands: commands); + + presenter.Render(0.1, 800, 600); + commands.Current = second; + presenter.Render(0.2, 800, 600); + + Assert.Same(first, backend.Contexts[0].Commands); + Assert.Same(second, backend.Contexts[1].Commands); + } + + [Fact] + public void ViewMenu_TogglesExactPanelsAndUsesCurrentViewportForReset() + { + var backend = new RecordingBackend([]); + backend.OpenMenus.Add("View"); + backend.ClickedItems.UnionWith( + ["Settings", "Vitals", "Chat", "Debug", "Reset window layout"]); + var panels = new RecordingPanels(); + var presenter = Create(backend, panels: panels); + + presenter.Render(0.1, 1920, 1080); + + Assert.Equal( + [ + DevToolsPanelKind.Settings, + DevToolsPanelKind.Vitals, + DevToolsPanelKind.Chat, + DevToolsPanelKind.Debug, + ], + panels.Toggles); + Assert.Equal( + [ + new LayoutCall("Vitals", new Vector2(10f, 30f), new Vector2(220f, 110f)), + new LayoutCall("Chat", new Vector2(10f, 760f), new Vector2(450f, 300f)), + new LayoutCall("Debug", new Vector2(1540f, 30f), new Vector2(370f, 520f)), + new LayoutCall("Settings", new Vector2(610f, 290f), new Vector2(700f, 500f)), + ], + backend.Layouts.Select(call => call.WithoutCondition())); + Assert.All( + backend.Layouts, + call => Assert.Equal(DevToolsPanelLayoutCondition.Always, call.Condition)); + } + + [Fact] + public void CameraMenu_UsesFlyLabelAndRoundTripsCollisionState() + { + var backend = new RecordingBackend([]); + backend.OpenMenus.Add("Camera"); + backend.ClickedItems.UnionWith( + ["Exit Free-Fly Mode", "Collide Camera (spring arm)"]); + var camera = new RecordingCamera { IsFlyMode = true, CollideCamera = true }; + var presenter = Create(backend, camera: camera); + + presenter.Render(0.1, 1280, 720); + + Assert.Equal(1, camera.ToggleCount); + Assert.False(camera.CollideCamera); + Assert.Contains( + backend.MenuItems, + item => item.Label == "Collide Camera (spring arm)" && item.Selected); + } + + [Fact] + public void MissingSettingsPanel_IsInertAndSkippedFromLayout() + { + var backend = new RecordingBackend([]); + backend.OpenMenus.Add("View"); + backend.ClickedItems.Add("Settings"); + var panels = new RecordingPanels { HasSettings = false }; + var presenter = Create(backend, panels: panels); + + presenter.ToggleSettingsPanel(); + presenter.ResetLayout(1280, 720, DevToolsPanelLayoutCondition.FirstUseEver); + presenter.Render(0.1, 1280, 720); + + Assert.DoesNotContain(DevToolsPanelKind.Settings, panels.Toggles); + Assert.DoesNotContain(backend.MenuItems, item => item.Label == "Settings"); + Assert.DoesNotContain(backend.Layouts, call => call.Title == "Settings"); + } + + [Theory] + [InlineData(1280, 720, 900, 400, 290, 110)] + [InlineData(1920, 1080, 1540, 760, 610, 290)] + [InlineData(100, 100, 100, 0, -110, -90)] + public void ResetLayout_PreservesExactFormulasAndMinimumViewport( + int width, + int height, + int debugX, + int chatY, + int settingsX, + int settingsY) + { + var backend = new RecordingBackend([]); + var presenter = Create(backend); + + presenter.ResetLayout(width, height, DevToolsPanelLayoutCondition.FirstUseEver); + + Assert.Collection( + backend.Layouts, + call => Assert.Equal( + new LayoutCall("Vitals", new Vector2(10f, 30f), new Vector2(220f, 110f)), + call.WithoutCondition()), + call => Assert.Equal( + new LayoutCall("Chat", new Vector2(10f, chatY), new Vector2(450f, 300f)), + call.WithoutCondition()), + call => Assert.Equal( + new LayoutCall("Debug", new Vector2(debugX, 30f), new Vector2(370f, 520f)), + call.WithoutCondition()), + call => Assert.Equal( + new LayoutCall( + "Settings", + new Vector2(settingsX, settingsY), + new Vector2(700f, 500f)), + call.WithoutCondition())); + } + + [Fact] + public void InputFacingActionsDelegateToTheTypedPanelOwner() + { + var panels = new RecordingPanels(); + var presenter = Create(new RecordingBackend([]), panels: panels); + + presenter.ToggleDebugPanel(); + presenter.FocusChatInput(); + presenter.ToggleSettingsPanel(); + + Assert.Equal( + [DevToolsPanelKind.Debug, DevToolsPanelKind.Settings], + panels.Toggles); + Assert.Equal(1, panels.FocusChatCount); + Assert.Equal(["Debug panel ON"], panels.Toasts); + } + + [Fact] + public void PresenterAndAdaptersHaveNoWindowOrDelegateBackReferences() + { + Type[] owners = + [ + typeof(DevToolsFramePresenter), + typeof(ImGuiDevToolsFrameBackend), + typeof(DevToolsCameraMenuOperations), + typeof(DevToolsCommandBusSource), + typeof(DevToolsPanelSet), + ]; + + foreach (Type owner in owners) + { + FieldInfo[] fields = owner.GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + Assert.DoesNotContain( + fields, + field => field.FieldType == typeof(Silk.NET.Windowing.IWindow)); + } + + Assert.False(typeof(IDisposable).IsAssignableFrom( + typeof(ImGuiDevToolsFrameBackend))); + } + + private static DevToolsFramePresenter Create( + RecordingBackend backend, + RecordingCamera? camera = null, + RecordingCommandSource? commands = null, + RecordingPanels? panels = null) => + new( + backend, + camera ?? new RecordingCamera(), + commands ?? new RecordingCommandSource(), + new FrameProfiler(), + panels ?? new RecordingPanels()); + + private sealed class RecordingBackend(List calls) : IDevToolsFrameBackend + { + public HashSet OpenMenus { get; } = []; + public HashSet ClickedItems { get; } = []; + public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = []; + public List Contexts { get; } = []; + public List Layouts { get; } = []; + + public void BeginFrame(float deltaSeconds) => calls.Add($"begin:{deltaSeconds}"); + + public bool BeginMainMenuBar() + { + calls.Add("begin-bar"); + return true; + } + + public void EndMainMenuBar() => calls.Add("end-bar"); + + public bool BeginMenu(string label) + { + calls.Add($"begin-menu:{label}"); + return OpenMenus.Contains(label); + } + + public void EndMenu() => calls.Add("end-menu"); + + public bool MenuItem(string label, string? shortcut = null, bool selected = false) + { + MenuItems.Add((label, shortcut, selected)); + return ClickedItems.Contains(label); + } + + public void Separator() => calls.Add("separator"); + + public void RenderPanels(PanelContext context) + { + Contexts.Add(context); + calls.Add($"panels:{context.DeltaSeconds}"); + } + + public void RenderDrawData() => calls.Add("draw-data"); + + public void SetWindowLayout( + string title, + Vector2 position, + Vector2 size, + DevToolsPanelLayoutCondition condition) => + Layouts.Add(new LayoutCallWithCondition(title, position, size, condition)); + } + + private sealed class RecordingCamera : IDevToolsCameraMenuOperations + { + public bool IsFlyMode { get; init; } + public bool CollideCamera { get; set; } + public int ToggleCount { get; private set; } + + public void ToggleFlyOrChase() => ToggleCount++; + } + + private sealed class RecordingCommandSource : IDevToolsCommandBusSource + { + public ICommandBus Current { get; set; } = new RecordingCommandBus(); + } + + private sealed class RecordingCommandBus : ICommandBus + { + public void Publish(T command) where T : notnull + { + } + } + + private sealed class RecordingPanels : IDevToolsPanelSet + { + private readonly HashSet _visible = []; + + public bool HasSettings { get; init; } = true; + public List Toggles { get; } = []; + public List Toasts { get; } = []; + public int FocusChatCount { get; private set; } + + public bool Contains(DevToolsPanelKind kind) => + kind != DevToolsPanelKind.Settings || HasSettings; + + public string? GetTitle(DevToolsPanelKind kind) => Contains(kind) + ? kind.ToString() + : null; + + public bool IsVisible(DevToolsPanelKind kind) => _visible.Contains(kind); + + public void Toggle(DevToolsPanelKind kind) + { + if (!Contains(kind)) + return; + Toggles.Add(kind); + if (!_visible.Add(kind)) + _visible.Remove(kind); + } + + public void FocusChatInput() => FocusChatCount++; + + public void AddDebugToast(string message) => Toasts.Add(message); + } + + private readonly record struct LayoutCall( + string Title, + Vector2 Position, + Vector2 Size); + + private readonly record struct LayoutCallWithCondition( + string Title, + Vector2 Position, + Vector2 Size, + DevToolsPanelLayoutCondition Condition) + { + public LayoutCall WithoutCondition() => new(Title, Position, Size); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs new file mode 100644 index 00000000..fc01780b --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -0,0 +1,167 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class GameWindowRenderLeafCompositionTests +{ + [Fact] + public void ProductionRender_PreservesPrivatePresentationAndCaptureOrder() + { + string source = GameWindowSource(); + + AssertAppearsInOrder( + source, + "_localPlayerTeleport?.DrawPortalViewport(", + "_paperdollFramePresenter?.Render();", + "_retailUiRuntime.Tick(deltaSeconds);", + "_devToolsFramePresenter?.Render(", + "_frameScreenshots?.CapturePending() == true;", + "_renderFrameDiagnostics?.Publish("); + } + + [Fact] + public void ProductionRender_BeginsDevToolsBeforeWorldAndBeginsDispatcherOnce() + { + string source = GameWindowSource(); + + AssertAppearsInOrder( + source, + "_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);", + "Weather.Tick(nowSeconds: _weatherAccum", + "_retailSelectionScene?.BeginFrame();"); + Assert.Equal(1, CountOccurrences(source, "_wbDrawDispatcher?.BeginFrame(")); + } + + [Fact] + public void ProductionComposition_RemovesLegacyLeafOwnership() + { + string source = GameWindowSource(); + + string[] removed = + [ + "_imguiBootstrap", + "_panelHost", + "_paperdollDollDirty", + "RefreshPaperdollDoll", + "ApplyPaperdollPose", + "ResolvePaperdollPoseDid", + "EnumerateDebugPanel", + "ResetPanelLayout", + "SetPanelLayout", + "_lastRenderSignature", + "private void EmitRenderSignatureIfChanged(", + "private void EmitRetailPViewDiagnostics(", + "EmitGlStateTripwireIfChanged();", + "EmitClipRouteScissorProbe(scissor", + "_lastVisibleLandblocks", + "_perfAccum", + ]; + foreach (string identifier in removed) + Assert.DoesNotContain(identifier, source, StringComparison.Ordinal); + + Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source); + Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", source); + Assert.Contains("_inputCapture.WantCaptureMouse", source); + } + + [Fact] + public void Shutdown_PreservesBorrowedDevtoolsLifetimeAndDrainsGpuBeforeFrontends() + { + string source = GameWindowSource(); + + AssertAppearsInOrder( + source, + "new ResourceShutdownStage(\"submitted GPU work\"", + "new ResourceShutdownStage(\"render frontends\"", + "new(\"portal tunnel\"", + "new(\"paperdoll viewport\"", + "new ResourceShutdownStage(\"OpenGL context\""); + Assert.DoesNotContain("_devToolsBackend?.Dispose()", source); + } + + [Fact] + public void ProductionOutcome_UsesObservedWorldAndScreenshotFacts() + { + string source = GameWindowSource(); + + Assert.Contains("bool normalWorldDrawn = false;", source); + AssertAppearsInOrder( + source, + "if (IsLiveModeWaitingForLogin)", + "goto SkipWorldGeometry;", + "normalWorldDrawn = true;", + "bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;", + "normalWorldDrawn),", + "screenshotCaptured)));"); + } + + [Fact] + public void PaperdollComposition_SkipsEitherMissingOptionalUiSurface() + { + string source = GameWindowSource(); + + AssertAppearsInOrder( + source, + "PaperdollViewportWidget is { } paperdollViewport", + "InventoryFrame is { } paperdollInventoryFrame", + "new AcDream.App.Rendering.PaperdollFramePresenter("); + Assert.DoesNotContain("Paperdoll inventory frame is required.", source); + } + + [Fact] + public void TerrainAndFrameDiagnostics_CommitOneSharedCadenceAfterBothWrites() + { + string source = GameWindowSource(); + + AssertAppearsInOrder( + source, + "_worldRenderDiagnostics?.PublishTerrainDiagnostics(", + "PublishFrameDiagnostics();", + "_frameDiagLastPublicationMilliseconds = now;"); + } + + private static string GameWindowSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + private static void AssertAppearsInOrder(string source, params string[] needles) + { + int cursor = -1; + foreach (string needle in needles) + { + int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal); + Assert.True(next >= 0, $"Missing expected source fragment: {needle}"); + Assert.True(next > cursor, $"Out-of-order source fragment: {needle}"); + cursor = next; + } + } + + private static int CountOccurrences(string source, string needle) + { + int count = 0; + int cursor = 0; + while ((cursor = source.IndexOf(needle, cursor, StringComparison.Ordinal)) >= 0) + { + count++; + cursor += needle.Length; + } + + return count; + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs new file mode 100644 index 00000000..87dc0270 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs @@ -0,0 +1,256 @@ +using System.Numerics; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.App.Input; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class PaperdollFramePresenterTests +{ + [Fact] + public void HiddenView_DoesNotBuildOrRender() + { + var renderer = new RecordingRenderer(); + var view = new RecordingView { Visible = false }; + var factory = new RecordingFactory(); + var presenter = new PaperdollFramePresenter(renderer, view, factory); + + presenter.Render(); + + Assert.True(presenter.IsDirty); + Assert.Equal(0, factory.BuildCount); + Assert.Equal(0, renderer.RenderCount); + Assert.Empty(view.TextureHandles); + } + + [Fact] + public void FirstVisibleFrame_BuildsThenRendersAndPublishesTexture() + { + var doll = CreateDoll(); + var renderer = new RecordingRenderer { TextureHandle = 91u }; + var view = new RecordingView { Width = 240, Height = 320 }; + var factory = new RecordingFactory { Doll = doll }; + var presenter = new PaperdollFramePresenter(renderer, view, factory); + + presenter.Render(); + + Assert.False(presenter.IsDirty); + Assert.Equal(1, factory.BuildCount); + Assert.Same(doll, renderer.Dolls.Single()); + Assert.Equal([(240, 320)], renderer.RenderSizes); + Assert.Equal([91u], view.TextureHandles); + } + + [Fact] + public void CleanFrame_ReusesDollUntilMarkedDirty() + { + var renderer = new RecordingRenderer(); + var view = new RecordingView(); + var factory = new RecordingFactory + { + Doll = CreateDoll(), + }; + var presenter = new PaperdollFramePresenter(renderer, view, factory); + + presenter.Render(); + presenter.Render(); + presenter.MarkDirty(); + presenter.Render(); + + Assert.Equal(2, factory.BuildCount); + Assert.Equal(2, renderer.Dolls.Count); + Assert.Equal(3, renderer.RenderCount); + Assert.False(presenter.IsDirty); + } + + private static WorldEntity CreateDoll() => new() + { + Id = 42u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + [Fact] + public void MissingPlayer_ClearsOldDollPublishesEmptyTextureAndRetries() + { + var renderer = new RecordingRenderer { TextureHandle = 0u }; + var view = new RecordingView(); + var factory = new RecordingFactory { CanBuild = false }; + var presenter = new PaperdollFramePresenter(renderer, view, factory); + + presenter.Render(); + presenter.Render(); + + Assert.True(presenter.IsDirty); + Assert.Equal(2, factory.BuildCount); + Assert.Equal(2, renderer.Dolls.Count); + Assert.All(renderer.Dolls, Assert.Null); + Assert.Equal(2, renderer.RenderCount); + Assert.Equal([0u, 0u], view.TextureHandles); + } + + [Fact] + public void PresenterAndProductionHelpersHaveNoWindowOrDelegateBackReference() + { + Type[] owners = + [ + typeof(PaperdollFramePresenter), + typeof(RetailPaperdollFrameView), + typeof(PaperdollInventoryVisibility), + typeof(LivePaperdollEntityLookup), + typeof(RetailPaperdollDollFactory), + typeof(RetailPaperdollPoseApplicator), + ]; + + foreach (Type owner in owners) + { + FieldInfo[] fields = owner.GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain( + fields, + field => field.FieldType == typeof(AcDream.App.UI.RetailUiRuntime)); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + } + + [Fact] + public void RetailFactory_MissingOrEmptyCurrentPlayerRetriesWithoutApplyingPose() + { + var entities = new RecordingEntityLookup(); + var identity = new LocalPlayerIdentityState { ServerGuid = 7u }; + var pose = new RecordingPoseApplicator(); + var factory = new RetailPaperdollDollFactory(entities, identity, pose); + + Assert.False(factory.TryBuild(out WorldEntity? missing)); + Assert.Null(missing); + + entities.Entities[7u] = CreateDoll(); + Assert.False(factory.TryBuild(out WorldEntity? empty)); + Assert.Null(empty); + Assert.Empty(pose.Applications); + } + + [Fact] + public void RetailFactory_ResolvesCurrentIdentityAndClonesLiveAppearance() + { + var firstMesh = new MeshRef(0x01000001u, Matrix4x4.Identity) + { + SurfaceOverrides = new Dictionary { [2] = 0x08000001u }, + }; + var secondMesh = new MeshRef( + 0x01000002u, + Matrix4x4.CreateTranslation(1f, 2f, 3f)); + var first = CreatePlayer(0x02000001u, firstMesh); + var second = CreatePlayer(0x02000002u, secondMesh); + var entities = new RecordingEntityLookup + { + Entities = + { + [10u] = first, + [20u] = second, + }, + }; + var identity = new LocalPlayerIdentityState { ServerGuid = 10u }; + var pose = new RecordingPoseApplicator(); + var factory = new RetailPaperdollDollFactory(entities, identity, pose); + + Assert.True(factory.TryBuild(out WorldEntity? firstDoll)); + identity.ServerGuid = 20u; + Assert.True(factory.TryBuild(out WorldEntity? secondDoll)); + + Assert.NotNull(firstDoll); + Assert.NotNull(secondDoll); + Assert.Equal(0x02000001u, firstDoll.SourceGfxObjOrSetupId); + Assert.Equal(0x02000002u, secondDoll.SourceGfxObjOrSetupId); + Assert.NotSame(first.MeshRefs, firstDoll.MeshRefs); + Assert.Equal(firstMesh.GfxObjId, firstDoll.MeshRefs[0].GfxObjId); + Assert.Same(firstMesh.SurfaceOverrides, firstDoll.MeshRefs[0].SurfaceOverrides); + Assert.Equal( + [(firstDoll, 0x02000001u), (secondDoll, 0x02000002u)], + pose.Applications); + Assert.Equal([10u, 20u], entities.RequestedGuids); + } + + private static WorldEntity CreatePlayer(uint setupId, MeshRef mesh) => new() + { + Id = setupId, + SourceGfxObjOrSetupId = setupId, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = new List { mesh }, + }; + + private sealed class RecordingRenderer : IPaperdollDollRenderer + { + public uint TextureHandle { get; init; } + public List Dolls { get; } = []; + public List<(int Width, int Height)> RenderSizes { get; } = []; + public int RenderCount => RenderSizes.Count; + + public void SetDoll(WorldEntity? doll) => Dolls.Add(doll); + + public uint Render(int width, int height) + { + RenderSizes.Add((width, height)); + return TextureHandle; + } + } + + private sealed class RecordingView : IPaperdollFrameView + { + public bool Visible { get; init; } = true; + public int Width { get; init; } = 100; + public int Height { get; init; } = 120; + public List TextureHandles { get; } = []; + + public bool TryGetVisibleSize(out int width, out int height) + { + width = Width; + height = Height; + return Visible; + } + + public void SetTextureHandle(uint textureHandle) => + TextureHandles.Add(textureHandle); + } + + private sealed class RecordingFactory : IPaperdollDollFactory + { + public bool CanBuild { get; init; } = true; + public WorldEntity? Doll { get; init; } + public int BuildCount { get; private set; } + + public bool TryBuild(out WorldEntity? doll) + { + BuildCount++; + doll = Doll; + return CanBuild; + } + } + + private sealed class RecordingEntityLookup : IPaperdollEntityLookup + { + public Dictionary Entities { get; } = []; + public List RequestedGuids { get; } = []; + + public bool TryGet(uint serverGuid, out WorldEntity player) + { + RequestedGuids.Add(serverGuid); + return Entities.TryGetValue(serverGuid, out player!); + } + } + + private sealed class RecordingPoseApplicator : IPaperdollPoseApplicator + { + public List<(WorldEntity Doll, uint SetupId)> Applications { get; } = []; + + public void Apply(WorldEntity doll, uint setupId) => + Applications.Add((doll, setupId)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameDiagnosticsControllerTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameDiagnosticsControllerTests.cs new file mode 100644 index 00000000..2cada03b --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameDiagnosticsControllerTests.cs @@ -0,0 +1,321 @@ +using System.Globalization; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RenderFrameDiagnosticsControllerTests +{ + private static readonly RenderFrameOutcome Outcome = new( + new WorldRenderFrameOutcome( + VisibleLandblocks: 17, + TotalLandblocks: 43, + NormalWorldDrawn: true), + new PrivatePresentationFrameOutcome( + PortalViewportDrawn: false, + ScreenshotCaptured: true)); + + private static readonly RenderFrameTitleFacts TitleFacts = new( + EntityCount: 3721, + AnimatedEntityCount: 226, + Calendar: new DerethDateTime.Calendar( + 117, + DerethDateTime.MonthName.Leafcull, + 22, + DerethDateTime.HourName.DawnsongAndHalf), + DayFraction: 0.3125); + + [Fact] + public void Snapshot_StartsAtTheAcceptedRuntimeDefaults() + { + var harness = new Harness(resourceDump: false); + + Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, harness.Controller.Snapshot); + Assert.Equal(60.0, harness.Controller.Snapshot.Fps); + Assert.Equal(16.7, harness.Controller.Snapshot.FrameMilliseconds); + Assert.Empty(harness.Calls); + } + + [Fact] + public void Publish_BelowHalfSecondDoesNotSampleOrPublish() + { + var harness = new Harness(resourceDump: true); + + harness.Publish(0.499999); + + Assert.Empty(harness.Calls); + Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, harness.Controller.Snapshot); + } + + [Fact] + public void Publish_AtHalfSecondPublishesTitleThenResourcesThenSnapshot() + { + using var culture = new CultureScope(CultureInfo.InvariantCulture); + var harness = new Harness(resourceDump: true); + + harness.Publish(0.2); + harness.Publish(0.3); + + Assert.Equal(["facts", "title", "resources", "log"], harness.Calls); + Assert.Equal( + "acdream | 4 fps | 250.0 ms | lb 17/43 | ent 3721/anim 226 | " + + "PY117 Leafcull 22 DawnsongAndHalf (df=0.3125)", + harness.Title); + Assert.Equal( + new RenderFrameDiagnosticsSnapshot( + Fps: 4.0, + FrameMilliseconds: 250.0, + VisibleLandblocks: 17, + TotalLandblocks: 43, + EntityCount: 3721, + AnimatedEntityCount: 226), + harness.Controller.Snapshot); + Assert.Equal( + RenderFrameDiagnosticsController.FormatGpuStream(ResourceFacts), + harness.Line); + } + + [Fact] + public void Publish_WhenResourceDumpIsOffDoesNotCaptureResources() + { + var harness = new Harness(resourceDump: false); + + harness.Publish(0.5); + + Assert.Equal(["facts", "title"], harness.Calls); + Assert.Null(harness.Line); + } + + [Fact] + public void Publish_DiscardsCadenceOvershootAfterSuccessfulPublication() + { + var harness = new Harness(resourceDump: false); + + harness.Publish(0.6); + harness.Calls.Clear(); + harness.Publish(0.4); + + Assert.Empty(harness.Calls); + Assert.Equal(1.0 / 0.6, harness.Controller.Snapshot.Fps, precision: 10); + + harness.Publish(0.1); + + Assert.Equal(["facts", "title"], harness.Calls); + Assert.Equal(4.0, harness.Controller.Snapshot.Fps, precision: 10); + Assert.Equal(250.0, harness.Controller.Snapshot.FrameMilliseconds, precision: 10); + } + + [Fact] + public void TitleFailure_DoesNotCaptureResourcesPublishSnapshotOrResetWindow() + { + var harness = new Harness(resourceDump: true) + { + TitleFailure = new InvalidOperationException("title"), + }; + + InvalidOperationException actual = Assert.Throws( + () => harness.Publish(0.5)); + + Assert.Same(harness.TitleFailure, actual); + Assert.Equal(["facts", "title"], harness.Calls); + Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, harness.Controller.Snapshot); + + harness.TitleFailure = null; + harness.Calls.Clear(); + harness.Publish(0.0); + + Assert.Equal(["facts", "title", "resources", "log"], harness.Calls); + Assert.Equal(4.0, harness.Controller.Snapshot.Fps, precision: 10); + Assert.Equal(250.0, harness.Controller.Snapshot.FrameMilliseconds, precision: 10); + } + + [Fact] + public void ResourceLogFailure_LeavesPublishedCacheAndCadenceUntouched() + { + var harness = new Harness(resourceDump: true) + { + LogFailure = new InvalidOperationException("log"), + }; + + InvalidOperationException actual = Assert.Throws( + () => harness.Publish(0.5)); + + Assert.Same(harness.LogFailure, actual); + Assert.Equal(["facts", "title", "resources", "log"], harness.Calls); + Assert.NotNull(harness.Title); + Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, harness.Controller.Snapshot); + + harness.LogFailure = null; + harness.Calls.Clear(); + harness.Publish(0.0); + + Assert.Equal(["facts", "title", "resources", "log"], harness.Calls); + Assert.Equal(4.0, harness.Controller.Snapshot.Fps, precision: 10); + } + + [Fact] + public void Constructor_RequiresEveryActiveNarrowSeam() + { + var facts = new FixedTitleFactsSource([], TitleFacts); + var title = new RecordingTitleSink([]); + var log = new RecordingLog([]); + var resources = new RecordingResources([], ResourceFacts); + + Assert.Throws(() => new RenderFrameDiagnosticsController( + null!, title, log, false)); + Assert.Throws(() => new RenderFrameDiagnosticsController( + facts, null!, log, false)); + Assert.Throws(() => new RenderFrameDiagnosticsController( + facts, title, null!, false)); + Assert.Throws(() => new RenderFrameDiagnosticsController( + facts, title, log, true)); + + _ = new RenderFrameDiagnosticsController(facts, title, log, false, resources: null); + _ = new RenderFrameDiagnosticsController(facts, title, log, true, resources); + } + + [Fact] + public void Controller_RetainsOnlyNarrowTypedSeamsAndValueState() + { + FieldInfo[] fields = typeof(RenderFrameDiagnosticsController).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain(fields, field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + Assert.DoesNotContain(fields, field => + field.FieldType.FullName?.Contains("Silk.NET.Windowing.IWindow", StringComparison.Ordinal) == true); + Assert.Contains(fields, field => field.FieldType == typeof(IRenderFrameTitleFactsSource)); + Assert.Contains(fields, field => field.FieldType == typeof(IRenderFrameTitleSink)); + Assert.Contains(fields, field => field.FieldType == typeof(IRenderFrameDiagnosticLog)); + Assert.Contains(fields, field => field.FieldType == typeof(IRenderFrameResourceDiagnosticsSource)); + Assert.True(typeof(IRenderFrameDiagnosticsPhase).IsAssignableFrom( + typeof(RenderFrameDiagnosticsController))); + } + + [Fact] + public void GpuStreamFormatter_PreservesTheAcceptedFieldOrderAndShape() + { + string actual = RenderFrameDiagnosticsController.FormatGpuStream(ResourceFacts); + + Assert.Equal( + "[gpu-stream] emit=1 particles=2 bindings=3 wbSets=4 cellSets=5 " + + "particleSets=6/7B uiBytes=8 portalBytes=9 clipSets=10 terrainBuffers=11 " + + "lightBuffers=12 mesh=13/14/15 meshEst=16 meshUpload=17/18 " + + "uploadFrame=19/20/21/22/23 bufferFrame=24/25/26/27 " + + "staging=28/29/True/30 cpuMesh=31/32 mipmapFrame=33/34 meshCap=35 " + + "meshPhys=36/True gpuTrack=57/58/59 managed=55/56 ownedTextures=37/38 " + + "particleTextures=39/40/41/42/43 compositeCache=44/45/46 " + + "compositeAtlas=47/48 compositeUpload=49/50/51", + actual); + } + + private sealed class Harness + { + public readonly List Calls = []; + public readonly RenderFrameDiagnosticsController Controller; + private readonly RecordingTitleSink _title; + private readonly RecordingLog _log; + + public Harness(bool resourceDump) + { + _title = new RecordingTitleSink(Calls); + _log = new RecordingLog(Calls); + Controller = new RenderFrameDiagnosticsController( + new FixedTitleFactsSource(Calls, TitleFacts), + _title, + _log, + resourceDump, + new RecordingResources(Calls, ResourceFacts)); + } + + public Exception? TitleFailure + { + get => _title.Failure; + set => _title.Failure = value; + } + + public Exception? LogFailure + { + get => _log.Failure; + set => _log.Failure = value; + } + + public string? Title => _title.Title; + public string? Line => _log.Line; + + public void Publish(double deltaSeconds) => Controller.Publish( + new RenderFrameInput(deltaSeconds, 1920, 1080), + Outcome); + } + + private sealed class FixedTitleFactsSource( + List calls, + RenderFrameTitleFacts facts) : IRenderFrameTitleFactsSource + { + public RenderFrameTitleFacts Capture() + { + calls.Add("facts"); + return facts; + } + } + + private sealed class RecordingTitleSink(List calls) : IRenderFrameTitleSink + { + public Exception? Failure { get; set; } + public string? Title { get; private set; } + + public void SetTitle(string title) + { + calls.Add("title"); + if (Failure is not null) + throw Failure; + Title = title; + } + } + + private sealed class RecordingResources( + List calls, + RenderFrameResourceDiagnosticsSnapshot snapshot) + : IRenderFrameResourceDiagnosticsSource + { + public RenderFrameResourceDiagnosticsSnapshot Capture() + { + calls.Add("resources"); + return snapshot; + } + } + + private sealed class RecordingLog(List calls) : IRenderFrameDiagnosticLog + { + public Exception? Failure { get; set; } + public string? Line { get; private set; } + + public void WriteLine(string message) + { + calls.Add("log"); + if (Failure is not null) + throw Failure; + Line = message; + } + } + + private sealed class CultureScope : IDisposable + { + private readonly CultureInfo _prior = CultureInfo.CurrentCulture; + + public CultureScope(CultureInfo culture) => CultureInfo.CurrentCulture = culture; + + public void Dispose() => CultureInfo.CurrentCulture = _prior; + } + + private static readonly RenderFrameResourceDiagnosticsSnapshot ResourceFacts = new( + new VfxStreamResourceDiagnostics(1, 2, 3), + new DynamicBufferResourceDiagnostics(4, 5, 6, 7, 8, 9, 10, 11, 12), + new MeshStreamResourceDiagnostics( + 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, true, 30, 31, 32, 33, 34, 35, 36, true), + new TextureStreamResourceDiagnostics( + 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51), + new ProcessResourceDiagnostics(55, 56, 57, 58, 59)); +} diff --git a/tests/AcDream.App.Tests/Rendering/RollingTimingSampleWindowTests.cs b/tests/AcDream.App.Tests/Rendering/RollingTimingSampleWindowTests.cs new file mode 100644 index 00000000..dec284d5 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RollingTimingSampleWindowTests.cs @@ -0,0 +1,66 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RollingTimingSampleWindowTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Constructor_RejectsNonPositiveCapacity(int capacity) + { + Assert.Throws( + () => new RollingTimingSampleWindow(capacity)); + } + + [Fact] + public void Snapshot_EmptyOrNonPositiveSamplesReturnsZeroes() + { + var samples = new RollingTimingSampleWindow(4); + samples.PushHundredthsMicroseconds(0); + samples.PushHundredthsMicroseconds(-10); + + Assert.Equal(default, samples.Snapshot()); + } + + [Fact] + public void Snapshot_UsesTheAcceptedUpperMedianAndP95Rules() + { + var samples = new RollingTimingSampleWindow(8); + samples.PushHundredthsMicroseconds(100); + samples.PushHundredthsMicroseconds(200); + + RollingTimingPercentiles snapshot = samples.Snapshot(); + + Assert.Equal(200, snapshot.MedianHundredthsMicroseconds); + Assert.Equal(200, snapshot.Percentile95HundredthsMicroseconds); + } + + [Fact] + public void Push_WrapsAndRetainsOnlyTheNewestCapacitySamples() + { + var samples = new RollingTimingSampleWindow(3); + samples.PushHundredthsMicroseconds(100); + samples.PushHundredthsMicroseconds(200); + samples.PushHundredthsMicroseconds(300); + samples.PushHundredthsMicroseconds(400); + + RollingTimingPercentiles snapshot = samples.Snapshot(); + + Assert.Equal(3, samples.Capacity); + Assert.Equal(300, snapshot.MedianHundredthsMicroseconds); + Assert.Equal(400, snapshot.Percentile95HundredthsMicroseconds); + } + + [Fact] + public void PushStopwatchTicks_UsesHundredthsOfAMicrosecondUnits() + { + var samples = new RollingTimingSampleWindow(1); + + samples.PushStopwatchTicks(System.Diagnostics.Stopwatch.Frequency); + + Assert.Equal( + new RollingTimingPercentiles(100_000_000, 100_000_000), + samples.Snapshot()); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs new file mode 100644 index 00000000..319bd3d8 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs @@ -0,0 +1,292 @@ +using System.Numerics; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.Core.Vfx; + +namespace AcDream.App.Tests.Rendering; + +public sealed class WorldRenderDiagnosticsTests +{ + [Fact] + public void GlTripwire_DisabledPerformsNoGlRead() + { + var gl = new RecordingGlStateReader(); + var log = new RecordingLog(); + var diagnostics = new WorldRenderDiagnostics(gl, log); + + diagnostics.EmitGlStateTripwireIfChanged(enabled: false); + + Assert.Equal(0, gl.StateCaptureCount); + Assert.Empty(log.Messages); + } + + [Fact] + public void GlTripwire_LogsOnlyChangesAndReportsStableFrameCount() + { + var gl = new RecordingGlStateReader + { + State = State(depthFunction: 0x201), + }; + var log = new RecordingLog(); + var diagnostics = new WorldRenderDiagnostics(gl, log); + + diagnostics.EmitGlStateTripwireIfChanged(enabled: true); + diagnostics.EmitGlStateTripwireIfChanged(enabled: true); + gl.State = State(depthFunction: 0x203); + diagnostics.EmitGlStateTripwireIfChanged(enabled: true); + + Assert.Equal(3, gl.StateCaptureCount); + Assert.Collection( + log.Messages, + first => + { + Assert.StartsWith("[gl-state] frame=1 stable=0", first); + Assert.Contains("dfunc=0x201", first); + }, + changed => + { + Assert.StartsWith("[gl-state] frame=3 stable=1", changed); + Assert.Contains("dfunc=0x203", changed); + }); + } + + [Fact] + public void ScissorProbe_SequenceAdvancesAcrossSuppressedDuplicates() + { + var gl = new RecordingGlStateReader + { + Scissor = new RenderGlScissorSnapshot( + true, + new IntRenderRectangle(1, 2, 3, 4)), + }; + var log = new RecordingLog(); + var diagnostics = new WorldRenderDiagnostics(gl, log); + + diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1)); + diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1)); + gl.Scissor = new RenderGlScissorSnapshot( + true, + new IntRenderRectangle(5, 6, 7, 8)); + diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1)); + + Assert.Equal(3, gl.ScissorCaptureCount); + Assert.Collection( + log.Messages, + first => Assert.StartsWith("[clip-route-scis] n=1", first), + changed => Assert.StartsWith("[clip-route-scis] n=3", changed)); + } + + [Fact] + public void Owner_RetainsNoBorrowedFrameProductsOrBroadRuntimeOwners() + { + FieldInfo[] fields = typeof(WorldRenderDiagnostics).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + + Type[] forbidden = + [ + typeof(GameWindow), + typeof(PortalVisibilityFrame), + typeof(ClipFrameAssembly), + typeof(InteriorEntityPartition.Result), + typeof(AcDream.Core.Physics.PhysicsEngine), + typeof(CameraController), + typeof(CellVisibility), + ]; + foreach (Type type in forbidden) + Assert.DoesNotContain(fields, field => field.FieldType == type); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + [Fact] + public void SilkGlReader_ConsumesEnteringErrorBeforeStateQueries() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "WorldRenderDiagnostics.cs")); + + int captureStart = source.IndexOf( + "public RenderGlStateSnapshot CaptureState()", + StringComparison.Ordinal); + int error = source.IndexOf("_gl.GetError()", captureStart, StringComparison.Ordinal); + int depth = source.IndexOf( + "_gl.IsEnabled(EnableCap.DepthTest)", + captureStart, + StringComparison.Ordinal); + + Assert.True(captureStart >= 0); + Assert.True(error > captureStart); + Assert.True(depth > error); + } + + [Fact] + public void TerrainDiagnostics_RetryFailedPublicationWithoutLosingSamples() + { + var log = new ThrowOnceLog(); + var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log); + var facts = new TerrainRenderDiagnosticFacts(3, 5, 8); + + diagnostics.BeginTerrainDraw(); + diagnostics.EndTerrainDraw(); + Assert.Throws(() => + diagnostics.PublishTerrainDiagnostics(facts)); + + diagnostics.BeginTerrainDraw(); + diagnostics.EndTerrainDraw(); + diagnostics.PublishTerrainDiagnostics(facts); + + Assert.Single(log.Messages); + Assert.Contains("draws=3/frame", log.Messages[0]); + Assert.Contains("visible=3", log.Messages[0]); + Assert.Contains("loaded=5", log.Messages[0]); + Assert.Contains("capacity=8", log.Messages[0]); + } + + [Fact] + public void RenderSignature_LogsOnlyChangesAndCarriesStableCount() + { + var log = new RecordingLog(); + var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log); + + EmitMinimalSignature(diagnostics, "flat"); + EmitMinimalSignature(diagnostics, "flat"); + EmitMinimalSignature(diagnostics, "pview"); + + Assert.Collection( + log.Messages, + first => Assert.StartsWith("[render-sig] frame=1 stable=0 branch=flat", first), + changed => Assert.StartsWith("[render-sig] frame=3 stable=1 branch=pview", changed)); + } + + [Fact] + public void OutStageParticleProbe_IsDisabledWithoutEnumerationAndSuppressesDuplicates() + { + var log = new RecordingLog(); + var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log); + var particles = new ParticleSystem(new EmitterDescRegistry()); + + diagnostics.EmitOutStageParticles(false, particles, new HashSet()); + diagnostics.EmitOutStageParticles(true, particles, new HashSet()); + diagnostics.EmitOutStageParticles(true, particles, new HashSet()); + + Assert.Single(log.Messages); + Assert.Equal( + "[outstage-pt] ids=0 attachedEmitters=0 matched=0 unattached=0 matchedIds=[] unmatchedIds=[]", + log.Messages[0]); + } + + private static RenderGlStateSnapshot State(int depthFunction) => new( + DepthTest: true, + DepthWrite: true, + DepthFunction: depthFunction, + Blend: false, + BlendSource: 1, + BlendDestination: 0, + CullFace: true, + CullMode: 0x405, + FrontFace: 0x900, + Scissor: false, + ScissorBox: new IntRenderRectangle(0, 0, 1280, 720), + Viewport: new IntRenderRectangle(0, 0, 1280, 720), + DrawFramebuffer: 0, + AlphaToCoverage: false, + Stencil: false, + ClipBits: 0, + Error: 0); + + private sealed class RecordingGlStateReader : IRenderGlStateReader + { + public RenderGlStateSnapshot State { get; set; } = WorldRenderDiagnosticsTests.State(0x201); + public RenderGlScissorSnapshot Scissor { get; set; } + public int StateCaptureCount { get; private set; } + public int ScissorCaptureCount { get; private set; } + + public RenderGlStateSnapshot CaptureState() + { + StateCaptureCount++; + return State; + } + + public RenderGlScissorSnapshot CaptureScissor() + { + ScissorCaptureCount++; + return Scissor; + } + } + + private sealed class RecordingLog : IRenderFrameDiagnosticLog + { + public List Messages { get; } = []; + + public void WriteLine(string message) => Messages.Add(message); + } + + private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog + { + private bool _throw = true; + public List Messages { get; } = []; + + public void WriteLine(string message) + { + if (_throw) + { + _throw = false; + throw new InvalidOperationException("diagnostic sink failed"); + } + + Messages.Add(message); + } + } + + private static void EmitMinimalSignature( + WorldRenderDiagnostics diagnostics, + string branch) => + diagnostics.EmitRenderSignatureIfChanged( + enabled: true, + branch: branch, + clipRoot: null, + viewerRoot: null, + playerRoot: null, + viewerCellId: 0, + playerCellId: 0, + playerIndoorGate: false, + cameraInsideCell: false, + renderSkyGate: false, + drawSkyThisFrame: false, + terrainDrawn: false, + terrainClipMode: TerrainClipMode.Skip, + skyDrawn: false, + depthClear: false, + outdoorSceneryDrawn: false, + outdoorPortalDrawn: false, + outdoorRootObjectCount: 0, + liveDynamicDrawnCount: 0, + sceneParticles: "none", + portalFrame: null, + clipAssembly: null, + drawableCells: null, + partition: null, + exteriorPortalFrame: null, + exteriorClipAssembly: null, + exteriorDrawableCells: null, + exteriorPartition: null, + cameraPosition: Vector3.Zero, + playerPosition: Vector3.Zero); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} From bc6f09f9878cc530be8ce28d912f4729e4df6aa5 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 05:23:46 +0200 Subject: [PATCH 84/98] refactor(render): extract frame resource preparation Move ordered resource begin, clear/state setup, live upload/reveal, weather timing, and display pacing behind focused owners while preserving the accepted frame order and retryable shutdown ownership. Co-authored-by: Codex --- ...ndow-slice-7-render-frame-orchestration.md | 14 + .../Rendering/DisplayFramePacingController.cs | 141 +++++++ src/AcDream.App/Rendering/GameWindow.cs | 289 +++++--------- .../Rendering/GpuFrameFlightController.cs | 1 + .../RenderFrameResourceController.cs | 373 ++++++++++++++++++ .../DisplayFramePacingControllerTests.cs | 208 ++++++++++ .../GameWindowRenderLeafCompositionTests.cs | 53 ++- .../RenderFrameResourceControllerTests.cs | 194 +++++++++ 8 files changed, 1068 insertions(+), 205 deletions(-) create mode 100644 src/AcDream.App/Rendering/DisplayFramePacingController.cs create mode 100644 src/AcDream.App/Rendering/RenderFrameResourceController.cs create mode 100644 tests/AcDream.App.Tests/Rendering/DisplayFramePacingControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md index ac50d0e7..1cf8ede2 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md +++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md @@ -377,6 +377,20 @@ corrected-diff reviews are clean. - prove `WbMeshAdapter.Tick` remains before reveal evaluation and drawing; - prove dispatcher begin occurs once for the whole world/paperdoll frame. +Completed 2026-07-22. `RenderFrameResourceController` now owns the exact +resource-begin → atmosphere/GL-clear → live-upload/reveal/particle transaction, +with a single captured GPU slot shared by every phase. Focused sources own the +login reveal cell, teleport render state, upload timing, and render-time weather +clock. `DisplayFramePacingController` owns requested VSync, the event-refreshed +monitor-rate cache, software pacing, and the post-render callback without adding +a monitor query to the frame hot path. Borrowed frame owners are withdrawn before +render teardown; portal fallback ownership transfers before the throwable sink +bind; pacing releases its borrowed surface/profiler before their owners close. +`GameWindow.cs` is 6,181 raw lines, 60.7% below the 15,723-line campaign +baseline. Twenty-one focused tests, the Release build, and the complete suite +pass (7,269 passed / 5 skipped). Retail, architecture, and adversarial +corrected-diff reviews are clean. + ### D — world frame builder - move animated/equipped classification, camera/root selection, outdoor-node diff --git a/src/AcDream.App/Rendering/DisplayFramePacingController.cs b/src/AcDream.App/Rendering/DisplayFramePacingController.cs new file mode 100644 index 00000000..a9f378b2 --- /dev/null +++ b/src/AcDream.App/Rendering/DisplayFramePacingController.cs @@ -0,0 +1,141 @@ +using AcDream.App.Diagnostics; +using Silk.NET.Maths; +using Silk.NET.Windowing; + +namespace AcDream.App.Rendering; + +internal interface IDisplayFramePacingSurface +{ + bool VSync { get; set; } + + int? ActiveMonitorRefreshHz { get; } +} + +/// Narrow Silk window adapter for display pacing. +internal sealed class SilkDisplayFramePacingSurface : IDisplayFramePacingSurface +{ + private readonly IWindow _window; + + public SilkDisplayFramePacingSurface(IWindow window) + { + _window = window ?? throw new ArgumentNullException(nameof(window)); + } + + public bool VSync + { + get => _window.VSync; + set => _window.VSync = value; + } + + public int? ActiveMonitorRefreshHz + { + get + { + int? refreshHz = _window.Monitor?.VideoMode.RefreshRate; + return refreshHz is > 0 ? refreshHz : null; + } + } +} + +/// +/// Owns requested VSync, monitor-aware policy resolution, the software pacer, +/// and the post-render pacing event. Window lifecycle and frame preparation +/// call this typed owner rather than duplicating pacing state. +/// +internal sealed class DisplayFramePacingController : IDisposable +{ + private readonly bool _uncappedRendering; + private FrameProfiler? _profiler; + private readonly FramePacingController _pacing; + private IDisplayFramePacingSurface? _surface; + private int? _activeMonitorRefreshHz; + private bool _requestedVSync = + AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default.VSync; + + public DisplayFramePacingController( + bool uncappedRendering, + FrameProfiler profiler) + : this(uncappedRendering, profiler, new FramePacingController()) + { + } + + internal DisplayFramePacingController( + bool uncappedRendering, + FrameProfiler profiler, + FramePacingController pacing) + { + _uncappedRendering = uncappedRendering; + _profiler = profiler ?? throw new ArgumentNullException(nameof(profiler)); + _pacing = pacing ?? throw new ArgumentNullException(nameof(pacing)); + } + + internal bool RequestedVSync => _requestedVSync; + + internal FramePacingPolicy Policy => _pacing.Policy; + + internal bool IsSurfaceBound => _surface is not null; + + public FramePacingPolicy InitializeStartup(bool requestedVSync) + { + _requestedVSync = requestedVSync; + FramePacingPolicy policy = Resolve(monitorRefreshHz: null); + _pacing.Apply(policy); + return policy; + } + + public void BindSurface(IDisplayFramePacingSurface surface) + { + _surface = surface ?? throw new ArgumentNullException(nameof(surface)); + } + + public void ApplyPreference(bool requestedVSync) + { + _requestedVSync = requestedVSync; + ApplyResolvedPolicy(); + } + + public void RefreshActiveMonitor() + { + _activeMonitorRefreshHz = _surface?.ActiveMonitorRefreshHz; + ApplyResolvedPolicy(); + } + + public void OnWindowMoved(Vector2D _) => RefreshActiveMonitor(); + + public void OnWindowStateChanged(WindowState _) => RefreshActiveMonitor(); + + public void OnFrameRendered(double _) + { + FrameProfiler profiler = _profiler + ?? throw new ObjectDisposedException(nameof(DisplayFramePacingController)); + using var pacingStage = profiler.BeginStage(FrameStage.Pacing); + _pacing.CompleteFrame(); + } + + private FramePacingPolicy Resolve(int? monitorRefreshHz) => + FramePacingPolicy.Resolve( + _requestedVSync, + _uncappedRendering, + monitorRefreshHz); + + private void ApplyResolvedPolicy() + { + FramePacingPolicy policy = Resolve(_activeMonitorRefreshHz); + _pacing.Apply(policy); + if (_surface is not null && _surface.VSync != policy.UseVSync) + _surface.VSync = policy.UseVSync; + } + + public void Dispose() + { + try + { + _pacing.Dispose(); + } + finally + { + _surface = null; + _profiler = null; + } + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 654acb7d..884af989 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -85,8 +85,6 @@ public sealed class GameWindow : IDisposable System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", System.StringComparison.Ordinal); - private readonly AcDream.App.Rendering.RollingTimingSampleWindow - _entityUploadTiming = new(256); private long _frameDiagLastPublicationMilliseconds; // MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call @@ -104,11 +102,14 @@ public sealed class GameWindow : IDisposable private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; + private AcDream.App.Rendering.RenderFrameResourceController? + _renderFrameResources; + private AcDream.App.Rendering.RuntimeRenderFrameLivePreparation? + _renderFrameLivePreparation; + private AcDream.App.Rendering.RenderWeatherFrameController? + _renderWeatherFrame; private ResourceShutdownTransaction? _shutdown; - private readonly FramePacingController _framePacing = new(); - private bool _requestedVSync = - AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default.VSync; - private int? _activeMonitorRefreshHz; + private readonly DisplayFramePacingController _displayFramePacing; // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; @@ -513,8 +514,6 @@ public sealed class GameWindow : IDisposable private long _loadedSkyDayIndex = long.MinValue; private AcDream.Core.World.DayGroupData? _activeDayGroup; - private double _weatherAccum; - // F7 / F10 debug-cycle steps for time + weather. Initialized out of // range of the real values so the first press hits index 0 of the // cycle table cleanly. @@ -720,6 +719,9 @@ public sealed class GameWindow : IDisposable _datDir = options.DatDir; _worldGameState = worldGameState; _worldEvents = worldEvents; + _displayFramePacing = new DisplayFramePacingController( + options.UncappedRendering, + _frameProfiler); _selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _uiRegistry = uiRegistry; @@ -760,12 +762,8 @@ public sealed class GameWindow : IDisposable var startupDisplay = startupStore.LoadDisplay(); var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); - _requestedVSync = startupDisplay.VSync; - FramePacingPolicy startupPacing = FramePacingPolicy.Resolve( - startupDisplay.VSync, - _options.UncappedRendering, - monitorRefreshHz: null); - _framePacing.Apply(startupPacing); + FramePacingPolicy startupPacing = + _displayFramePacing.InitializeStartup(startupDisplay.VSync); var options = WindowOptions.Default with { Size = new Vector2D(1280, 720), @@ -789,16 +787,18 @@ public sealed class GameWindow : IDisposable }; _window = Window.Create(options); + _displayFramePacing.BindSurface( + new SilkDisplayFramePacingSurface(_window)); _window.Load += OnLoad; _window.Update += OnUpdate; _window.Render += OnRender; // Registered after OnRender so software pacing waits after all frame // work and before Silk.NET performs its automatic buffer swap. - _window.Render += OnFrameRendered; + _window.Render += _displayFramePacing.OnFrameRendered; _window.Closing += OnClosing; _window.FocusChanged += OnFocusChanged; - _window.Move += OnWindowMoved; - _window.StateChanged += OnWindowStateChanged; + _window.Move += _displayFramePacing.OnWindowMoved; + _window.StateChanged += _displayFramePacing.OnWindowStateChanged; // L.0 Display tab: keep the GL viewport + camera aspect in sync // with the window framebuffer. Without this handler, resizing // the window (or applying a Display-tab Resolution change at @@ -2840,11 +2840,56 @@ public sealed class GameWindow : IDisposable new AcDream.App.Streaming.LocalPlayerTeleportSession( _liveSessionController), localTeleportPresentation); - _localPlayerTeleportSink.Bind(_localPlayerTeleport); // Ownership transferred to LocalPlayerTeleportController. This field // remains only as the staged-startup fallback used by shutdown when // composition fails before the transfer. _portalTunnel = null; + _localPlayerTeleportSink.Bind(_localPlayerTeleport); + var teleportRenderState = + new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource( + _localPlayerTeleport); + _renderFrameLivePreparation = + new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation( + _textureCache, + _wbMeshAdapter, + _worldReveal, + teleportRenderState, + new AcDream.App.Rendering.RenderLoginStateSource( + _options.LiveMode, + _localPlayerMode), + new AcDream.App.Rendering.LiveLoginRevealCellSource( + _liveEntities!, + _localPlayerIdentity), + _particleRenderer, + _frameProfiler, + _frameDiag); + _renderFrameResources = + new AcDream.App.Rendering.RenderFrameResourceController( + _gpuFrameFlights!, + new AcDream.App.Rendering.RuntimeRenderFrameBeginResources( + _textureCache, + _wbDrawDispatcher, + _envCellRenderer, + _portalDepthMask, + _textRenderer, + _uiHost?.TextRenderer, + _clipFrame, + _terrain, + _sceneLightingUbo, + _frameProfiler, + _gl!), + new AcDream.App.Rendering.RuntimeRenderFrameClearPhase( + _gl!, + WorldTime, + Weather, + teleportRenderState, + _particleVisibility, + _worldRenderDiagnostics!), + _renderFrameLivePreparation); + _renderWeatherFrame = + new AcDream.App.Rendering.RenderWeatherFrameController( + WorldTime, + Weather); var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( liveObjectFrame, _worldState, @@ -3223,18 +3268,6 @@ public sealed class GameWindow : IDisposable session.SendTurbineChatTo( roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - private bool TryGetLoginWorldCell(out uint cell) - { - cell = 0; - if (_liveEntities is null - || !_liveEntities.TryGetSnapshot(_playerServerGuid, out var playerSpawn) - || playerSpawn.Position is not { } position) - return false; - - cell = position.LandblockId; - return cell != 0; - } - private void UpdateSkyPes( float dayFraction, AcDream.Core.World.DayGroupData? dayGroup, @@ -3440,112 +3473,23 @@ public sealed class GameWindow : IDisposable Exception? renderFailure = null; try { - _textureCache?.BeginCompositeTextureFrame(); - _textureCache?.TickCompositeTextureCache(); - _wbDrawDispatcher?.BeginFrame(_gpuFrameFlights.CurrentSlot); - _envCellRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); - _portalDepthMask?.BeginFrame(_gpuFrameFlights.CurrentSlot); - _textRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); - _uiHost?.TextRenderer.BeginFrame(_gpuFrameFlights.CurrentSlot); - _clipFrame?.BeginFrame(_gpuFrameFlights.CurrentSlot); - _terrain?.BeginFrame(_gpuFrameFlights.CurrentSlot); - _sceneLightingUbo?.BeginFrame(_gpuFrameFlights.CurrentSlot); - _frameProfiler.FrameBoundary(_gl!); + GL gl = _gl!; + var frameInput = new AcDream.App.Rendering.RenderFrameInput( + deltaSeconds, + _window!.Size.X, + _window.Size.Y); + _renderFrameResources!.Prepare(frameInput); + AcDream.App.Rendering.RenderFrameFoundation foundation = + _renderFrameResources.Foundation; // gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the // normal SmartBox viewport and UIElement_Viewport's portal // CreatureMode; it does not redraw the world behind portal space. - bool portalViewportVisible = - _localPlayerTeleport?.IsPortalViewportVisible == true; - if (portalViewportVisible) - _particleVisibility.Reset(); + bool portalViewportVisible = foundation.PortalViewportVisible; - // Phase G.1: set the clear color from the current sky's fog - // tint so the horizon band continues naturally past the - // rendered geometry. Fog blends to this color at max distance - // so there's no visible seam. Updated each frame from the - // interpolated keyframe. - var kf = WorldTime.CurrentSky; - var atmo = Weather.Snapshot(in kf); - bool environOverrideActive = atmo.Override != AcDream.Core.World.EnvironOverride.None; - var fogColor = atmo.FogColor; - // Clear to fog color (horizon haze) so if sky meshes have alpha - // gaps or don't cover the full view, the "missing" area reads as - // distant haze, not as pitch-black. Fog color is clamped to 0..1 - // since keyframes may pre-multiply DirBright and produce over-1 - // values that some drivers interpret as "bright clamp" (pink/green - // frames). - if (portalViewportVisible) - { - // SceneTool::BeginScene @ 0x0043DAD0 begins every retail frame - // with RenderDevice.Clear(7, black, 1). The portal viewport later - // clears depth only, preserving this frame's black target—not a - // previous swap-chain image or the hidden destination world. - _gl!.ClearColor(0f, 0f, 0f, 1f); - } - else - { - _gl!.ClearColor( - System.Math.Clamp(fogColor.X, 0f, 1f), - System.Math.Clamp(fogColor.Y, 0f, 1f), - System.Math.Clamp(fogColor.Z, 0f, 1f), - 1f); - } - - // §4 outdoor full-world flap (2026-06-10): the depth clear DEPENDS on the depth - // write mask — glClear(GL_DEPTH_BUFFER_BIT) is silently gated by glDepthMask. - // A pass that leaked DepthMask(false) at frame end (EnvCellRenderer's empty - // Transparent pass, fixed same-day) turned this clear into a no-op and the whole - // world failed GL_LESS against its own previous-frame depth ghost. Per - // feedback_render_self_contained_gl_state: the clear site asserts the state it - // depends on rather than inheriting it. The [gl-state] tripwire still detects - // any future leak. - _gl!.DepthMask(true); - _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit); - - // WB GameScene.cs:830-843 establishes CW as the frame-global - // front-face convention; per-batch CullMode changes only the culled - // side. A8 indoor/env-cell geometry and setup meshes share that - // convention, so keep the GL state aligned before any scene pass. - _gl.CullFace(TriangleFace.Back); - _gl.FrontFace(FrontFaceDirection.CW); - - // §4 flap apparatus: GL-state tripwire — one [gl-state] line whenever the - // state entering the world passes differs from the previous frame. - _worldRenderDiagnostics?.EmitGlStateTripwireIfChanged( - AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled); - - // Phase N.6 slice 1: one-shot surface-format histogram dump under - // ACDREAM_DUMP_SURFACES=1. Zero cost when off. - _textureCache?.TickSurfaceHistogramDumpIfEnabled(); - - // Phase N.4: drain WB pipeline queues (staged mesh data + - // GL thread queue). Must happen before any draw work so that - // resources uploaded this frame are available immediately. - // No-op when ACDREAM_USE_WB_FOUNDATION is off (_wbMeshAdapter is null). - // [FRAME-DIAG]: this OnRender drain is where staged GfxObj/entity meshes hit - // the GPU (uncapped) — the H2 entity-upload suspect, separate from the apply. - long fdE0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; - using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload)) - { - _wbMeshAdapter?.Tick(); - uint revealCell = _localPlayerTeleport?.ActiveDestinationCell is uint teleportCell - && teleportCell != 0u - ? teleportCell - : IsLiveModeWaitingForLogin && TryGetLoginWorldCell(out uint loginCell) - ? loginCell - : 0u; - if (revealCell != 0) - { - _worldReveal?.PrepareAndEvaluate(revealCell); - } - _particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); - } - if (_frameDiag) - { - _entityUploadTiming.PushStopwatchTicks( - System.Diagnostics.Stopwatch.GetTimestamp() - fdE0); - } + AcDream.Core.World.SkyKeyframe kf = foundation.Sky; + AcDream.Core.World.AtmosphereSnapshot atmo = foundation.Atmosphere; + bool environOverrideActive = foundation.EnvironOverrideActive; // Phase D.2a — begin ImGui frame. Paired with the Render() call // after the scene draws (below). ImGuiController.Update() @@ -3554,13 +3498,7 @@ public sealed class GameWindow : IDisposable // Phase G.1: weather state machine — deterministic per-day roll // + transitions + lightning flash. - var cal = WorldTime.CurrentCalendar; - int dayIndex = cal.Year * (AcDream.Core.World.DerethDateTime.DaysInAMonth * - AcDream.Core.World.DerethDateTime.MonthsInAYear) - + (int)cal.Month * AcDream.Core.World.DerethDateTime.DaysInAMonth - + (cal.Day - 1); - Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds); - _weatherAccum += deltaSeconds; + _renderWeatherFrame!.Tick(deltaSeconds); // (Pre-Bug-A code spawned camera-attached rain/snow particle // emitters here as a workaround for missing weather-mesh @@ -3636,7 +3574,7 @@ public sealed class GameWindow : IDisposable _cameraController.Fly.FovY = fovYRad; if (_cameraController.Chase is not null) _cameraController.Chase.FovY = fovYRad; - ApplyFramePacingPreference(d.VSync); + _displayFramePacing.ApplyPreference(d.VSync); } // Phase E.2 audio: update listener pose so 3D sounds pan/attenuate @@ -4015,9 +3953,9 @@ public sealed class GameWindow : IDisposable // Outdoor frames have one no-clip terrain state. Indoor frames // defer both allocations to RetailPViewRenderer, which knows the // exact slice count and packs every terrain state into one arena. - _clipFrame.ReserveTerrainUploads(_gl, 1); - _clipFrame.UploadRegions(_gl); - TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(_gl); + _clipFrame.ReserveTerrainUploads(gl, 1); + _clipFrame.UploadRegions(gl); + TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(gl); _wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo); _envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo); _terrain?.SetClipUbo(terrainBinding); @@ -4035,13 +3973,13 @@ public sealed class GameWindow : IDisposable sigSkyDrawn = drawSkyThisFrame; if (drawSkyThisFrame) { - _clipFrame.BindTerrainClip(_gl); + _clipFrame.BindTerrainClip(gl); for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - _gl.Enable(EnableCap.ClipDistance0 + _cp); + gl.Enable(EnableCap.ClipDistance0 + _cp); _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, _activeDayGroup, kf, environOverrideActive); for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - _gl.Disable(EnableCap.ClipDistance0 + _cp); + gl.Disable(EnableCap.ClipDistance0 + _cp); if (_particleSystem is not null && _particleRenderer is not null) _particleRenderer.Draw(camera, camPos, @@ -4147,9 +4085,9 @@ public sealed class GameWindow : IDisposable ? null : () => { - _gl.Disable(EnableCap.ScissorTest); - _gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson) - _gl.Clear(ClearBufferMask.DepthBufferBit); + gl.Disable(EnableCap.ScissorTest); + gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson) + gl.Clear(ClearBufferMask.DepthBufferBit); }, DrawExitPortalMasks = sliceCtx => DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, @@ -4283,7 +4221,7 @@ public sealed class GameWindow : IDisposable // below DOES use sky.vert — it re-enables the planes in its OWN local bracket; the sky // pre-scene pass above already did the same. Both are scissored to the doorway too.) for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - _gl.Disable(EnableCap.ClipDistance0 + _cp); + gl.Disable(EnableCap.ClipDistance0 + _cp); // Phase G.1 / E.3: draw all live particles after opaque // scene geometry so alpha blending composites correctly. @@ -4342,13 +4280,13 @@ public sealed class GameWindow : IDisposable if (clipRoot is null && drawSkyThisFrame) { sigSkyDrawn = true; - _clipFrame.BindTerrainClip(_gl); + _clipFrame.BindTerrainClip(gl); for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - _gl.Enable(EnableCap.ClipDistance0 + _cp); + gl.Enable(EnableCap.ClipDistance0 + _cp); _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, _activeDayGroup, kf, environOverrideActive); for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - _gl.Disable(EnableCap.ClipDistance0 + _cp); + gl.Disable(EnableCap.ClipDistance0 + _cp); if (_particleSystem is not null && _particleRenderer is not null) _particleRenderer.Draw(camera, camPos, @@ -5390,8 +5328,8 @@ public sealed class GameWindow : IDisposable // resolution + fullscreen use the on-Save path below. if (_window is not null) { - RefreshActiveMonitorFramePacing(); - ApplyFramePacingPreference(_persistedDisplay.VSync); + _displayFramePacing.RefreshActiveMonitor(); + _displayFramePacing.ApplyPreference(_persistedDisplay.VSync); ApplyDisplayWindowState(_persistedDisplay); } @@ -5532,39 +5470,6 @@ public sealed class GameWindow : IDisposable _window.WindowState = desiredState; } - private void ApplyFramePacingPreference(bool requestedVSync) - { - _requestedVSync = requestedVSync; - FramePacingPolicy policy = FramePacingPolicy.Resolve( - requestedVSync, - _options.UncappedRendering, - _activeMonitorRefreshHz); - _framePacing.Apply(policy); - - if (_window is not null && _window.VSync != policy.UseVSync) - _window.VSync = policy.UseVSync; - } - - private void RefreshActiveMonitorFramePacing() - { - int? refreshHz = _window?.Monitor?.VideoMode.RefreshRate; - _activeMonitorRefreshHz = refreshHz is > 0 ? refreshHz : null; - ApplyFramePacingPreference(_requestedVSync); - } - - private void OnFrameRendered(double _) - { - using var pacingStage = _frameProfiler.BeginStage( - AcDream.App.Diagnostics.FrameStage.Pacing); - _framePacing.CompleteFrame(); - } - - private void OnWindowMoved(Silk.NET.Maths.Vector2D _) - => RefreshActiveMonitorFramePacing(); - - private void OnWindowStateChanged(Silk.NET.Windowing.WindowState _) - => RefreshActiveMonitorFramePacing(); - private static bool TryParseResolution(string spec, out int width, out int height) { width = height = 0; @@ -6007,7 +5912,7 @@ public sealed class GameWindow : IDisposable double ticksToMicros = 1_000_000.0 / System.Diagnostics.Stopwatch.Frequency; AcDream.App.Rendering.RollingTimingPercentiles upload = - _entityUploadTiming.Snapshot(); + _renderFrameLivePreparation?.UploadTiming ?? default; double uploadMedian = upload.MedianHundredthsMicroseconds / 100.0; double uploadP95 = upload.Percentile95HundredthsMicroseconds / 100.0; int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0; @@ -6154,6 +6059,12 @@ public sealed class GameWindow : IDisposable ]), new ResourceShutdownStage("render frontends", [ + new("frame composition", () => + { + _renderFrameResources = null; + _renderFrameLivePreparation = null; + _renderWeatherFrame = null; + }), new("portal tunnel", () => { _localPlayerTeleport?.Dispose(); @@ -6200,8 +6111,8 @@ public sealed class GameWindow : IDisposable new("debug lines", () => _debugLines?.Dispose()), new("text renderer", () => _textRenderer?.Dispose()), new("debug font", () => _debugFont?.Dispose()), + new("frame pacing", _displayFramePacing.Dispose), new("frame profiler", _frameProfiler.Dispose), - new("frame pacing", _framePacing.Dispose), ]), new ResourceShutdownStage("frame flight owner", [ diff --git a/src/AcDream.App/Rendering/GpuFrameFlightController.cs b/src/AcDream.App/Rendering/GpuFrameFlightController.cs index d2342d7d..a0a6b78b 100644 --- a/src/AcDream.App/Rendering/GpuFrameFlightController.cs +++ b/src/AcDream.App/Rendering/GpuFrameFlightController.cs @@ -217,6 +217,7 @@ internal sealed class ImmediateGpuResourceRetirementQueue : IGpuResourceRetireme internal sealed class GpuFrameFlightController : IGpuResourceRetirementQueue, IRenderFrameLifetime, + IRenderFrameSlotSource, IDisposable { internal const int DefaultMaximumFramesInFlight = 3; diff --git a/src/AcDream.App/Rendering/RenderFrameResourceController.cs b/src/AcDream.App/Rendering/RenderFrameResourceController.cs new file mode 100644 index 00000000..7d98733f --- /dev/null +++ b/src/AcDream.App/Rendering/RenderFrameResourceController.cs @@ -0,0 +1,373 @@ +using AcDream.App.Diagnostics; +using AcDream.App.Input; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.World; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +internal readonly record struct RenderFrameFoundation( + bool PortalViewportVisible, + SkyKeyframe Sky, + AtmosphereSnapshot Atmosphere) +{ + public bool EnvironOverrideActive => + Atmosphere.Override != EnvironOverride.None; +} + +internal interface IRenderFrameBeginResources +{ + void Begin(int gpuSlot); +} + +internal interface IRenderFrameSlotSource +{ + int CurrentSlot { get; } +} + +internal interface IRenderFrameClearPhase +{ + RenderFrameFoundation Clear(); +} + +internal interface IRenderFrameLivePreparation +{ + void Prepare(int gpuSlot); +} + +/// +/// Owns the pre-world resource transaction after GPU-flight begin. The three +/// typed phases preserve resource begin, clear/state, then live publication +/// order without exposing a renderer service bag. +/// +internal sealed class RenderFrameResourceController : IRenderFrameResourcePhase +{ + private readonly IRenderFrameSlotSource _frameSlots; + private readonly IRenderFrameBeginResources _resources; + private readonly IRenderFrameClearPhase _clear; + private readonly IRenderFrameLivePreparation _live; + + public RenderFrameResourceController( + IRenderFrameSlotSource frameSlots, + IRenderFrameBeginResources resources, + IRenderFrameClearPhase clear, + IRenderFrameLivePreparation live) + { + _frameSlots = frameSlots + ?? throw new ArgumentNullException(nameof(frameSlots)); + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + _clear = clear ?? throw new ArgumentNullException(nameof(clear)); + _live = live ?? throw new ArgumentNullException(nameof(live)); + } + + public RenderFrameFoundation Foundation { get; private set; } + + public void Prepare(RenderFrameInput input) + { + int gpuSlot = _frameSlots.CurrentSlot; + _resources.Begin(gpuSlot); + Foundation = _clear.Clear(); + _live.Prepare(gpuSlot); + } +} + +/// Exact ordered begin calls for resources shared by the frame. +internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResources +{ + private readonly TextureCache? _textures; + private readonly WbDrawDispatcher? _dispatcher; + private readonly EnvCellRenderer? _environmentCells; + private readonly PortalDepthMaskRenderer? _portalDepth; + private readonly TextRenderer? _worldText; + private readonly TextRenderer? _uiText; + private readonly ClipFrame? _clip; + private readonly TerrainModernRenderer? _terrain; + private readonly SceneLightingUboBinding? _lighting; + private readonly FrameProfiler _profiler; + private readonly GL _gl; + + public RuntimeRenderFrameBeginResources( + TextureCache? textures, + WbDrawDispatcher? dispatcher, + EnvCellRenderer? environmentCells, + PortalDepthMaskRenderer? portalDepth, + TextRenderer? worldText, + TextRenderer? uiText, + ClipFrame? clip, + TerrainModernRenderer? terrain, + SceneLightingUboBinding? lighting, + FrameProfiler profiler, + GL gl) + { + _textures = textures; + _dispatcher = dispatcher; + _environmentCells = environmentCells; + _portalDepth = portalDepth; + _worldText = worldText; + _uiText = uiText; + _clip = clip; + _terrain = terrain; + _lighting = lighting; + _profiler = profiler ?? throw new ArgumentNullException(nameof(profiler)); + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + } + + public void Begin(int gpuSlot) + { + _textures?.BeginCompositeTextureFrame(); + _textures?.TickCompositeTextureCache(); + _dispatcher?.BeginFrame(gpuSlot); + _environmentCells?.BeginFrame(gpuSlot); + _portalDepth?.BeginFrame(gpuSlot); + _worldText?.BeginFrame(gpuSlot); + _uiText?.BeginFrame(gpuSlot); + _clip?.BeginFrame(gpuSlot); + _terrain?.BeginFrame(gpuSlot); + _lighting?.BeginFrame(gpuSlot); + _profiler.FrameBoundary(_gl); + } +} + +internal interface IRenderFramePortalStateSource +{ + bool IsPortalViewportVisible { get; } + + uint ActiveDestinationCell { get; } +} + +internal sealed class LocalPlayerTeleportRenderStateSource + : IRenderFramePortalStateSource +{ + private readonly LocalPlayerTeleportController _teleport; + + public LocalPlayerTeleportRenderStateSource(LocalPlayerTeleportController teleport) + { + _teleport = teleport ?? throw new ArgumentNullException(nameof(teleport)); + } + + public bool IsPortalViewportVisible => _teleport.IsPortalViewportVisible; + + public uint ActiveDestinationCell => _teleport.ActiveDestinationCell; +} + +/// Atmosphere clear and frame-global GL state establishment. +internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase +{ + private readonly GL _gl; + private readonly WorldTimeService _worldTime; + private readonly WeatherSystem _weather; + private readonly IRenderFramePortalStateSource _portal; + private readonly ParticleVisibilityController _particleVisibility; + private readonly WorldRenderDiagnostics _diagnostics; + + public RuntimeRenderFrameClearPhase( + GL gl, + WorldTimeService worldTime, + WeatherSystem weather, + IRenderFramePortalStateSource portal, + ParticleVisibilityController particleVisibility, + WorldRenderDiagnostics diagnostics) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); + _weather = weather ?? throw new ArgumentNullException(nameof(weather)); + _portal = portal ?? throw new ArgumentNullException(nameof(portal)); + _particleVisibility = particleVisibility + ?? throw new ArgumentNullException(nameof(particleVisibility)); + _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + } + + public RenderFrameFoundation Clear() + { + bool portalViewportVisible = _portal.IsPortalViewportVisible; + if (portalViewportVisible) + _particleVisibility.Reset(); + + SkyKeyframe sky = _worldTime.CurrentSky; + AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky); + if (portalViewportVisible) + { + // SceneTool::BeginScene @ 0x0043DAD0 starts the replacement + // CreatureMode frame with an opaque black target. + _gl.ClearColor(0f, 0f, 0f, 1f); + } + else + { + _gl.ClearColor( + Math.Clamp(atmosphere.FogColor.X, 0f, 1f), + Math.Clamp(atmosphere.FogColor.Y, 0f, 1f), + Math.Clamp(atmosphere.FogColor.Z, 0f, 1f), + 1f); + } + + _gl.DepthMask(true); + _gl.Clear( + ClearBufferMask.ColorBufferBit + | ClearBufferMask.DepthBufferBit + | ClearBufferMask.StencilBufferBit); + _gl.CullFace(TriangleFace.Back); + _gl.FrontFace(FrontFaceDirection.CW); + _diagnostics.EmitGlStateTripwireIfChanged( + AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled); + + return new RenderFrameFoundation( + portalViewportVisible, + sky, + atmosphere); + } +} + +internal interface IRenderLoginStateSource +{ + bool IsWaitingForLogin { get; } +} + +internal sealed class RenderLoginStateSource : IRenderLoginStateSource +{ + private readonly bool _liveMode; + private readonly ILocalPlayerModeSource _mode; + + public RenderLoginStateSource(bool liveMode, ILocalPlayerModeSource mode) + { + _liveMode = liveMode; + _mode = mode ?? throw new ArgumentNullException(nameof(mode)); + } + + public bool IsWaitingForLogin => _liveMode && !_mode.ChaseModeEverEntered; +} + +internal interface ILoginRevealCellSource +{ + bool TryGet(out uint cellId); +} + +internal sealed class LiveLoginRevealCellSource : ILoginRevealCellSource +{ + private readonly LiveEntityRuntime _entities; + private readonly ILocalPlayerIdentitySource _identity; + + public LiveLoginRevealCellSource( + LiveEntityRuntime entities, + ILocalPlayerIdentitySource identity) + { + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + _identity = identity ?? throw new ArgumentNullException(nameof(identity)); + } + + public bool TryGet(out uint cellId) + { + cellId = 0; + if (!_entities.TryGetSnapshot(_identity.ServerGuid, out var playerSpawn) + || playerSpawn.Position is not { } position) + { + return false; + } + + cellId = position.LandblockId; + return cellId != 0; + } +} + +/// Render-thread mesh publication, reveal evaluation, and VFX begin. +internal sealed class RuntimeRenderFrameLivePreparation + : IRenderFrameLivePreparation +{ + private readonly TextureCache? _textures; + private readonly WbMeshAdapter? _meshes; + private readonly WorldRevealCoordinator? _worldReveal; + private readonly IRenderFramePortalStateSource _portal; + private readonly IRenderLoginStateSource _login; + private readonly ILoginRevealCellSource _loginCell; + private readonly ParticleRenderer? _particles; + private readonly FrameProfiler _profiler; + private readonly bool _diagnosticsEnabled; + private readonly RollingTimingSampleWindow _uploadTiming = new(256); + + public RuntimeRenderFrameLivePreparation( + TextureCache? textures, + WbMeshAdapter? meshes, + WorldRevealCoordinator? worldReveal, + IRenderFramePortalStateSource portal, + IRenderLoginStateSource login, + ILoginRevealCellSource loginCell, + ParticleRenderer? particles, + FrameProfiler profiler, + bool diagnosticsEnabled) + { + _textures = textures; + _meshes = meshes; + _worldReveal = worldReveal; + _portal = portal ?? throw new ArgumentNullException(nameof(portal)); + _login = login ?? throw new ArgumentNullException(nameof(login)); + _loginCell = loginCell ?? throw new ArgumentNullException(nameof(loginCell)); + _particles = particles; + _profiler = profiler ?? throw new ArgumentNullException(nameof(profiler)); + _diagnosticsEnabled = diagnosticsEnabled; + } + + public RollingTimingPercentiles UploadTiming => _uploadTiming.Snapshot(); + + public void Prepare(int gpuSlot) + { + _textures?.TickSurfaceHistogramDumpIfEnabled(); + long start = _diagnosticsEnabled + ? System.Diagnostics.Stopwatch.GetTimestamp() + : 0L; + using (var uploadStage = _profiler.BeginStage(FrameStage.Upload)) + { + _meshes?.Tick(); + uint revealCell = _portal.ActiveDestinationCell; + if (revealCell == 0 + && _login.IsWaitingForLogin + && _loginCell.TryGet(out uint loginCell)) + { + revealCell = loginCell; + } + + if (revealCell != 0) + _worldReveal?.PrepareAndEvaluate(revealCell); + _particles?.BeginFrame(gpuSlot); + } + + if (_diagnosticsEnabled) + { + _uploadTiming.PushStopwatchTicks( + System.Diagnostics.Stopwatch.GetTimestamp() - start); + } + } +} + +/// Owns the render-time weather clock and deterministic day index. +internal sealed class RenderWeatherFrameController +{ + private readonly WorldTimeService _worldTime; + private readonly WeatherSystem _weather; + private double _elapsedSeconds; + + public RenderWeatherFrameController( + WorldTimeService worldTime, + WeatherSystem weather) + { + _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); + _weather = weather ?? throw new ArgumentNullException(nameof(weather)); + } + + internal double ElapsedSeconds => _elapsedSeconds; + + public void Tick(double deltaSeconds) + { + DerethDateTime.Calendar calendar = _worldTime.CurrentCalendar; + int dayIndex = calendar.Year + * (DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear) + + (int)calendar.Month * DerethDateTime.DaysInAMonth + + (calendar.Day - 1); + _weather.Tick( + nowSeconds: _elapsedSeconds, + dayIndex, + dtSeconds: (float)deltaSeconds); + _elapsedSeconds += deltaSeconds; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/DisplayFramePacingControllerTests.cs b/tests/AcDream.App.Tests/Rendering/DisplayFramePacingControllerTests.cs new file mode 100644 index 00000000..bfd804ec --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/DisplayFramePacingControllerTests.cs @@ -0,0 +1,208 @@ +using AcDream.App.Diagnostics; +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class DisplayFramePacingControllerTests +{ + [Fact] + public void Binding_surface_preserves_startup_fallback_until_monitor_refresh() + { + using var profiler = new FrameProfiler(); + using var controller = new DisplayFramePacingController( + uncappedRendering: false, + profiler, + new FramePacingController( + new FakeClock(1_000), + new RecordingWaiter())); + var surface = new FakeSurface + { + VSync = true, + ActiveMonitorRefreshHz = 144, + }; + + FramePacingPolicy startup = controller.InitializeStartup( + requestedVSync: false); + controller.BindSurface(surface); + + Assert.Equal(FramePacingPolicy.FallbackRefreshHz, startup.SoftwareLimitHz); + Assert.Equal(startup, controller.Policy); + + controller.RefreshActiveMonitor(); + + Assert.Equal(new FramePacingPolicy(false, 144d), controller.Policy); + Assert.False(surface.VSync); + } + + [Fact] + public void Preference_and_monitor_changes_resolve_one_owned_policy() + { + using var profiler = new FrameProfiler(); + using var controller = new DisplayFramePacingController( + uncappedRendering: false, + profiler, + new FramePacingController( + new FakeClock(1_000), + new RecordingWaiter())); + var surface = new FakeSurface + { + VSync = false, + ActiveMonitorRefreshHz = 120, + }; + controller.InitializeStartup(requestedVSync: false); + controller.BindSurface(surface); + controller.RefreshActiveMonitor(); + + controller.ApplyPreference(requestedVSync: true); + Assert.Equal(new FramePacingPolicy(true, null), controller.Policy); + Assert.True(surface.VSync); + + surface.ActiveMonitorRefreshHz = 165; + controller.ApplyPreference(requestedVSync: false); + Assert.Equal(new FramePacingPolicy(false, 120d), controller.Policy); + Assert.False(surface.VSync); + + controller.OnWindowStateChanged(default); + Assert.Equal(new FramePacingPolicy(false, 165d), controller.Policy); + + surface.ActiveMonitorRefreshHz = 75; + controller.OnWindowMoved(default); + Assert.Equal(new FramePacingPolicy(false, 75d), controller.Policy); + } + + [Fact] + public void Repeated_preference_preview_uses_the_event_refreshed_monitor_cache() + { + using var profiler = new FrameProfiler(); + using var controller = new DisplayFramePacingController( + uncappedRendering: false, + profiler, + new FramePacingController( + new FakeClock(1_000), + new RecordingWaiter())); + var surface = new FakeSurface + { + VSync = false, + ActiveMonitorRefreshHz = 144, + }; + controller.InitializeStartup(requestedVSync: false); + controller.BindSurface(surface); + controller.RefreshActiveMonitor(); + Assert.Equal(1, surface.RefreshReadCount); + + controller.ApplyPreference(requestedVSync: false); + controller.ApplyPreference(requestedVSync: false); + controller.ApplyPreference(requestedVSync: false); + + Assert.Equal(1, surface.RefreshReadCount); + Assert.Equal(new FramePacingPolicy(false, 144d), controller.Policy); + } + + [Fact] + public void Explicit_uncapped_mode_disables_both_pacing_mechanisms() + { + using var profiler = new FrameProfiler(); + using var controller = new DisplayFramePacingController( + uncappedRendering: true, + profiler, + new FramePacingController( + new FakeClock(1_000), + new RecordingWaiter())); + var surface = new FakeSurface + { + VSync = true, + ActiveMonitorRefreshHz = 240, + }; + controller.InitializeStartup(requestedVSync: true); + controller.BindSurface(surface); + + controller.RefreshActiveMonitor(); + + Assert.Equal(new FramePacingPolicy(false, null), controller.Policy); + Assert.False(surface.VSync); + } + + [Fact] + public void Render_completion_delegates_to_the_owned_software_pacer() + { + var clock = new FakeClock(1_000); + var waiter = new RecordingWaiter(clock); + using var profiler = new FrameProfiler(); + using var controller = new DisplayFramePacingController( + uncappedRendering: false, + profiler, + new FramePacingController(clock, waiter)); + controller.InitializeStartup(requestedVSync: false); + + clock.Timestamp = 10; + controller.OnFrameRendered(0d); + + Assert.Equal([7L], waiter.Durations); + } + + [Fact] + public void Dispose_releases_borrowed_surface_and_profiler_without_disposing_surface() + { + using var profiler = new FrameProfiler(); + var controller = new DisplayFramePacingController( + uncappedRendering: false, + profiler, + new FramePacingController( + new FakeClock(1_000), + new RecordingWaiter())); + var surface = new FakeSurface(); + controller.BindSurface(surface); + Assert.True(controller.IsSurfaceBound); + + controller.Dispose(); + controller.Dispose(); + + Assert.False(controller.IsSurfaceBound); + Assert.Throws(() => controller.OnFrameRendered(0d)); + Assert.False(surface.Disposed); + } + + private sealed class FakeSurface : IDisplayFramePacingSurface, IDisposable + { + public bool Disposed { get; private set; } + + public bool VSync { get; set; } + + private int? _activeMonitorRefreshHz; + + public int RefreshReadCount { get; private set; } + + public int? ActiveMonitorRefreshHz + { + get + { + RefreshReadCount++; + return _activeMonitorRefreshHz; + } + set => _activeMonitorRefreshHz = value; + } + + public void Dispose() => Disposed = true; + } + + private sealed class FakeClock(long frequency) : IFramePacingClock + { + public long Frequency { get; } = frequency; + + public long Timestamp { get; set; } + + public long GetTimestamp() => Timestamp; + } + + private sealed class RecordingWaiter(FakeClock? clock = null) : IFramePacingWaiter + { + public List Durations { get; } = []; + + public void Wait(long durationTicks, long clockFrequency) + { + Durations.Add(durationTicks); + if (clock is not null) + clock.Timestamp += durationTicks; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index fc01780b..b56d9b31 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -20,16 +20,33 @@ public sealed class GameWindowRenderLeafCompositionTests } [Fact] - public void ProductionRender_BeginsDevToolsBeforeWorldAndBeginsDispatcherOnce() + public void ProductionRender_Prepares_resources_then_devtools_weather_and_world() { string source = GameWindowSource(); AssertAppearsInOrder( source, + "_renderFrameResources!.Prepare(frameInput);", "_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);", - "Weather.Tick(nowSeconds: _weatherAccum", + "_renderWeatherFrame!.Tick(deltaSeconds);", "_retailSelectionScene?.BeginFrame();"); - Assert.Equal(1, CountOccurrences(source, "_wbDrawDispatcher?.BeginFrame(")); + Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source); + Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source); + Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source); + } + + [Fact] + public void Composition_transfers_portal_tunnel_before_constructing_frame_borrowers() + { + string source = GameWindowSource(); + + AssertAppearsInOrder( + source, + "new AcDream.App.Streaming.LocalPlayerTeleportController(", + "_portalTunnel = null;", + "_localPlayerTeleportSink.Bind(_localPlayerTeleport);", + "new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(", + "new AcDream.App.Rendering.RenderFrameResourceController("); } [Fact] @@ -55,12 +72,21 @@ public sealed class GameWindowRenderLeafCompositionTests "EmitClipRouteScissorProbe(scissor", "_lastVisibleLandblocks", "_perfAccum", + "_entityUploadTiming", + "_weatherAccum", + "TryGetLoginWorldCell", + "ApplyFramePacingPreference", + "RefreshActiveMonitorFramePacing", + "private void OnFrameRendered(", ]; foreach (string identifier in removed) Assert.DoesNotContain(identifier, source, StringComparison.Ordinal); Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source); Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", source); + Assert.Contains("new AcDream.App.Rendering.RenderFrameResourceController(", source); + Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", source); + Assert.Contains("new DisplayFramePacingController(", source); Assert.Contains("_inputCapture.WantCaptureMouse", source); } @@ -73,9 +99,17 @@ public sealed class GameWindowRenderLeafCompositionTests source, "new ResourceShutdownStage(\"submitted GPU work\"", "new ResourceShutdownStage(\"render frontends\"", + "new(\"frame composition\"", "new(\"portal tunnel\"", "new(\"paperdoll viewport\"", "new ResourceShutdownStage(\"OpenGL context\""); + Assert.Contains("_renderFrameResources = null;", source); + Assert.Contains("_renderFrameLivePreparation = null;", source); + Assert.Contains("_renderWeatherFrame = null;", source); + AssertAppearsInOrder( + source, + "new(\"frame pacing\", _displayFramePacing.Dispose)", + "new(\"frame profiler\", _frameProfiler.Dispose)"); Assert.DoesNotContain("_devToolsBackend?.Dispose()", source); } @@ -139,19 +173,6 @@ public sealed class GameWindowRenderLeafCompositionTests } } - private static int CountOccurrences(string source, string needle) - { - int count = 0; - int cursor = 0; - while ((cursor = source.IndexOf(needle, cursor, StringComparison.Ordinal)) >= 0) - { - count++; - cursor += needle.Length; - } - - return count; - } - private static string FindRepoRoot() { DirectoryInfo? directory = new(AppContext.BaseDirectory); diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs new file mode 100644 index 00000000..f08673d0 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs @@ -0,0 +1,194 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RenderFrameResourceControllerTests +{ + [Fact] + public void Prepare_runs_begin_clear_and_live_in_order_for_one_gpu_slot() + { + List calls = []; + var clearResult = new RenderFrameFoundation( + PortalViewportVisible: true, + Sky: default, + Atmosphere: default); + var slots = new AdvancingSlotSource(2); + var controller = new RenderFrameResourceController( + slots, + new RecordingBegin(calls), + new RecordingClear(calls, clearResult), + new RecordingLive(calls)); + + controller.Prepare(new RenderFrameInput(1d / 60d, 1280, 720)); + + Assert.Equal(["begin:2", "clear", "live:2"], calls); + Assert.Equal(1, slots.ReadCount); + Assert.Equal(clearResult, controller.Foundation); + } + + [Fact] + public void Required_phase_dependencies_fail_fast() + { + var slots = new AdvancingSlotSource(0); + var begin = new RecordingBegin([]); + var clear = new RecordingClear([], default); + var live = new RecordingLive([]); + + Assert.Throws(() => + new RenderFrameResourceController(null!, begin, clear, live)); + Assert.Throws(() => + new RenderFrameResourceController(slots, null!, clear, live)); + Assert.Throws(() => + new RenderFrameResourceController(slots, begin, null!, live)); + Assert.Throws(() => + new RenderFrameResourceController(slots, begin, clear, null!)); + } + + [Fact] + public void Production_begin_resources_preserve_the_exact_frame_order() + { + string source = ResourceSource(); + + AssertAppearsInOrder( + source, + "_textures?.BeginCompositeTextureFrame();", + "_textures?.TickCompositeTextureCache();", + "_dispatcher?.BeginFrame(gpuSlot);", + "_environmentCells?.BeginFrame(gpuSlot);", + "_portalDepth?.BeginFrame(gpuSlot);", + "_worldText?.BeginFrame(gpuSlot);", + "_uiText?.BeginFrame(gpuSlot);", + "_clip?.BeginFrame(gpuSlot);", + "_terrain?.BeginFrame(gpuSlot);", + "_lighting?.BeginFrame(gpuSlot);", + "_profiler.FrameBoundary(_gl);"); + } + + [Fact] + public void Production_live_preparation_publishes_meshes_before_reveal_and_particles() + { + string source = ResourceSource(); + + AssertAppearsInOrder( + source, + "_meshes?.Tick();", + "_worldReveal?.PrepareAndEvaluate(revealCell);", + "_particles?.BeginFrame(gpuSlot);"); + } + + [Fact] + public void Production_clear_phase_preserves_atmosphere_and_gl_state_order() + { + string source = ResourceSource(); + + AssertAppearsInOrder( + source, + "bool portalViewportVisible = _portal.IsPortalViewportVisible;", + "_particleVisibility.Reset();", + "SkyKeyframe sky = _worldTime.CurrentSky;", + "AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky);", + "_gl.DepthMask(true);", + "_gl.Clear(", + "_gl.CullFace(TriangleFace.Back);", + "_gl.FrontFace(FrontFaceDirection.CW);", + "_diagnostics.EmitGlStateTripwireIfChanged("); + } + + [Fact] + public void Weather_frame_clock_advances_only_after_the_weather_tick() + { + var controller = new RenderWeatherFrameController( + new AcDream.Core.World.WorldTimeService( + AcDream.Core.World.SkyStateProvider.Default()), + new AcDream.Core.World.WeatherSystem()); + + controller.Tick(0.25d); + controller.Tick(0.5d); + + Assert.Equal(0.75d, controller.ElapsedSeconds); + AssertAppearsInOrder( + ResourceSource(), + "_weather.Tick(", + "nowSeconds: _elapsedSeconds,", + "_elapsedSeconds += deltaSeconds;"); + } + + [Fact] + public void Login_state_is_latched_by_the_shared_player_mode_source() + { + var mode = new AcDream.App.Input.LocalPlayerModeState(); + var live = new AcDream.App.Rendering.RenderLoginStateSource( + liveMode: true, + mode); + var offline = new AcDream.App.Rendering.RenderLoginStateSource( + liveMode: false, + mode); + + Assert.True(live.IsWaitingForLogin); + Assert.False(offline.IsWaitingForLogin); + + mode.ChaseModeEverEntered = true; + + Assert.False(live.IsWaitingForLogin); + } + + private static string ResourceSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "RenderFrameResourceController.cs")); + + private static void AssertAppearsInOrder(string source, params string[] needles) + { + int cursor = -1; + foreach (string needle in needles) + { + int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal); + Assert.True(next >= 0, $"Missing expected source fragment: {needle}"); + Assert.True(next > cursor, $"Out-of-order source fragment: {needle}"); + cursor = next; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } + + private sealed class AdvancingSlotSource(int firstSlot) : IRenderFrameSlotSource + { + public int ReadCount { get; private set; } + + public int CurrentSlot => firstSlot + ReadCount++; + } + + private sealed class RecordingBegin(List calls) : IRenderFrameBeginResources + { + public void Begin(int gpuSlot) => calls.Add($"begin:{gpuSlot}"); + } + + private sealed class RecordingClear( + List calls, + RenderFrameFoundation result) : IRenderFrameClearPhase + { + public RenderFrameFoundation Clear() + { + calls.Add("clear"); + return result; + } + } + + private sealed class RecordingLive(List calls) : IRenderFrameLivePreparation + { + public void Prepare(int gpuSlot) => calls.Add($"live:{gpuSlot}"); + } +} From 6d6e5b5fa53b79ea99736f5e56cb7d49cd64a026 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 05:54:00 +0200 Subject: [PATCH 85/98] refactor(render): extract world frame preparation Move camera/root resolution, live settings and listener preview, sky/lighting/fog preparation, animated classification, and building visibility scratch behind a typed WorldRenderFrameBuilder while preserving retail frame order and borrowed lifetimes. Co-authored-by: Codex --- .../retail-divergence-register.md | 8 +- ...ndow-slice-7-render-frame-orchestration.md | 17 +- src/AcDream.App/Rendering/GameWindow.cs | 684 +++--------------- .../Rendering/SkyPesFrameController.cs | 135 ++++ .../Rendering/WorldRenderFrameBuilder.cs | 645 +++++++++++++++++ src/AcDream.App/RuntimeOptions.cs | 13 +- .../Rendering/WorldRenderFrameBuilderTests.cs | 522 +++++++++++++ .../AcDream.App.Tests/RuntimeOptionsTests.cs | 22 + 8 files changed, 1463 insertions(+), 583 deletions(-) create mode 100644 src/AcDream.App/Rendering/SkyPesFrameController.cs create mode 100644 src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs create mode 100644 tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index e9b658d1..df6dddc3 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -77,12 +77,12 @@ accepted-divergence entries (#96, #49, #50). | 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 | | AD-13 | 1-second dedup window for identical system chat messages (retail has none) | `src/AcDream.Core/Chat/ChatLog.cs:29` | ACE dual-sends the same system text (0xF7E0 + 0x02EB) for back-compat; without dedup every line doubled (Phase J compromise) | Two genuinely distinct but textually identical system messages within 1 s collapse to one line where retail shows both | ACE dual-send 0xF7E0 + 0x02EB | | AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 | -| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/GameWindow.cs:7634` | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 | +| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameBuildingSource.Gather`) | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 | | AD-17 | ≤8 GPU `gl_ClipDistance` half-planes per view region, degrading to a union-AABB scissor (over-include) on multi-polygon / >8-edge views; particles always scissor; scissor slices disable per-object viewcone culling. Retail CPU-clips against the exact portal polygon | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23` | GL guarantees only 8 simultaneous clip planes; invariant documented: over-inclusion is safe, under-inclusion is the bug class | Fallback on complex multi-aperture views draws terrain/sky/particles/objects outside the true aperture but inside its AABB — background/interior bleed strips at doorways (the **#130** family) | `ACRender::polyClipFinish` decomp:702749; PView portal_view slices | | AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) | | AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw` → `DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 | | AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 | -| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 | +| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameRootSource`, `WorldRenderFrame.ClipRoot`); `src/AcDream.App/Rendering/GameWindow.cs` (null-root safety draw) | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 | | AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams | | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | @@ -145,7 +145,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewRenderer.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 | -| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) | +| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.UpdateSunFromSky`) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) | | AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | | AP-37 | LayoutDesc meters collapse Type-3 slice descendants into `UiMeter.BackLeft..FrontRight` and reuse `UiMeter.DrawHBar` rather than building those media descendants and dispatching retail `UIElement_Meter::DrawChildren`. Non-Type-3 meter children are imported normally. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`); `LayoutImporter.cs` meter child predicate | The current vitals/character meter shapes are visually accepted and fixture-pinned; this is a representation adaptation, not a controller overlay. | A meter with a different descendant/media structure can render empty or with incorrect clipping/direction | `UIElement_Meter::DrawChildren @ 0x0046FBD0`; production meter LayoutDesc fixtures | | AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | @@ -183,7 +183,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) | | AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (VectorUpdate jump handler); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (landing blocks) | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. | | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | -| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) | +| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` back to `WorldRenderFrameBuilder` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.ObserveDrawableCells`, `ClearDrawableCells`, `Prepare`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) | | AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | | AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | | AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` | diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md index 1cf8ede2..c99749cf 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md +++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md @@ -18,10 +18,10 @@ port, or resource-lifetime redesign. introduce data-only frame contracts plus deterministic order/failure tests. - [x] B — extract paperdoll, panel-layout/devtools, and render-diagnostics leaf owners while `GameWindow` still orders the frame. -- [ ] C — extract frame-resource begin/upload/publication and live display, +- [x] C — extract frame-resource begin/upload/publication and live display, and weather/display foundations that do not depend on camera/root classification. -- [ ] D — extract frame-data classification and reusable world scratch without +- [x] D — extract frame-data classification and reusable world scratch without duplicating live identity, spatial, visibility, or resource ownership; then run the dependent audio, sky, lighting, and listener preparation. - [ ] E — replace `RetailPViewDrawContext`'s callback bag with a typed @@ -404,6 +404,19 @@ corrected-diff reviews are clean. fallback/null-root, sealed/SeenOutside, and borrowed scratch reset; - preserve allocation-free warmed frames and hysteretic scratch retention. +Completed 2026-07-22. `WorldRenderFrameBuilder` now owns the ordered camera, +visibility, live settings/listener, player-lighting versus collided-viewer root, +sky/lighting/fog, animated-ID, and building-candidate preparation. Reusable +animated/building/light-cell collections retain their explicit one-frame +borrowed lifetime; PView drawable-cell feedback returns through the builder and +is copied before the renderer reuses its own scratch. The diagnostic sky-PES +experiment moved to `SkyPesFrameController`, and fog startup overrides now enter +through typed `RuntimeOptions`. The frame graph is withdrawn before its borrowed +equipped/effect/audio owners during shutdown. `GameWindow.cs` is 5,715 raw +lines, 63.6% below the 15,723-line campaign baseline. Twenty-nine focused tests, +the Release build, and the complete suite pass (7,281 passed / 5 skipped). +Retail, architecture, and adversarial corrected-diff reviews are clean. + ### E — typed PView pass executor - replace `Action`/`Func` draw callbacks in `RetailPViewDrawContext`; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 884af989..e3bc6f5c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -16,8 +16,6 @@ public sealed class GameWindow : IDisposable System.Diagnostics.Stopwatch.GetTimestamp() / (double)System.Diagnostics.Stopwatch.Frequency; - private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene); - private readonly AcDream.App.RuntimeOptions _options; private readonly AnimationPresentationDiagnostics _animationDiagnostics; private readonly string _datDir; @@ -108,6 +106,9 @@ public sealed class GameWindow : IDisposable _renderFrameLivePreparation; private AcDream.App.Rendering.RenderWeatherFrameController? _renderWeatherFrame; + private AcDream.App.Rendering.WorldRenderFrameBuilder? + _worldRenderFrameBuilder; + private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame; private ResourceShutdownTransaction? _shutdown; private readonly DisplayFramePacingController _displayFramePacing; @@ -126,8 +127,18 @@ public sealed class GameWindow : IDisposable private AcDream.App.Streaming.LocalPlayerTeleportController? _localPlayerTeleport; private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) - private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9) - private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25) + private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange = + new(nearRadius: 4, farRadius: 12); + private int _nearRadius + { + get => _renderRange.NearRadius; + set => _renderRange.NearRadius = value; + } + private int _farRadius + { + get => _renderRange.FarRadius; + set => _renderRange.FarRadius = value; + } // A.5 T22.5: resolved quality settings (preset + env-var overrides). // Set once in OnLoad after LoadAndApplyPersistedSettings(); re-set on // ReapplyQualityPreset(). Default matches QualityPreset.High so the field @@ -226,12 +237,8 @@ public sealed class GameWindow : IDisposable // U.4 replaces the NoClip() frame with one built from the portal-visibility result. private ClipFrame? _clipFrame; - // Phase 3 (render unification, 2026-06-07): the synthetic outdoor cell node — the outdoor - // world as a flood-graph cell (spec 2026-06-07-render-unification-outdoor-as-cell). Rebuilt - // each outdoor frame from nearby building-entrance portals. ADDITIVE for now (built but not - // yet rooted; the clipRoot flip + OutsideView terrain integration is the cutover step). - private LoadedCell? _outdoorNode; - private readonly List _outdoorNodeBuildingCells = new(); + // PView pass-local particle classifications move with the pass executor in + // Checkpoint E; GameWindow still owns them until that cutover. private readonly HashSet _outdoorSceneParticleEntityIds = new(); private readonly HashSet _visibleSceneParticleEntityIds = new(); private readonly HashSet _noSceneParticleEntityIds = new(); @@ -248,13 +255,6 @@ public sealed class GameWindow : IDisposable private readonly LiveEntityAnimationRuntimeView _animatedEntities; private readonly AcDream.App.World.LiveEntityRuntimeSlot _liveEntityRuntimeSlot = new(); - // MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys. - // WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically - // (`animatedEntityIds is null || animatedEntityIds.Count == 0`), so this can - // always be a live (possibly empty) HashSet instead of `new`ing one every - // frame or passing null. - private readonly HashSet _animatedIdsScratch = new(); - /// /// Tier 1 cache (#53): per-entity classification results for static /// entities (those NOT in ). Conceptually @@ -298,35 +298,6 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects; private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new(); - // Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but - // never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime. - // Keep the experimental path available for DAT archaeology only. - // Backed by ACDREAM_ENABLE_SKY_PES via RuntimeOptions.EnableSkyPesDebug. - - // Diagnostic: hide a specific humanoid part (>=10 parts) at render. - // Backed by ACDREAM_HIDE_PART via RuntimeOptions.HidePartIndex. - - // Issue #47 — use retail's close-detail GfxObj selection on - // humanoid setups. When enabled, every per-part GfxObj id (after - // server AnimPartChanges are applied) is replaced with Degrades[0] - // from its DIDDegrade table when present. See GfxObjDegradeResolver - // for the full retail-decomp citation. Default-on after visual - // confirmation; set ACDREAM_RETAIL_CLOSE_DEGRADES=0 only for - // diagnostic before/after comparisons. - // Backed by ACDREAM_RETAIL_CLOSE_DEGRADES via RuntimeOptions.RetailCloseDegrades. - - // Issue #48 diagnostic — dump per-scenery-spawn placement evidence - // (rendered gfx id, sample source physics-vs-bilinear, ground/baseLoc/finalZ, - // mesh vertex Z range, DIDDegrade slot 0). One log line per spawn lets - // the user identify a floating tree by its world coordinates and tell - // whether the cause is BaseLoc.Z addition (H1), bilinear-fallback drift - // (H2), or DIDDegrade selection (H3). Diagnostic-first per CLAUDE.md. - // Backed by ACDREAM_DUMP_SCENERY_Z via RuntimeOptions.DumpSceneryZ. - - /// - private readonly HashSet _activeSkyPes = new(); - private readonly HashSet _missingSkyPes = new(); - // Remote-entity motion inference: tracks when each remote entity last // moved meaningfully. Used in TickAnimations to swap to Ready when // position has stalled for >StopIdleMs — retail observer pattern per @@ -467,21 +438,6 @@ public sealed class GameWindow : IDisposable AcDream.Core.World.SkyStateProvider.Default()); public readonly AcDream.Core.Lighting.LightManager Lighting = new(); - // A7.L1 (2026-07-09) — last frame's rendered visible-cell set, fed into next - // frame's Lighting.BuildPointLightSnapshot scoping. One frame of latency - // (~16 ms) instead of a mid-DrawInside callback: DrawableCells only becomes - // known partway through DrawInside (after the flood + look-in merge), and the - // #176 correction already showed that rebuilding the light pool FROM a - // freshly-recomputed frame flood (the deleted RebuildScopedLights callback, - // c500912b) is a flicker mechanism. Reusing last frame's already-drawn set - // (MP-Alloc: reused HashSet, no per-frame alloc) decouples the light-scoping - // change from DrawInside's internals entirely. _lightPoolVisibleCellsValid is - // false until the first indoor frame completes, and is cleared on any - // outdoor-only frame so scoping fails open (unscoped) for one frame on - // re-entry rather than filtering by a stale dungeon's cell ids. - private readonly HashSet _lightPoolVisibleCells = new(); - private bool _lightPoolVisibleCellsValid; - public readonly AcDream.Core.World.WeatherSystem Weather = new(); // Wired into the hook router in OnLoad so SetLightHook fires // from the animation pipeline flip the matching LightSource.IsLit. @@ -2890,6 +2846,52 @@ public sealed class GameWindow : IDisposable new AcDream.App.Rendering.RenderWeatherFrameController( WorldTime, Weather); + _skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController( + _scriptRunner!, + _particleSink!, + _effectPoses, + _entityEffects); + var worldFrameEnvironment = + new AcDream.App.Rendering.RuntimeWorldFrameEnvironmentPreparation( + _options, + WorldTime, + Lighting, + _wbDrawDispatcher, + _envCellRenderer, + _sceneLightingUbo, + _renderRange, + _skyPesFrame); + _worldRenderFrameBuilder = + new AcDream.App.Rendering.WorldRenderFrameBuilder( + new AcDream.App.Rendering.RuntimeWorldFrameCameraSource( + _cameraController!, + _localPlayerTeleport), + new AcDream.App.Rendering.RuntimeWorldFrameVisibilityPreparation( + _retailSelectionScene, + _particleVisibility, + _terrain, + _worldReveal, + _envCellFrustum), + new AcDream.App.Rendering.RuntimeWorldFrameSettingsPreview( + _settingsVm, + _audioEngine, + _cameraController!, + _displayFramePacing), + new AcDream.App.Rendering.RuntimeWorldFrameRootSource( + _physicsEngine, + _cellVisibility, + _localPlayerMode, + _chaseCameraInput, + _playerControllerSlot, + _liveWorldOrigin), + worldFrameEnvironment, + new AcDream.App.Rendering.RuntimeWorldFrameAnimatedEntitySource( + _animatedEntities, + _staticAnimationScheduler, + _equippedChildRenderer), + new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource( + _landblockPresentationPipeline!, + _cellVisibility)); var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( liveObjectFrame, _worldState, @@ -3268,115 +3270,8 @@ public sealed class GameWindow : IDisposable session.SendTurbineChatTo( roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - private void UpdateSkyPes( - float dayFraction, - AcDream.Core.World.DayGroupData? dayGroup, - System.Numerics.Vector3 cameraWorldPos, - bool suppressSky) - { - if (_scriptRunner is null || _particleSink is null) - return; - - var seen = new HashSet(); - if (!suppressSky && dayGroup is not null) - { - for (int i = 0; i < dayGroup.SkyObjects.Count; i++) - { - var obj = dayGroup.SkyObjects[i]; - if (obj.PesObjectId == 0 || !obj.IsVisible(dayFraction)) - continue; - - var key = new SkyPesKey(i, obj.PesObjectId, obj.IsPostScene); - seen.Add(key); - uint skyEntityId = SkyPesEntityId(key); - var renderPass = obj.IsPostScene - ? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene - : AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene; - _particleSink.SetEntityRenderPass(skyEntityId, renderPass); - var anchor = SkyPesAnchor(obj, cameraWorldPos); - var rotation = SkyPesRotation(obj, dayFraction); - _effectPoses.Publish( - skyEntityId, - System.Numerics.Matrix4x4.CreateFromQuaternion(rotation) - * System.Numerics.Matrix4x4.CreateTranslation(anchor), - System.Array.Empty(), - cellId: 0u); - - if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key)) - continue; - - _entityEffects?.RegisterSyntheticOwner(skyEntityId); - - if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor)) - { - _activeSkyPes.Add(key); - } - else - { - _missingSkyPes.Add(key); - _entityEffects?.UnregisterSyntheticOwner(skyEntityId); - _particleSink.ClearEntityRenderPass(skyEntityId); - _effectPoses.Remove(skyEntityId); - } - } - } - - foreach (var key in _activeSkyPes.ToArray()) - { - if (seen.Contains(key)) - continue; - - uint skyEntityId = SkyPesEntityId(key); - _scriptRunner.StopAllForEntity(skyEntityId); - _entityEffects?.UnregisterSyntheticOwner(skyEntityId); - _particleSink.StopAllForEntity(skyEntityId, fadeOut: true); - _effectPoses.Remove(skyEntityId); - _activeSkyPes.Remove(key); - } - - foreach (var key in _missingSkyPes.ToArray()) - { - if (!seen.Contains(key)) - _missingSkyPes.Remove(key); - } - } - - private static uint SkyPesEntityId(SkyPesKey key) - { - // 0xF0000000 prefix marks synthetic sky-PES entityIds (no real - // server GUID lives in the 0xFxxxxxxx space). Reserve bit - // 0x08000000 for the pre/post-scene flag and the lower 27 bits - // for the object index — keeps the post-scene flag from sliding - // into the index range if a future DayGroup ever ships >65k sky - // objects (current Dereth max is 18, but the constraint is free). - uint postBit = key.PostScene ? 0x08000000u : 0u; - return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu); - } - internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) => entity.Id; - - - private static System.Numerics.Vector3 SkyPesAnchor( - AcDream.Core.World.SkyObjectData obj, - System.Numerics.Vector3 cameraWorldPos) - { - if (obj.IsWeather && (obj.Properties & 0x08u) == 0u) - return cameraWorldPos + new System.Numerics.Vector3(0f, 0f, -120f); - - return cameraWorldPos; - } - - private static System.Numerics.Quaternion SkyPesRotation( - AcDream.Core.World.SkyObjectData obj, - float dayFraction) - { - float rotationRad = obj.CurrentAngle(dayFraction) * (MathF.PI / 180f); - return System.Numerics.Quaternion.CreateFromAxisAngle( - System.Numerics.Vector3.UnitY, - -rotationRad); - } - /// /// Phase 5d — retail AdminEnvirons (0xEA60) dispatcher. /// Routes fog presets into the weather system's sticky override @@ -3522,280 +3417,34 @@ public sealed class GameWindow : IDisposable if (_cameraController is not null && !portalViewportVisible) { _retailAlphaQueue.BeginFrame(); - var activeCamera = _cameraController.Active; - var camera = _localPlayerTeleport?.ApplyViewPlane(activeCamera) - ?? activeCamera; - var worldProjection = camera.Projection; - var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection); - _retailSelectionScene?.SetViewFrustum(frustum); - - // Extract camera world position from the inverse of the view - // matrix — needed by the scene-lighting UBO (for fog distance) - // and by the sky renderer (for the camera-centered sky dome). - System.Numerics.Matrix4x4.Invert(camera.View, out var invView); - var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43); - _particleVisibility.BeginFrame(camPos); - _terrain?.BeginVisibilityFrame(); - if (!IsLiveModeWaitingForLogin) - { - _particleVisibility.UseWorldView(); - _worldReveal?.ObserveWorldViewportVisible(); - _worldReveal?.Complete(); - } - - // L.0 Audio tab: push the SettingsVM's live AudioDraft into the - // engine each frame, so volume sliders preview audibly while - // the user drags. Cancel reverts the draft and the engine - // catches up on the very next frame; Save persists to - // settings.json without changing engine state (already - // applied). Cheap enough to run unconditionally on every - // tick — four float assignments. - if (_audioEngine is not null && _audioEngine.IsAvailable && _settingsVm is not null) - { - var a = _settingsVm.AudioDraft; - _audioEngine.MasterVolume = a.Master; - _audioEngine.MusicVolume = a.Music; - _audioEngine.SfxVolume = a.Sfx; - _audioEngine.AmbientVolume = a.Ambient; - } - - // L.0 Display tab: push the live DisplayDraft into the - // active rendering surfaces each frame. FOV is the live- - // preview slider per the brainstorm — dragging it changes - // camera FovY immediately. VSync change-detected to avoid - // spamming the window. Resolution + Fullscreen apply on - // Save (handled by ApplyDisplayWindowState — too jarring - // to live-preview a resize). - if (_settingsVm is not null && _cameraController is not null) - { - var d = _settingsVm.DisplayDraft; - float fovYRad = d.FieldOfView * (MathF.PI / 180f); - _cameraController.Orbit.FovY = fovYRad; - _cameraController.Fly.FovY = fovYRad; - if (_cameraController.Chase is not null) - _cameraController.Chase.FovY = fovYRad; - _displayFramePacing.ApplyPreference(d.VSync); - } - - // Phase E.2 audio: update listener pose so 3D sounds pan/attenuate - // correctly relative to where we're looking. - if (_audioEngine is not null && _audioEngine.IsAvailable) - { - var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33); - var up = new System.Numerics.Vector3( invView.M21, invView.M22, invView.M23); - _audioEngine.SetListener( - camPos.X, camPos.Y, camPos.Z, - fwd.X, fwd.Y, fwd.Z, - up.X, up.Y, up.Z); - } - - // Step 4: portal visibility — compute BEFORE the UBO upload so - // the indoor flag drives the sun's intensity to zero for - // dungeons (r13 §13.7). - // Phase W single-viewpoint V1 (2026-06-03): the render keys on ONE viewpoint — the - // collided camera ("viewer") — exactly like retail (RenderNormalMode @ 0x453aa0 → - // DrawInside(viewer_cell) pc:92675; InitCell side-test vs viewer.viewpoint pc:432991). - // The viewer cell is the camera-collision sweep's swept cell - // (RetailChaseCamera.ViewerCellId = retail viewer_cell = sphere_path.curr_cell): - // graph-tracked, deterministic, NO AABB / NO grace frames — so the U.4c flap source - // (stale FindCameraCell over grace frames) is gone WITHOUT splitting viewpoints. - // SEPARATELY, lighting / seen_outside key on the PLAYER cell (CurrCell), matching retail - // CellManager::ChangePosition @ 0x4559B0 — the player's cell, not the camera's, decides - // whether the sun dies (sealed interior). retail player->cell (physics/lighting) vs - // SmartBox->viewer_cell (render); the old per-render player-root + eye-projection split is gone. - - // ── Lighting root: the PLAYER cell (CurrCell). ── - LoadedCell? playerRoot = null; - if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell playerCellObj - && _cellVisibility.TryGetCell(playerCellObj.Id, out var playerRegCell)) - playerRoot = playerRegCell; - bool playerSeenOutside = playerRoot?.SeenOutside ?? true; - - // ── Render root: the VIEWER (collided camera) cell + eye. ── - // Default (player mode + retail chase cam): the sweep's viewer cell. Fallback for the - // non-default legacy/debug camera paths: the player's registered cell (or none). - uint viewerCellId = - (_playerMode && _retailChaseCamera is not null - && AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera) - ? _retailChaseCamera.ViewerCellId - : (playerRoot?.CellId ?? 0u); - var viewerEyePos = camPos; // the collided eye drives the projection - var playerViewPos = _playerController?.RenderPosition - ?? _playerController?.Position - ?? camPos; - LoadedCell? viewerRoot = null; - if (viewerCellId != 0u && _cellVisibility.TryGetCell(viewerCellId, out var viewerRegCell)) - viewerRoot = viewerRegCell; - // T4 (BR-6): the per-frame ACME BFS (ComputeVisibilityFromRoot) is - // DELETED from the frame — it ran a full second visibility - // computation whose only production consumer was this boolean, - // which is exactly "the viewer root resolved to a loaded interior - // cell" (TryGetCell above already proves cells are loaded). The - // PView flood is the ONE visibility gate (feedback_render_one_gate). - bool cameraInsideCell = viewerRoot is not null; - - // Retail render routing is owned by the collided camera/viewer cell. - // The player cell still owns lighting state, but it must not force an - // indoor draw while the camera is outside; that drops the outdoor pass - // and leaves clear color around a floating doorway slice. - bool rootSeenOutside = viewerRoot?.SeenOutside ?? true; - - // Phase U.4 (2026-05-30): the [vis] probe moved DOWN to the unified - // gated-draw block (after envCellViewProj exists) where it can report - // the real PortalVisibilityFrame — OutsideView polygon/plane counts and - // per-cell slot plane counts — via RenderingDiagnostics.EmitVis, instead - // of the old camera-state-only spike. See the U.4 ClipFrame assembly - // below (gated on ACDREAM_PROBE_VIS=1, cell-change-throttled). - - // Stage 3 (2026-06-02): replace the IsInsideAnyCell AABB scan with the - // seen_outside-derived predicate. Retail CellManager::ChangePosition (0x004559B0) - // gates sun/lighting off seen_outside on the player's current cell, NOT off an - // independent AABB containment scan. playerInsideCell = true (kill sunlight) only - // when the player is inside a SEALED interior (seen_outside=false = dungeon). - // Building interiors with seen_outside=true keep the sun (sky visible through door). - // V1 (2026-06-03): keyed on the PLAYER cell (playerRoot/playerSeenOutside), independent - // of the camera's viewer cell — retail kills the sun off the player's cell, not the eye. - bool playerInsideCell = playerRoot is not null && !playerSeenOutside; - - // Phase C.1: tick retail PhysicsScript particle hooks. Named - // retail decomp confirms SkyObject.PesObjectId is copied by - // SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is - // debug-only and disabled for normal retail rendering. - if (_options.EnableSkyPesDebug) - UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell); - // Phase G.1/G.2: feed the sun, tick LightManager, build + upload - // the scene-lighting UBO once per frame. Every shader that - // consumes binding=1 reads the same data for the rest of the - // frame — terrain, static mesh, instanced mesh, sky. - UpdateSunFromSky(kf, playerInsideCell); - // A7 indoor lighting: position retail's viewer fill light at the player - // (SmartBox::set_viewer 0x00452c40) before the snapshot is built. It is - // the primary interior fill (no sun indoors) and is indoor-only via the - // AP-43 gate. playerViewPos is the player render position (or camPos when - // there is no player), matching retail's player/viewer branch. - Lighting.UpdateViewerLight(playerViewPos); - Lighting.Tick(camPos); - - // Fix B (A7 #3): build this frame's point-light snapshot and hand it to - // the entity dispatcher for per-OBJECT light selection - // (minimize_object_lighting). Replaces the single global nearest-8-to- - // camera UBO set for point/spot lights so a wall's torches stay tied to - // the wall as the camera moves. The SUN + ambient still flow through the - // SceneLighting UBO built below (binding=1) — terrain/sky read those. - // #176 root cause: the pool is anchored at the PLAYER (retail - // Render::insert_light sorts by Render::player_pos, 0x0054d1b0) and - // collected from ALL registered (=resident-cell) lights — it is a - // function of player position only, so camera rotation cannot change - // it. playerViewPos carries retail's no-player fallback (camPos). - // A7.L1: candidacy is additionally scoped to last frame's rendered - // visible-cell set (see _lightPoolVisibleCells) when available — the - // Town Network starvation fix. Unscoped (null) until the first indoor - // frame completes or after any outdoor-only frame. - Lighting.BuildPointLightSnapshot( - playerViewPos, - _lightPoolVisibleCellsValid ? _lightPoolVisibleCells : null); - _wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot); - _envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2) - - var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( - Lighting, in atmo, camPos, (float)WorldTime.DayFraction); - - // A.5 T22: override fog ramp with N₁/N₂-derived distances so the - // horizon fog masks the N₁ scenery boundary. Sky keyframe fog is - // retail-accurate at normal view distances but far too short for - // the extended N₂=12 (25×25 LB) streaming window. - // FogStart = N₁ × 192m × 0.7 ≈ 538m at defaults (4/12). - // FogEnd = N₂ × 192m × 0.95 ≈ 2188m at defaults. - // Multipliers exposed as env vars for fast iteration at visual gate. - { - const float LandblockSize = 192.0f; - float startMult = ParseEnvFloat("ACDREAM_FOG_START_MULT", 0.7f); - float endMult = ParseEnvFloat("ACDREAM_FOG_END_MULT", 0.95f); - float fogStart = _nearRadius * LandblockSize * startMult; - float fogEnd = _farRadius * LandblockSize * endMult; - // Preserve fog color (xyz), lightning flash (z), and mode (w). - ubo.FogParams = new System.Numerics.Vector4( - fogStart, - fogEnd, - ubo.FogParams.Z, // lightning flash — unchanged - ubo.FogParams.W); // fog mode — unchanged - } - - _sceneLightingUbo?.Upload(ubo); - - // #133 A7 (2026-06-13): objective dungeon-lighting probe. One - // rate-limited [light] line — insideCell / ambient / sun / - // registered-point-lights / active-slot-count / player cell — so - // the dungeon-dim question is self-verifiable from launch.log - // without a screenshot. RegisteredCount is point/spot lights only - // (the sun lives in LightManager.Sun, never in the _all list); - // ubo.CellAmbient.W is the shader active-slot count, which counts - // the (zeroed) sun slot indoors. Inert unless ACDREAM_PROBE_LIGHT=1. - AcDream.Core.Rendering.RenderingDiagnostics.EmitLight( - insideCell: playerInsideCell, - ambientR: Lighting.CurrentAmbient.AmbientColor.X, - ambientG: Lighting.CurrentAmbient.AmbientColor.Y, - ambientB: Lighting.CurrentAmbient.AmbientColor.Z, - sunIntensity: Lighting.Sun?.Intensity ?? 0f, - registeredLights: Lighting.RegisteredCount, - activeLights: (int)ubo.CellAmbient.W, - playerCellId: playerRoot?.CellId ?? 0u, - lights: Lighting); + AcDream.App.Rendering.WorldRenderFrame worldFrame = + _worldRenderFrameBuilder!.Build( + in foundation, + IsLiveModeWaitingForLogin, + _activeDayGroup); + AcDream.App.Rendering.WorldCameraFrame cameraFrame = worldFrame.Camera; + AcDream.App.Rendering.WorldRootFrame rootFrame = worldFrame.Roots; + var camera = cameraFrame.Camera; + var worldProjection = cameraFrame.Projection; + var frustum = cameraFrame.Frustum; + var camPos = cameraFrame.Position; + // The frame builder resolves retail's player-lighting root and + // collided-viewer render root before any draw decision is made. + LoadedCell? playerRoot = rootFrame.PlayerRoot; + bool playerSeenOutside = rootFrame.PlayerSeenOutside; + uint viewerCellId = rootFrame.ViewerCellId; + var viewerEyePos = rootFrame.ViewerEyePosition; + var playerViewPos = rootFrame.PlayerViewPosition; + LoadedCell? viewerRoot = rootFrame.ViewerRoot; + bool cameraInsideCell = rootFrame.CameraInsideCell; // Never cull the landblock the player is currently on. - uint? playerLb = null; - if (_playerMode && _playerController is not null) - { - var pp = _playerController.Position; - int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); - int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); - playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); - } + uint? playerLb = rootFrame.PlayerLandblockId; + int renderCenterLbX = rootFrame.RenderCenterLandblockX; + int renderCenterLbY = rootFrame.RenderCenterLandblockY; - int renderCenterLbX = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f); - int renderCenterLbY = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f); - - // Phase A8: update EnvCellRenderer's frustum. The per-frame shell snapshot - // is prepared after the portal-visible cell filter is known. - var envCellViewProj = camera.View * worldProjection; - _envCellFrustum?.Update(envCellViewProj); - - // MP-Alloc: reuse _animatedIdsScratch instead of `new`ing a - // HashSet every frame. Downstream consumers (WbDrawDispatcher. - // WalkEntitiesInto) treat null and an empty set identically, so an - // always-non-null (possibly empty) set is behaviorally the same as - // the old null-when-nothing-animated local. - // - // Every entity in _animatedEntities (i.e. every entity with a - // Sequencer) is added UNCONDITIONALLY: membership here is the - // "re-classify me every frame, don't use the Tier-1 static cache" - // flag, and the Tier-1 cache captures an entity's REST pose + - // opacity-1.0 exactly once. Any entity whose rendered state can - // depart that rest — a door held at its final OPEN frame, a wall - // held FADED-OUT by a TransparentPartHook — MUST stay on the - // per-frame path so the live sequencer pose + TranslucencyFadeManager - // opacity are read; otherwise it flips back to the stale cached - // closed/opaque state the instant its animation settles. - // - // DO NOT re-narrow this to "only if the current cycle is multi-frame" - // (the reverted IsEntityCurrentlyMoving gate, 2026-07-09): a settled - // one-shot hold IS pose-stable frame-to-frame, but stable at the - // HELD pose the cache does NOT contain — that is the door/fade - // flip-back. The per-frame re-classification cost that narrowing - // saved was a DEBUG-build artifact; Release is GPU-bound (the CPU - // work hides under GPU time), so the unconditional add is free where - // it matters. If a real Release CPU cost from static-prop - // re-classification is ever measured, gate on "entity is at its - // captured rest state" (default motion AND no active fade), never on - // "is mid-cycle". - _animatedEntities.CopySpatialIdsTo(_animatedIdsScratch); - _staticAnimationScheduler?.CopyAnimatedEntityIdsTo(_animatedIdsScratch); - if (_equippedChildRenderer is not null) - { - foreach (uint id in _equippedChildRenderer.AttachedEntityIds) - _animatedIdsScratch.Add(id); - } - HashSet? animatedIds = _animatedIdsScratch; + var envCellViewProj = cameraFrame.ViewProjection; + HashSet animatedIds = worldFrame.AnimatedEntityIds; // Phase G.1: sky renderer — draws the far-plane-infinity // celestial meshes FIRST so the rest of the scene z-tests @@ -3814,7 +3463,7 @@ public sealed class GameWindow : IDisposable // Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky — clipped // to the doorway via the OutsideView (Stage 4, below). // Sealed dungeon (cameraInsideCell=true, rootSeenOutside=false): no sky. - bool renderSky = viewerRoot is null || rootSeenOutside; + bool renderSky = rootFrame.RenderSky; // Phase W Stage 4 (2026-06-02): the sky/weather DRAW moved DOWN to its retail LScape // position — AFTER the portal-visibility ClipFrame is assembled — so it can be clipped to // the doorway (OutsideView) by sky.vert's gl_ClipDistance. See the "[Stage 4] sky @@ -3841,72 +3490,20 @@ public sealed class GameWindow : IDisposable // GPU-fenced frame slot; each renderer re-binds binding=2 defensively. _clipFrame ??= ClipFrame.NoClip(); - // Phase 3 (render unification, additive): build the synthetic outdoor cell node when - // the eye is outdoors (no interior viewerRoot). Stored in _outdoorNode but NOT yet - // rooted — behaviour is unchanged this commit. The nearby-building enumeration mirrors - // the look-in candidate gather in the OUTDOOR branch below (Chebyshev <=1 landblocks); - // OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip + - // OutsideView terrain integration that consumes this is the next (cutover) step. - _outdoorNode = null; - _outdoorNodeBuildingCells.Clear(); - if (viewerRoot is not null || viewerCellId != 0u) - { - // T2 (BR-4): draw-driven flood gating. Retail floods a building's - // interior exactly when its shell DRAWS and an aperture survives - // the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck - // → portal-BSP walk → ConstructView's GetClip; NO distance - // constant anywhere on the chain). Port: a per-BUILDING frustum - // pre-gate on the aperture bounds (Building.PortalBounds — the - // tight equivalent of the shell viewconeCheck for FLOOD purposes), - // replacing the old Chebyshev≤1 landblock cell-sweep; the 48 m - // seed cap dies with it (RetailPViewRenderer seeds at ∞). The - // per-portal admission stays BuildFromExterior's screen clip - // (empty clip = no seed) — retail's GetClip-vs-view gate. - // Per-building iteration is also the FPS fix the 2026-06-07 - // Chebyshev hack approximated: dozens of AABB tests instead of an - // O(all loaded cells) portal sweep. - // #124: the gather now runs for INTERIOR roots too — retail's - // look-in executes inside LScape::draw for ANY root with a - // non-empty outside view (DrawCells pc:432719). The renderer - // routes interior-root look-ins to its landscape-stage sub-pass - // (DrawBuildingLookIns); the root's own building self-excludes - // via the seed eye-side test. - foreach (var registry in _landblockPresentationPipeline?.BuildingRegistries - ?? Array.Empty()) - { - foreach (var b in registry.All()) - { - if (b.HasPortalBounds - && !AcDream.App.Rendering.FrustumCuller.IsAabbVisible( - frustum, b.PortalBounds.Min, b.PortalBounds.Max)) - continue; - - foreach (uint cid in b.EnvCellIds) - if (_cellVisibility.TryGetCell(cid, out var bc) && bc is not null) - _outdoorNodeBuildingCells.Add(bc); - } - } - if (viewerRoot is null) - _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId); - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) - Console.WriteLine(System.FormattableString.Invariant( - $"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)")); - } - - uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; - bool playerIndoorGate = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor( - playerCellId, - playerRoot is not null); + IReadOnlyList nearbyBuildingCells = + worldFrame.Buildings.NearbyBuildingCells; + bool playerIndoorGate = rootFrame.PlayerIndoorGate; + uint playerCellId = rootFrame.PlayerCellId; // Render unification (outdoor-as-cell, 2026-06-07 cutover): ONE render path rooted at the // VIEWER cell. Eye indoors -> its interior EnvCell (viewerRoot); eye outdoors -> the - // synthetic outdoor node (_outdoorNode, built above from nearby building entrances). The + // frame builder's synthetic outdoor node, built from nearby building entrances. The // result is null ONLY when neither exists (pre-spawn / login / legacy non-chase camera) -> // the outdoor LScape block below still runs as the safety path (and login still shows the // live sky). There is no inside/outside branch to TOGGLE as the chase eye crosses the // doorway boundary, so the indoor FLAP dies by construction. playerIndoorGate stays // computed for the [render-sig] probe but no longer selects the path (handoff // docs/research/2026-06-07-render-unification-cutover-flip-handoff.md section 4 Step B). - var clipRoot = viewerRoot ?? _outdoorNode; + var clipRoot = worldFrame.ClipRoot; string renderBranch = clipRoot is null ? "OutdoorRoot" : "RetailPViewInside"; @@ -4026,7 +3623,7 @@ public sealed class GameWindow : IDisposable // R-A2: outdoor root floods each nearby building per-building (not via the root). // #124: interior roots get the gather too — the renderer routes them to the // landscape-stage look-in sub-pass instead of the merge. - NearbyBuildingCells = _outdoorNodeBuildingCells, + NearbyBuildingCells = nearbyBuildingCells, ViewerEyePos = viewerEyePos, ViewProjection = envCellViewProj, CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, @@ -4141,9 +3738,7 @@ public sealed class GameWindow : IDisposable // pool scoping. DrawableCells is the renderer's own reused scratch set // (cleared/rebuilt every DrawInside call) — copy it, don't hold a // reference to it. - _lightPoolVisibleCells.Clear(); - _lightPoolVisibleCells.UnionWith(pviewResult.DrawableCells); - _lightPoolVisibleCellsValid = true; + _worldRenderFrameBuilder!.ObserveDrawableCells(pviewResult.DrawableCells); // Flap root-cause apparatus (2026-06-07): per-frame, the EXACT Build inputs at 6 dp + // the resulting flood count. The live flap shows flood flipping 2↔6 at an eye/player @@ -4212,7 +3807,7 @@ public sealed class GameWindow : IDisposable // A7.L1: no indoor draw this frame — fail open (unscoped) rather than // scoping next frame's light pool by a stale dungeon's cell ids. - _lightPoolVisibleCellsValid = false; + _worldRenderFrameBuilder!.ClearDrawableCells(); } // Phase U.3: close the world-geometry clip bracket opened above. From here down the @@ -4993,67 +4588,6 @@ public sealed class GameWindow : IDisposable return true; } - /// - /// Derive the current sun (directional light, slot 0 of the UBO) - /// from the interpolated , - /// plus the cell ambient. Indoor cells force the sun intensity to - /// zero and substitute a flat 0.2 white ambient — exact retail - /// behavior per CellManager::ChangePosition @ 0x004559B0, - /// which calls SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF) - /// when the player's CObjCell::seen_outside flag is 0. - /// Indoor brightness then comes from per-cell point lights - /// (Setup.Lights on the cell's static objects, registered through - /// ). - /// The trigger is the PLAYER's cell, not the camera's — third-person - /// chase camera enters interiors before the player body does, and - /// retail keys lighting off the player position. - /// - private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool playerInsideCell) - { - // Sun direction: points FROM the sun TOWARDS the world. Our - // shader does dot(N, -forward) so a positive N·L means the - // surface faces the sun. - var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf); - - if (playerInsideCell) - { - // Indoor default — retail's flat 0.2 neutral ambient, sun - // zeroed. See xref to retail decomp in the doc comment above. - Lighting.Sun = new AcDream.Core.Lighting.LightSource - { - Kind = AcDream.Core.Lighting.LightKind.Directional, - WorldForward = sunToWorld, - ColorLinear = System.Numerics.Vector3.Zero, - Intensity = 0f, - Range = 1f, - }; - Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState( - AmbientColor: new System.Numerics.Vector3(0.20f, 0.20f, 0.20f), - SunColor: System.Numerics.Vector3.Zero, - SunDirection: sunToWorld); - } - else - { - // Outdoor: full keyframe sun + ambient. The SkyKeyframe stores - // raw DirColor + DirBright (and AmbColor + AmbBright) for - // retail-faithful per-channel keyframe interpolation; the - // computed `kf.SunColor` / `kf.AmbientColor` properties return - // the post-multiplied product the shader expects. - Lighting.Sun = new AcDream.Core.Lighting.LightSource - { - Kind = AcDream.Core.Lighting.LightKind.Directional, - WorldForward = sunToWorld, - ColorLinear = kf.SunColor, - Intensity = 1f, - Range = 1f, - }; - Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState( - AmbientColor: kf.AmbientColor, - SunColor: kf.SunColor, - SunDirection: sunToWorld); - } - } - // ── Phase I.2 — DebugPanel helpers ──────────────────────────────── // // The ImGui DebugPanel reads through DebugVM closures that ask @@ -5934,17 +5468,6 @@ public sealed class GameWindow : IDisposable $"resident={_worldState.Entities.Count} walked={walked}"); } - /// A.5 T22: parse a float environment variable, returning - /// when the variable is absent or unparseable. - private static float ParseEnvFloat(string name, float defaultValue) - { - var s = System.Environment.GetEnvironmentVariable(name); - if (s is not null && float.TryParse(s, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out var v)) - return v; - return defaultValue; - } - private void OnClosing() => CompleteShutdown(); @@ -5989,6 +5512,17 @@ public sealed class GameWindow : IDisposable _liveSessionController = null; }), ]), + // Frame composition borrows equipped, effect, audio, and render + // owners. Withdraw the graph before the first borrowed owner closes; + // the later render-frontend stage still disposes the GL owners. + new ResourceShutdownStage("frame borrowers", + [ + new("world frame composition", () => + { + _worldRenderFrameBuilder = null; + _skyPesFrame = null; + }), + ]), new ResourceShutdownStage("session dependents", [ new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()), diff --git a/src/AcDream.App/Rendering/SkyPesFrameController.cs b/src/AcDream.App/Rendering/SkyPesFrameController.cs new file mode 100644 index 00000000..57dfd96b --- /dev/null +++ b/src/AcDream.App/Rendering/SkyPesFrameController.cs @@ -0,0 +1,135 @@ +using System.Numerics; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Vfx; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Owns the optional DAT-archaeology sky-PES experiment. Named retail shows +/// GameSky does not consume SkyObject.PesObjectId, so production invokes this +/// owner only when the explicit startup diagnostic is enabled. +/// +internal sealed class SkyPesFrameController +{ + private readonly record struct SkyPesKey( + int ObjectIndex, + uint PesObjectId, + bool PostScene); + + private readonly PhysicsScriptRunner _scripts; + private readonly ParticleHookSink _particles; + private readonly EntityEffectPoseRegistry _poses; + private readonly EntityEffectController? _effects; + private readonly HashSet _active = []; + private readonly HashSet _missing = []; + + public SkyPesFrameController( + PhysicsScriptRunner scripts, + ParticleHookSink particles, + EntityEffectPoseRegistry poses, + EntityEffectController? effects) + { + _scripts = scripts ?? throw new ArgumentNullException(nameof(scripts)); + _particles = particles ?? throw new ArgumentNullException(nameof(particles)); + _poses = poses ?? throw new ArgumentNullException(nameof(poses)); + _effects = effects; + } + + public void Update( + float dayFraction, + DayGroupData? dayGroup, + Vector3 cameraWorldPosition, + bool suppressSky) + { + var seen = new HashSet(); + if (!suppressSky && dayGroup is not null) + { + for (int index = 0; index < dayGroup.SkyObjects.Count; index++) + { + SkyObjectData skyObject = dayGroup.SkyObjects[index]; + if (skyObject.PesObjectId == 0 || !skyObject.IsVisible(dayFraction)) + continue; + + var key = new SkyPesKey( + index, + skyObject.PesObjectId, + skyObject.IsPostScene); + seen.Add(key); + uint ownerId = EntityId(key); + ParticleRenderPass renderPass = skyObject.IsPostScene + ? ParticleRenderPass.SkyPostScene + : ParticleRenderPass.SkyPreScene; + _particles.SetEntityRenderPass(ownerId, renderPass); + Vector3 anchor = Anchor(skyObject, cameraWorldPosition); + Quaternion rotation = Rotation(skyObject, dayFraction); + _poses.Publish( + ownerId, + Matrix4x4.CreateFromQuaternion(rotation) + * Matrix4x4.CreateTranslation(anchor), + Array.Empty(), + cellId: 0u); + + if (_active.Contains(key) || _missing.Contains(key)) + continue; + + _effects?.RegisterSyntheticOwner(ownerId); + if (_scripts.Play(skyObject.PesObjectId, ownerId, anchor)) + { + _active.Add(key); + } + else + { + _missing.Add(key); + _effects?.UnregisterSyntheticOwner(ownerId); + _particles.ClearEntityRenderPass(ownerId); + _poses.Remove(ownerId); + } + } + } + + foreach (SkyPesKey key in _active.ToArray()) + { + if (seen.Contains(key)) + continue; + + uint ownerId = EntityId(key); + _scripts.StopAllForEntity(ownerId); + _effects?.UnregisterSyntheticOwner(ownerId); + _particles.StopAllForEntity(ownerId, fadeOut: true); + _poses.Remove(ownerId); + _active.Remove(key); + } + + foreach (SkyPesKey key in _missing.ToArray()) + { + if (!seen.Contains(key)) + _missing.Remove(key); + } + } + + private static uint EntityId(SkyPesKey key) + { + uint postScene = key.PostScene ? 0x08000000u : 0u; + return 0xF0000000u + | postScene + | ((uint)key.ObjectIndex & 0x07FFFFFFu); + } + + private static Vector3 Anchor( + SkyObjectData skyObject, + Vector3 cameraWorldPosition) + { + if (skyObject.IsWeather && (skyObject.Properties & 0x08u) == 0u) + return cameraWorldPosition + new Vector3(0f, 0f, -120f); + return cameraWorldPosition; + } + + private static Quaternion Rotation( + SkyObjectData skyObject, + float dayFraction) + { + float radians = skyObject.CurrentAngle(dayFraction) * (MathF.PI / 180f); + return Quaternion.CreateFromAxisAngle(Vector3.UnitY, -radians); + } +} diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs new file mode 100644 index 00000000..9e575b07 --- /dev/null +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -0,0 +1,645 @@ +using System.Numerics; +using AcDream.App.Audio; +using AcDream.App.Input; +using AcDream.App.Rendering.Selection; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Lighting; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; +using AcDream.Core.World; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Rendering; + +/// Camera facts borrowed for one world render preparation. +internal readonly record struct WorldCameraFrame( + ICamera Camera, + Matrix4x4 Projection, + Matrix4x4 ViewProjection, + FrustumPlanes Frustum, + Matrix4x4 InverseView, + Vector3 Position); + +/// Player-lighting and collided-viewer roots for one frame. +internal readonly record struct WorldRootFrame( + LoadedCell? PlayerRoot, + bool PlayerSeenOutside, + uint ViewerCellId, + Vector3 ViewerEyePosition, + Vector3 PlayerViewPosition, + LoadedCell? ViewerRoot, + bool CameraInsideCell, + bool RootSeenOutside, + bool PlayerInsideCell, + uint? PlayerLandblockId, + int RenderCenterLandblockX, + int RenderCenterLandblockY, + uint PlayerCellId, + bool PlayerIndoorGate) +{ + public bool RenderSky => ViewerRoot is null || RootSeenOutside; +} + +/// Borrowed building scratch, valid only until the next build. +internal readonly record struct WorldBuildingFrame( + LoadedCell? OutdoorNode, + IReadOnlyList NearbyBuildingCells); + +/// +/// Immutable one-frame world facts. Collection members borrow reusable owner +/// scratch and must not be retained beyond the next . +/// +internal readonly record struct WorldRenderFrame( + WorldCameraFrame Camera, + WorldRootFrame Roots, + WorldBuildingFrame Buildings, + HashSet AnimatedEntityIds) +{ + public LoadedCell? ClipRoot => Roots.ViewerRoot ?? Buildings.OutdoorNode; +} + +internal interface IWorldFrameCameraSource +{ + WorldCameraFrame Resolve(); +} + +internal interface IWorldFrameRootSource +{ + WorldRootFrame Resolve(in WorldCameraFrame camera); +} + +internal interface IWorldFrameVisibilityPreparation +{ + void Begin(in WorldCameraFrame camera, bool waitingForLogin); + + void PublishViewProjection(in WorldCameraFrame camera); +} + +internal interface IWorldFrameSettingsPreview +{ + void Apply(in WorldCameraFrame camera); +} + +internal interface IWorldFrameEnvironmentPreparation +{ + void Prepare( + in WorldCameraFrame camera, + in WorldRootFrame roots, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup); + + void ObserveDrawableCells(IReadOnlySet drawableCells); + + void ClearDrawableCells(); +} + +internal interface IWorldFrameAnimatedEntitySource +{ + HashSet Capture(); +} + +internal interface IWorldFrameBuildingSource +{ + WorldBuildingFrame Gather( + LoadedCell? viewerRoot, + uint viewerCellId, + in FrustumPlanes frustum); +} + +/// +/// Orders typed world-frame fact sources without owning their borrowed render +/// resources. It makes no draw decision and retains no result from a prior call. +/// +internal sealed class WorldRenderFrameBuilder +{ + private readonly IWorldFrameCameraSource _camera; + private readonly IWorldFrameVisibilityPreparation _visibility; + private readonly IWorldFrameSettingsPreview _settings; + private readonly IWorldFrameRootSource _roots; + private readonly IWorldFrameEnvironmentPreparation _environment; + private readonly IWorldFrameAnimatedEntitySource _animated; + private readonly IWorldFrameBuildingSource _buildings; + + public WorldRenderFrameBuilder( + IWorldFrameCameraSource camera, + IWorldFrameVisibilityPreparation visibility, + IWorldFrameSettingsPreview settings, + IWorldFrameRootSource roots, + IWorldFrameEnvironmentPreparation environment, + IWorldFrameAnimatedEntitySource animated, + IWorldFrameBuildingSource buildings) + { + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _visibility = visibility ?? throw new ArgumentNullException(nameof(visibility)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _roots = roots ?? throw new ArgumentNullException(nameof(roots)); + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + _animated = animated ?? throw new ArgumentNullException(nameof(animated)); + _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); + } + + public WorldRenderFrame Build( + in RenderFrameFoundation foundation, + bool waitingForLogin, + DayGroupData? activeDayGroup) + { + WorldCameraFrame camera = _camera.Resolve(); + _visibility.Begin(in camera, waitingForLogin); + _settings.Apply(in camera); + WorldRootFrame roots = _roots.Resolve(in camera); + _environment.Prepare(in camera, in roots, in foundation, activeDayGroup); + _visibility.PublishViewProjection(in camera); + HashSet animated = _animated.Capture(); + FrustumPlanes frustum = camera.Frustum; + WorldBuildingFrame buildings = _buildings.Gather( + roots.ViewerRoot, + roots.ViewerCellId, + in frustum); + return new WorldRenderFrame(camera, roots, buildings, animated); + } + + public void ObserveDrawableCells(IReadOnlySet drawableCells) => + _environment.ObserveDrawableCells(drawableCells); + + public void ClearDrawableCells() => _environment.ClearDrawableCells(); +} + +internal sealed class RuntimeWorldFrameCameraSource : IWorldFrameCameraSource +{ + private readonly CameraController _cameras; + private readonly LocalPlayerTeleportController _teleport; + + public RuntimeWorldFrameCameraSource( + CameraController cameras, + LocalPlayerTeleportController teleport) + { + _cameras = cameras ?? throw new ArgumentNullException(nameof(cameras)); + _teleport = teleport ?? throw new ArgumentNullException(nameof(teleport)); + } + + public WorldCameraFrame Resolve() + { + ICamera camera = _teleport.ApplyViewPlane(_cameras.Active); + Matrix4x4 projection = camera.Projection; + Matrix4x4 viewProjection = camera.View * projection; + FrustumPlanes frustum = FrustumPlanes.FromViewProjection(viewProjection); + Matrix4x4.Invert(camera.View, out Matrix4x4 inverseView); + var position = new Vector3(inverseView.M41, inverseView.M42, inverseView.M43); + return new WorldCameraFrame( + camera, + projection, + viewProjection, + frustum, + inverseView, + position); + } +} + +internal sealed class RuntimeWorldFrameRootSource : IWorldFrameRootSource +{ + private const float LandblockSize = 192f; + + private readonly PhysicsEngine _physics; + private readonly CellVisibility _cells; + private readonly ILocalPlayerModeSource _mode; + private readonly IChaseCameraSource _chase; + private readonly ILocalPlayerControllerSource _player; + private readonly LiveWorldOriginState _origin; + + public RuntimeWorldFrameRootSource( + PhysicsEngine physics, + CellVisibility cells, + ILocalPlayerModeSource mode, + IChaseCameraSource chase, + ILocalPlayerControllerSource player, + LiveWorldOriginState origin) + { + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _cells = cells ?? throw new ArgumentNullException(nameof(cells)); + _mode = mode ?? throw new ArgumentNullException(nameof(mode)); + _chase = chase ?? throw new ArgumentNullException(nameof(chase)); + _player = player ?? throw new ArgumentNullException(nameof(player)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + } + + public WorldRootFrame Resolve(in WorldCameraFrame camera) + { + // Retail keeps these as two related but distinct positions: + // CellManager::ChangePosition @ 0x004559B0 uses the player's current + // cell for seen_outside/lighting, while RenderNormalMode @ 0x00453AA0 + // passes the collided camera's viewer_cell to DrawInside. + LoadedCell? playerRoot = null; + if (_physics.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell playerCell + && _cells.TryGetCell(playerCell.Id, out LoadedCell? registeredPlayer)) + { + playerRoot = registeredPlayer; + } + + bool playerSeenOutside = playerRoot?.SeenOutside ?? true; + uint viewerCellId = _mode.IsPlayerMode + && _chase.Retail is { } retailChase + && CameraDiagnostics.UseRetailChaseCamera + ? retailChase.ViewerCellId + : playerRoot?.CellId ?? 0u; + LoadedCell? viewerRoot = null; + if (viewerCellId != 0u + && _cells.TryGetCell(viewerCellId, out LoadedCell? registeredViewer)) + { + viewerRoot = registeredViewer; + } + + var player = _player.Controller; + Vector3 playerViewPosition = player?.RenderPosition + ?? player?.Position + ?? camera.Position; + bool cameraInsideCell = viewerRoot is not null; + bool rootSeenOutside = viewerRoot?.SeenOutside ?? true; + bool playerInsideCell = playerRoot is not null && !playerSeenOutside; + + uint? playerLandblockId = null; + if (_mode.IsPlayerMode && player is not null) + { + int playerX = _origin.CenterX + (int)Math.Floor(player.Position.X / LandblockSize); + int playerY = _origin.CenterY + (int)Math.Floor(player.Position.Y / LandblockSize); + playerLandblockId = (uint)((playerX << 24) | (playerY << 16) | 0xFFFF); + } + + int renderCenterX = _origin.CenterX + + (int)Math.Floor(camera.Position.X / LandblockSize); + int renderCenterY = _origin.CenterY + + (int)Math.Floor(camera.Position.Y / LandblockSize); + uint playerCellId = _physics.DataCache?.CellGraph.CurrCell?.Id ?? 0u; + bool playerIndoorGate = RenderingDiagnostics.ShouldRenderIndoor( + playerCellId, + playerRoot is not null); + + return new WorldRootFrame( + playerRoot, + playerSeenOutside, + viewerCellId, + camera.Position, + playerViewPosition, + viewerRoot, + cameraInsideCell, + rootSeenOutside, + playerInsideCell, + playerLandblockId, + renderCenterX, + renderCenterY, + playerCellId, + playerIndoorGate); + } +} + +internal sealed class RuntimeWorldFrameVisibilityPreparation + : IWorldFrameVisibilityPreparation +{ + private readonly RetailSelectionScene? _selection; + private readonly ParticleVisibilityController _particles; + private readonly TerrainModernRenderer? _terrain; + private readonly WorldRevealCoordinator? _reveal; + private readonly WbFrustum? _environmentFrustum; + + public RuntimeWorldFrameVisibilityPreparation( + RetailSelectionScene? selection, + ParticleVisibilityController particles, + TerrainModernRenderer? terrain, + WorldRevealCoordinator? reveal, + WbFrustum? environmentFrustum) + { + _selection = selection; + _particles = particles ?? throw new ArgumentNullException(nameof(particles)); + _terrain = terrain; + _reveal = reveal; + _environmentFrustum = environmentFrustum; + } + + public void Begin(in WorldCameraFrame camera, bool waitingForLogin) + { + _selection?.SetViewFrustum(camera.Frustum); + _particles.BeginFrame(camera.Position); + _terrain?.BeginVisibilityFrame(); + if (waitingForLogin) + return; + + _particles.UseWorldView(); + _reveal?.ObserveWorldViewportVisible(); + _reveal?.Complete(); + } + + public void PublishViewProjection(in WorldCameraFrame camera) => + _environmentFrustum?.Update(camera.ViewProjection); +} + +internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPreview +{ + private readonly SettingsVM? _settings; + private readonly OpenAlAudioEngine? _audio; + private readonly CameraController _cameras; + private readonly DisplayFramePacingController _pacing; + + public RuntimeWorldFrameSettingsPreview( + SettingsVM? settings, + OpenAlAudioEngine? audio, + CameraController cameras, + DisplayFramePacingController pacing) + { + _settings = settings; + _audio = audio; + _cameras = cameras ?? throw new ArgumentNullException(nameof(cameras)); + _pacing = pacing ?? throw new ArgumentNullException(nameof(pacing)); + } + + public void Apply(in WorldCameraFrame camera) + { + if (_audio is { IsAvailable: true } && _settings is not null) + { + var audio = _settings.AudioDraft; + _audio.MasterVolume = audio.Master; + _audio.MusicVolume = audio.Music; + _audio.SfxVolume = audio.Sfx; + _audio.AmbientVolume = audio.Ambient; + } + + if (_settings is not null) + { + var display = _settings.DisplayDraft; + float fieldOfView = display.FieldOfView * (MathF.PI / 180f); + _cameras.Orbit.FovY = fieldOfView; + _cameras.Fly.FovY = fieldOfView; + if (_cameras.Chase is not null) + _cameras.Chase.FovY = fieldOfView; + _pacing.ApplyPreference(display.VSync); + } + + if (_audio is not { IsAvailable: true }) + return; + + Matrix4x4 inverse = camera.InverseView; + var forward = new Vector3(-inverse.M31, -inverse.M32, -inverse.M33); + var up = new Vector3(inverse.M21, inverse.M22, inverse.M23); + Vector3 position = camera.Position; + _audio.SetListener( + position.X, position.Y, position.Z, + forward.X, forward.Y, forward.Z, + up.X, up.Y, up.Z); + } +} + +internal interface IWorldRenderRangeSource +{ + int NearRadius { get; } + + int FarRadius { get; } +} + +internal sealed class WorldRenderRangeState : IWorldRenderRangeSource +{ + public WorldRenderRangeState(int nearRadius, int farRadius) + { + NearRadius = nearRadius; + FarRadius = farRadius; + } + + public int NearRadius { get; set; } + + public int FarRadius { get; set; } +} + +internal sealed class RuntimeWorldFrameEnvironmentPreparation + : IWorldFrameEnvironmentPreparation +{ + private const float LandblockSize = 192f; + + private readonly RuntimeOptions _options; + private readonly WorldTimeService _worldTime; + private readonly LightManager _lighting; + private readonly WbDrawDispatcher? _dispatcher; + private readonly EnvCellRenderer? _environmentCells; + private readonly SceneLightingUboBinding? _lightingUbo; + private readonly IWorldRenderRangeSource _ranges; + private readonly SkyPesFrameController? _skyPes; + private readonly HashSet _visibleCells = []; + private bool _visibleCellsValid; + + public RuntimeWorldFrameEnvironmentPreparation( + RuntimeOptions options, + WorldTimeService worldTime, + LightManager lighting, + WbDrawDispatcher? dispatcher, + EnvCellRenderer? environmentCells, + SceneLightingUboBinding? lightingUbo, + IWorldRenderRangeSource ranges, + SkyPesFrameController? skyPes) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); + _lighting = lighting ?? throw new ArgumentNullException(nameof(lighting)); + _dispatcher = dispatcher; + _environmentCells = environmentCells; + _lightingUbo = lightingUbo; + _ranges = ranges ?? throw new ArgumentNullException(nameof(ranges)); + _skyPes = skyPes; + } + + public void Prepare( + in WorldCameraFrame camera, + in WorldRootFrame roots, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup) + { + if (_options.EnableSkyPesDebug) + { + _skyPes?.Update( + (float)_worldTime.DayFraction, + activeDayGroup, + camera.Position, + roots.CameraInsideCell); + } + + UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell); + _lighting.UpdateViewerLight(roots.PlayerViewPosition); + _lighting.Tick(camera.Position); + _lighting.BuildPointLightSnapshot( + roots.PlayerViewPosition, + _visibleCellsValid ? _visibleCells : null); + _dispatcher?.SetSceneLights(_lighting.PointSnapshot); + _environmentCells?.SetPointSnapshot(_lighting.PointSnapshot); + + AtmosphereSnapshot atmosphere = foundation.Atmosphere; + SceneLightingUbo ubo = SceneLightingUbo.Build( + _lighting, + in atmosphere, + camera.Position, + (float)_worldTime.DayFraction); + float fogStart = _ranges.NearRadius + * LandblockSize + * _options.FogStartMultiplier; + float fogEnd = _ranges.FarRadius + * LandblockSize + * _options.FogEndMultiplier; + ubo.FogParams = new Vector4( + fogStart, + fogEnd, + ubo.FogParams.Z, + ubo.FogParams.W); + _lightingUbo?.Upload(ubo); + + RenderingDiagnostics.EmitLight( + insideCell: roots.PlayerInsideCell, + ambientR: _lighting.CurrentAmbient.AmbientColor.X, + ambientG: _lighting.CurrentAmbient.AmbientColor.Y, + ambientB: _lighting.CurrentAmbient.AmbientColor.Z, + sunIntensity: _lighting.Sun?.Intensity ?? 0f, + registeredLights: _lighting.RegisteredCount, + activeLights: (int)ubo.CellAmbient.W, + playerCellId: roots.PlayerRoot?.CellId ?? 0u, + lights: _lighting); + } + + public void ObserveDrawableCells(IReadOnlySet drawableCells) + { + ArgumentNullException.ThrowIfNull(drawableCells); + _visibleCells.Clear(); + _visibleCells.UnionWith(drawableCells); + _visibleCellsValid = true; + } + + public void ClearDrawableCells() => _visibleCellsValid = false; + + private void UpdateSunFromSky(SkyKeyframe keyframe, bool playerInsideCell) + { + // CellManager::ChangePosition @ 0x004559B0 keys this on the player + // cell's seen_outside flag and installs neutral 0.2 ambient indoors. + Vector3 sunToWorld = -SkyStateProvider.SunDirectionFromKeyframe(keyframe); + if (playerInsideCell) + { + _lighting.Sun = new LightSource + { + Kind = LightKind.Directional, + WorldForward = sunToWorld, + ColorLinear = Vector3.Zero, + Intensity = 0f, + Range = 1f, + }; + _lighting.CurrentAmbient = new CellAmbientState( + new Vector3(0.20f, 0.20f, 0.20f), + Vector3.Zero, + sunToWorld); + return; + } + + _lighting.Sun = new LightSource + { + Kind = LightKind.Directional, + WorldForward = sunToWorld, + ColorLinear = keyframe.SunColor, + Intensity = 1f, + Range = 1f, + }; + _lighting.CurrentAmbient = new CellAmbientState( + keyframe.AmbientColor, + keyframe.SunColor, + sunToWorld); + } +} + +internal sealed class RuntimeWorldFrameAnimatedEntitySource + : IWorldFrameAnimatedEntitySource +{ + private readonly LiveEntityAnimationRuntimeView _live; + private readonly RetailStaticAnimatingObjectScheduler? _statics; + private readonly EquippedChildRenderController? _equipped; + private readonly HashSet _scratch = []; + + public RuntimeWorldFrameAnimatedEntitySource( + LiveEntityAnimationRuntimeView live, + RetailStaticAnimatingObjectScheduler? statics, + EquippedChildRenderController? equipped) + { + _live = live ?? throw new ArgumentNullException(nameof(live)); + _statics = statics; + _equipped = equipped; + } + + public HashSet Capture() + { + // Membership means "classify from the current pose this frame," not + // merely "animation is advancing." A settled door or translucency + // hook can differ permanently from its cached rest pose, so every + // sequenced/attached entity remains on the live path. + _live.CopySpatialIdsTo(_scratch); + _statics?.CopyAnimatedEntityIdsTo(_scratch); + if (_equipped is not null) + { + foreach (uint entityId in _equipped.AttachedEntityIds) + _scratch.Add(entityId); + } + + return _scratch; + } +} + +internal sealed class RuntimeWorldFrameBuildingSource : IWorldFrameBuildingSource +{ + private readonly LandblockPresentationPipeline _presentation; + private readonly CellVisibility _cells; + private readonly List _scratch = []; + + public RuntimeWorldFrameBuildingSource( + LandblockPresentationPipeline presentation, + CellVisibility cells) + { + _presentation = presentation + ?? throw new ArgumentNullException(nameof(presentation)); + _cells = cells ?? throw new ArgumentNullException(nameof(cells)); + } + + public WorldBuildingFrame Gather( + LoadedCell? viewerRoot, + uint viewerCellId, + in FrustumPlanes frustum) + { + _scratch.Clear(); + LoadedCell? outdoorNode = null; + if (viewerRoot is null && viewerCellId == 0u) + return new WorldBuildingFrame(null, _scratch); + + // DrawBuilding @ 0x0059F2A0 gates each building by its visible aperture + // before walking its portal BSP. The portal-bounds frustum test is the + // corresponding coarse admission gate; there is no distance cutoff. + foreach (BuildingRegistry registry in _presentation.BuildingRegistries) + { + foreach (Building building in registry.All()) + { + if (building.HasPortalBounds + && !FrustumCuller.IsAabbVisible( + frustum, + building.PortalBounds.Min, + building.PortalBounds.Max)) + { + continue; + } + + foreach (uint cellId in building.EnvCellIds) + { + if (_cells.TryGetCell(cellId, out LoadedCell? cell) && cell is not null) + _scratch.Add(cell); + } + } + } + + if (viewerRoot is null) + outdoorNode = OutdoorCellNode.Build(viewerCellId); + if (RenderingDiagnostics.ProbeFlapEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_scratch.Count} (T2 frustum-gated per-building floods)")); + } + + return new WorldBuildingFrame(outdoorNode, _scratch); + } +} diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 82e3e3a5..32e502eb 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -46,7 +46,9 @@ public sealed record RuntimeOptions( string? AcDir, bool UiProbeDump, string? UiProbeScript, - string? AutomationArtifactDirectory) + string? AutomationArtifactDirectory, + float FogStartMultiplier, + float FogEndMultiplier) { /// /// Build options from the process environment. Used by @@ -99,7 +101,9 @@ public sealed record RuntimeOptions( UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")), UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")), AutomationArtifactDirectory: - NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR"))); + NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")), + FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f, + FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f); } /// True iff live-mode credentials are present and valid for connecting. @@ -120,4 +124,9 @@ public sealed record RuntimeOptions( private static int? TryParseNonNegativeInt(string? s) => TryParseInt(s) is { } v && v >= 0 ? v : null; + + private static float? TryParseFloat(string? s) + => float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float value) + ? value + : null; } diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs new file mode 100644 index 00000000..498422ff --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -0,0 +1,522 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Lighting; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; +using AcDream.Core.World; +using AcDream.Core.World.Cells; + +namespace AcDream.App.Tests.Rendering; + +public sealed class WorldRenderFrameBuilderTests +{ + [Fact] + public void Build_orders_world_preparation_and_combines_borrowed_results() + { + List calls = []; + var camera = new FlyCamera { Position = new Vector3(11f, 22f, 33f) }; + var cameraFrame = new WorldCameraFrame( + camera, + camera.Projection, + camera.View * camera.Projection, + default, + Matrix4x4.Identity, + camera.Position); + WorldRootFrame roots = default; + var animated = new HashSet { 41u, 42u }; + var buildings = new List(); + var foundation = new RenderFrameFoundation( + PortalViewportVisible: false, + Sky: default, + Atmosphere: default); + var visibility = new RecordingVisibility(calls); + var environment = new RecordingEnvironment(calls); + var builder = new WorldRenderFrameBuilder( + new RecordingCamera(calls, cameraFrame), + visibility, + new RecordingSettings(calls), + new RecordingRoots(calls, roots), + environment, + new RecordingAnimated(calls, animated), + new RecordingBuildings(calls, new WorldBuildingFrame(null, buildings))); + + WorldRenderFrame result = builder.Build( + in foundation, + waitingForLogin: true, + activeDayGroup: null); + var drawableCells = new HashSet { 0x01010001u }; + builder.ObserveDrawableCells(drawableCells); + builder.ClearDrawableCells(); + + Assert.Equal( + [ + "camera", + "visibility:begin", + "settings", + "roots", + "environment", + "visibility:projection", + "animated", + "buildings", + ], + calls); + Assert.Same(camera, result.Camera.Camera); + Assert.Same(animated, result.AnimatedEntityIds); + Assert.Same(buildings, result.Buildings.NearbyBuildingCells); + Assert.Null(result.ClipRoot); + Assert.True(visibility.WaitingForLogin); + Assert.Equal(foundation, environment.Foundation); + Assert.Same(drawableCells, environment.DrawableCells); + Assert.Equal(1, environment.ClearCount); + } + + [Fact] + public void Required_sources_fail_fast() + { + var camera = new RecordingCamera([], default); + var visibility = new RecordingVisibility([]); + var settings = new RecordingSettings([]); + var roots = new RecordingRoots([], default); + var environment = new RecordingEnvironment([]); + var animated = new RecordingAnimated([], []); + var buildings = new RecordingBuildings([], default); + + Assert.Throws(() => + new WorldRenderFrameBuilder(null!, visibility, settings, roots, environment, animated, buildings)); + Assert.Throws(() => + new WorldRenderFrameBuilder(camera, null!, settings, roots, environment, animated, buildings)); + Assert.Throws(() => + new WorldRenderFrameBuilder(camera, visibility, null!, roots, environment, animated, buildings)); + Assert.Throws(() => + new WorldRenderFrameBuilder(camera, visibility, settings, null!, environment, animated, buildings)); + Assert.Throws(() => + new WorldRenderFrameBuilder(camera, visibility, settings, roots, null!, animated, buildings)); + Assert.Throws(() => + new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, null!, buildings)); + Assert.Throws(() => + new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, animated, null!)); + } + + [Fact] + public void Render_range_state_updates_both_tiers_without_replacing_the_source() + { + IWorldRenderRangeSource source = new WorldRenderRangeState(4, 12); + var mutable = Assert.IsType(source); + + mutable.NearRadius = 7; + mutable.FarRadius = 19; + + Assert.Equal(7, source.NearRadius); + Assert.Equal(19, source.FarRadius); + } + + [Fact] + public void Runtime_animated_source_reuses_scratch_and_removes_stale_ids() + { + var slot = new LiveEntityRuntimeSlot(); + var live = new LiveEntityAnimationRuntimeView(slot); + var source = new RuntimeWorldFrameAnimatedEntitySource( + live, + statics: null, + equipped: null); + + HashSet first = source.Capture(); + first.Add(0xDEADBEEFu); + HashSet second = source.Capture(); + + Assert.Same(first, second); + Assert.Empty(second); + } + + [Fact] + public void Runtime_building_source_reuses_scratch_and_rebuilds_outdoor_root() + { + var pipeline = new LandblockPresentationPipeline( + publishBeforeSpatialCommit: (_, _) => { }, + state: new GpuWorldState()); + var source = new RuntimeWorldFrameBuildingSource( + pipeline, + new CellVisibility()); + FrustumPlanes frustum = default; + + WorldBuildingFrame first = source.Gather( + viewerRoot: null, + viewerCellId: 0x01010001u, + in frustum); + var scratch = Assert.IsType>(first.NearbyBuildingCells); + scratch.Add(new LoadedCell { CellId = 0x01010100u }); + WorldBuildingFrame second = source.Gather( + viewerRoot: null, + viewerCellId: 0x02020001u, + in frustum); + + Assert.Same(scratch, second.NearbyBuildingCells); + Assert.Empty(second.NearbyBuildingCells); + Assert.Equal(0x01010001u, first.OutdoorNode?.CellId); + Assert.Equal(0x02020001u, second.OutdoorNode?.CellId); + } + + [Fact] + public void Runtime_root_source_keeps_player_lighting_and_viewer_render_cells_distinct() + { + const uint playerCellId = 0x01010100u; + const uint viewerCellId = 0x01010101u; + var physics = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + physics.DataCache.CellGraph.Add(CoreCell(playerCellId, seenOutside: false)); + physics.UpdatePlayerCurrCell(playerCellId); + var cells = new CellVisibility(); + var playerCell = new LoadedCell { CellId = playerCellId, SeenOutside = false }; + var viewerCell = new LoadedCell { CellId = viewerCellId, SeenOutside = true }; + cells.AddCell(playerCell); + cells.AddCell(viewerCell); + var retailChase = new RetailChaseCamera(); + retailChase.Update( + playerPosition: Vector3.Zero, + playerYaw: 0f, + playerVelocity: Vector3.Zero, + isOnGround: true, + contactPlaneNormal: Vector3.UnitZ, + dt: 1f / 60f, + cellId: viewerCellId); + var mode = new LocalPlayerModeState { IsPlayerMode = true }; + var chase = new ChaseCameraInputState { Retail = retailChase }; + var origin = new LiveWorldOriginState(); + origin.SetPlaceholder(0x01, 0x01); + var source = new RuntimeWorldFrameRootSource( + physics, + cells, + mode, + chase, + new LocalPlayerControllerSlot(), + origin); + var camera = new FlyCamera { Position = new Vector3(5f, 6f, 7f) }; + WorldCameraFrame cameraFrame = CameraFrame(camera); + bool previousRetailCamera = CameraDiagnostics.UseRetailChaseCamera; + + try + { + CameraDiagnostics.UseRetailChaseCamera = true; + WorldRootFrame result = source.Resolve(in cameraFrame); + + Assert.Same(playerCell, result.PlayerRoot); + Assert.Same(viewerCell, result.ViewerRoot); + Assert.True(result.PlayerInsideCell); + Assert.True(result.CameraInsideCell); + Assert.False(result.PlayerSeenOutside); + Assert.True(result.RootSeenOutside); + Assert.True(result.RenderSky); + Assert.Equal(playerCellId, result.PlayerCellId); + Assert.Equal(viewerCellId, result.ViewerCellId); + Assert.Equal(camera.Position, result.ViewerEyePosition); + } + finally + { + CameraDiagnostics.UseRetailChaseCamera = previousRetailCamera; + } + } + + [Fact] + public void Runtime_root_source_preserves_null_root_fallback_before_player_membership() + { + var physics = new PhysicsEngine(); + var source = new RuntimeWorldFrameRootSource( + physics, + new CellVisibility(), + new LocalPlayerModeState(), + new ChaseCameraInputState(), + new LocalPlayerControllerSlot(), + new LiveWorldOriginState()); + WorldCameraFrame camera = CameraFrame(new FlyCamera()); + + WorldRootFrame result = source.Resolve(in camera); + + Assert.Null(result.PlayerRoot); + Assert.Null(result.ViewerRoot); + Assert.Equal(0u, result.PlayerCellId); + Assert.Equal(0u, result.ViewerCellId); + Assert.False(result.PlayerInsideCell); + Assert.False(result.CameraInsideCell); + Assert.True(result.RenderSky); + } + + [Fact] + public void Runtime_environment_copies_visible_cells_and_clear_restores_unscoped_lights() + { + const uint visibleCell = 0x01010100u; + const uint hiddenCell = 0x01010101u; + var lighting = new LightManager(); + var visibleLight = new LightSource + { + Kind = LightKind.Point, + CellId = visibleCell, + }; + var hiddenLight = new LightSource + { + Kind = LightKind.Point, + CellId = hiddenCell, + }; + lighting.Register(visibleLight); + lighting.Register(hiddenLight); + var environment = new RuntimeWorldFrameEnvironmentPreparation( + RuntimeOptions.Parse("test-dat", _ => null), + new WorldTimeService(SkyStateProvider.Default()), + lighting, + dispatcher: null, + environmentCells: null, + lightingUbo: null, + new WorldRenderRangeState(4, 12), + skyPes: null); + var borrowed = new HashSet { visibleCell }; + environment.ObserveDrawableCells(borrowed); + borrowed.Clear(); + borrowed.Add(hiddenCell); + WorldCameraFrame camera = CameraFrame(new FlyCamera()); + WorldRootFrame roots = default; + RenderFrameFoundation foundation = default; + + environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null); + + Assert.Contains(visibleLight, lighting.PointSnapshot); + Assert.DoesNotContain(hiddenLight, lighting.PointSnapshot); + + environment.ClearDrawableCells(); + environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null); + + Assert.Contains(visibleLight, lighting.PointSnapshot); + Assert.Contains(hiddenLight, lighting.PointSnapshot); + } + + [Fact] + public void Production_builder_preserves_the_frame_preparation_order() + { + string source = BuilderSource(); + + AssertAppearsInOrder( + source, + "WorldCameraFrame camera = _camera.Resolve();", + "_visibility.Begin(in camera, waitingForLogin);", + "_settings.Apply(in camera);", + "WorldRootFrame roots = _roots.Resolve(in camera);", + "_environment.Prepare(in camera, in roots, in foundation, activeDayGroup);", + "_visibility.PublishViewProjection(in camera);", + "HashSet animated = _animated.Capture();", + "WorldBuildingFrame buildings = _buildings.Gather("); + } + + [Fact] + public void Environment_preparation_keeps_lighting_snapshot_before_ubo_upload() + { + string source = BuilderSource(); + + AssertAppearsInOrder( + source, + "UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);", + "_lighting.UpdateViewerLight(roots.PlayerViewPosition);", + "_lighting.Tick(camera.Position);", + "_lighting.BuildPointLightSnapshot(", + "_dispatcher?.SetSceneLights(_lighting.PointSnapshot);", + "_environmentCells?.SetPointSnapshot(_lighting.PointSnapshot);", + "SceneLightingUbo ubo = SceneLightingUbo.Build(", + "_lightingUbo?.Upload(ubo);", + "RenderingDiagnostics.EmitLight("); + } + + [Fact] + public void Game_window_uses_the_typed_builder_and_releases_it_before_render_owners() + { + string source = GameWindowSource(); + + Assert.Equal( + 1, + CountOccurrences(source, "_worldRenderFrameBuilder!.Build(")); + Assert.DoesNotContain("private void UpdateSunFromSky(", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void UpdateSkyPes(", source, StringComparison.Ordinal); + Assert.DoesNotContain("private static float ParseEnvFloat(", source, StringComparison.Ordinal); + AssertAppearsInOrder( + source, + "if (_cameraController is not null && !portalViewportVisible)", + "_retailAlphaQueue.BeginFrame();", + "_worldRenderFrameBuilder!.Build(", + "if (IsLiveModeWaitingForLogin)", + "normalWorldDrawn = true;"); + AssertAppearsInOrder( + source, + "_worldRenderFrameBuilder = null;", + "_skyPesFrame = null;", + "new(\"equipped children\"", + "new(\"effect network state\"", + "new(\"audio\"", + "new(\"mesh draw dispatcher\"", + "new(\"environment cells\"", + "new(\"scene lighting\""); + } + + private static string BuilderSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "WorldRenderFrameBuilder.cs")); + + private static WorldCameraFrame CameraFrame(FlyCamera camera) => new( + camera, + camera.Projection, + camera.View * camera.Projection, + default, + Matrix4x4.Identity, + camera.Position); + + private static AcDream.Core.World.Cells.EnvCell CoreCell( + uint id, + bool seenOutside) => new( + id, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector3.Zero, + Vector3.One, + Array.Empty(), + Array.Empty(), + seenOutside, + containmentBsp: null); + + private static string GameWindowSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + private static int CountOccurrences(string source, string needle) + { + int count = 0; + int cursor = 0; + while ((cursor = source.IndexOf(needle, cursor, StringComparison.Ordinal)) >= 0) + { + count++; + cursor += needle.Length; + } + + return count; + } + + private static void AssertAppearsInOrder(string source, params string[] needles) + { + int cursor = -1; + foreach (string needle in needles) + { + int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal); + Assert.True(next >= 0, $"Missing expected source fragment: {needle}"); + Assert.True(next > cursor, $"Out-of-order source fragment: {needle}"); + cursor = next; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } + + private sealed class RecordingCamera( + List calls, + WorldCameraFrame result) : IWorldFrameCameraSource + { + public WorldCameraFrame Resolve() + { + calls.Add("camera"); + return result; + } + } + + private sealed class RecordingVisibility(List calls) + : IWorldFrameVisibilityPreparation + { + public bool WaitingForLogin { get; private set; } + + public void Begin(in WorldCameraFrame camera, bool waitingForLogin) + { + calls.Add("visibility:begin"); + WaitingForLogin = waitingForLogin; + } + + public void PublishViewProjection(in WorldCameraFrame camera) => + calls.Add("visibility:projection"); + } + + private sealed class RecordingSettings(List calls) + : IWorldFrameSettingsPreview + { + public void Apply(in WorldCameraFrame camera) => calls.Add("settings"); + } + + private sealed class RecordingRoots( + List calls, + WorldRootFrame result) : IWorldFrameRootSource + { + public WorldRootFrame Resolve(in WorldCameraFrame camera) + { + calls.Add("roots"); + return result; + } + } + + private sealed class RecordingEnvironment(List calls) + : IWorldFrameEnvironmentPreparation + { + public RenderFrameFoundation Foundation { get; private set; } + + public IReadOnlySet? DrawableCells { get; private set; } + + public int ClearCount { get; private set; } + + public void Prepare( + in WorldCameraFrame camera, + in WorldRootFrame roots, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup) + { + calls.Add("environment"); + Foundation = foundation; + } + + public void ObserveDrawableCells(IReadOnlySet drawableCells) => + DrawableCells = drawableCells; + + public void ClearDrawableCells() => ClearCount++; + } + + private sealed class RecordingAnimated( + List calls, + HashSet result) : IWorldFrameAnimatedEntitySource + { + public HashSet Capture() + { + calls.Add("animated"); + return result; + } + } + + private sealed class RecordingBuildings( + List calls, + WorldBuildingFrame result) : IWorldFrameBuildingSource + { + public WorldBuildingFrame Gather( + LoadedCell? viewerRoot, + uint viewerCellId, + in FrustumPlanes frustum) + { + calls.Add("buildings"); + return result; + } + } +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 6aecc398..86898d81 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -43,6 +43,8 @@ public sealed class RuntimeOptionsTests Assert.False(opts.UiProbeDump); Assert.Null(opts.UiProbeScript); Assert.Null(opts.AutomationArtifactDirectory); + Assert.Equal(0.7f, opts.FogStartMultiplier); + Assert.Equal(0.95f, opts.FogEndMultiplier); Assert.False(opts.UiProbeEnabled); Assert.False(opts.HasLiveCredentials); } @@ -180,6 +182,26 @@ public sealed class RuntimeOptionsTests Assert.Equal(12, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "12" })).LegacyStreamRadius); } + [Fact] + public void FogMultipliers_ParseInvariantFloats_AndFallBackIndependently() + { + var parsed = RuntimeOptions.Parse(AnyDatDir, Env(new() + { + ["ACDREAM_FOG_START_MULT"] = "0.625", + ["ACDREAM_FOG_END_MULT"] = "1.125", + })); + Assert.Equal(0.625f, parsed.FogStartMultiplier); + Assert.Equal(1.125f, parsed.FogEndMultiplier); + + var invalid = RuntimeOptions.Parse(AnyDatDir, Env(new() + { + ["ACDREAM_FOG_START_MULT"] = "not-a-number", + ["ACDREAM_FOG_END_MULT"] = "", + })); + Assert.Equal(0.7f, invalid.FogStartMultiplier); + Assert.Equal(0.95f, invalid.FogEndMultiplier); + } + [Fact] public void DiagnosticFlags_RespectExactValueOne() { From 85239fb3733c0c27c020cd65e797f42c6f8255e9 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 06:40:09 +0200 Subject: [PATCH 86/98] refactor(render): extract typed retail pview passes --- .../retail-divergence-register.md | 4 +- ...ndow-slice-7-render-frame-orchestration.md | 18 +- src/AcDream.App/Rendering/GameWindow.cs | 542 ++-------------- .../Rendering/PortalDepthMaskRenderer.cs | 2 +- .../Rendering/RetailPViewPassExecutor.cs | 576 ++++++++++++++++++ .../Rendering/RetailPViewRenderer.cs | 519 +++++----------- .../TerrainDrawDiagnosticsController.cs | 148 +++++ .../Rendering/WorldRenderDiagnostics.cs | 189 ++++++ .../Rendering/RenderingDiagnostics.cs | 4 +- .../GameWindowRenderLeafCompositionTests.cs | 20 +- .../Rendering/HouseExitWalkReplayTests.cs | 14 +- .../Rendering/RetailPViewPassExecutorTests.cs | 540 ++++++++++++++++ .../TerrainDrawDiagnosticsControllerTests.cs | 153 +++++ 13 files changed, 1888 insertions(+), 841 deletions(-) create mode 100644 src/AcDream.App/Rendering/RetailPViewPassExecutor.cs create mode 100644 src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index df6dddc3..5f8ee709 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -141,9 +141,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 | | AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1110` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | -| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | +| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`ShellDrawLiftZ`); `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`DrawPortalDepthWrite`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | -| AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewRenderer.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 | +| AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewPassExecutor.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 | | AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.UpdateSunFromSky`) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) | | AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md index c99749cf..cfbaaa50 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md +++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md @@ -24,7 +24,7 @@ port, or resource-lifetime redesign. - [x] D — extract frame-data classification and reusable world scratch without duplicating live identity, spatial, visibility, or resource ownership; then run the dependent audio, sky, lighting, and listener preparation. -- [ ] E — replace `RetailPViewDrawContext`'s callback bag with a typed +- [x] E — replace `RetailPViewDrawContext`'s callback bag with a typed `RetailPViewPassExecutor` and data-only inputs. - [ ] F — extract `WorldSceneRenderer`, including both shared-alpha scopes, PView/fallback world branches, particles, debug world draw, and completion. @@ -426,6 +426,22 @@ Retail, architecture, and adversarial corrected-diff reviews are clean. - verify no executor operation rebuilds portal visibility or retains a borrowed frame result across calls. +Completed 2026-07-22. `RetailPViewRenderer` is now the retail visibility and +ordering owner only; `RetailPViewPassExecutor` owns the concrete clip upload, +EnvCell/entity, landscape, portal depth, particle, alpha, and diagnostic pass +implementations. The old callback bag and broad window closures are gone. +`WorldRenderDiagnostics` now owns every PView print-on-change signature and +probe sink, while a focused terrain-diagnostics controller preserves the +shared five-second terrain/frame publication transaction across fallback and +PView draws. Real `DrawInside` fixtures cover outdoor, interior-exit, +no-outside, entity/static/dynamic particle, transparent-shell, look-in, and +second-call scratch-reset behavior; structural tests pin concrete forwarding, +no delegate/back-reference leakage, no borrowed frame/result retention, and +early shutdown withdrawal. `GameWindow.cs` is 5,303 raw lines, 66.3% below the +15,723-line campaign baseline. Release build and the complete suite pass +(7,295 passed / 5 skipped). Retail, architecture, and adversarial +corrected-diff reviews are clean. + ### F — world scene owner - extract alpha/selection/particle-visibility begin/end and the normal-world diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e3bc6f5c..85c3458f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -83,7 +83,6 @@ public sealed class GameWindow : IDisposable System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", System.StringComparison.Ordinal); - private long _frameDiagLastPublicationMilliseconds; // MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call // per OnRender + three stage scopes. All logic lives in @@ -224,6 +223,10 @@ public sealed class GameWindow : IDisposable // _interiorRenderer is constructed once both renderers exist; _interiorPartition is rebuilt // each frame on an indoor root (null on the outdoor root). private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; + private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor; + private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells; + private AcDream.App.Rendering.TerrainDrawDiagnosticsController? + _terrainDrawDiagnostics; private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel; private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition; @@ -237,11 +240,10 @@ public sealed class GameWindow : IDisposable // U.4 replaces the NoClip() frame with one built from the portal-visibility result. private ClipFrame? _clipFrame; - // PView pass-local particle classifications move with the pass executor in - // Checkpoint E; GameWindow still owns them until that cutover. - private readonly HashSet _outdoorSceneParticleEntityIds = new(); - private readonly HashSet _visibleSceneParticleEntityIds = new(); - private readonly HashSet _noSceneParticleEntityIds = new(); + // Flat-world fallback particle scratch. PView classifications belong to + // RetailPViewPassExecutor. + private readonly HashSet _visibleSceneParticleEntityIds = []; + private readonly HashSet _noSceneParticleEntityIds = []; /// /// Phase 6.4: per-entity animation playback state for entities whose @@ -2311,8 +2313,7 @@ public sealed class GameWindow : IDisposable landblockRenderPublisher.PrepareAfterRenderPins); _clipFrame ??= ClipFrame.NoClip(); - _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( - _gl, _clipFrame, _envCellRenderer!, _wbDrawDispatcher!); + _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(); // T1: invisible portal depth writes (seal/punch) — retail // DrawPortalPolyInternal (Ghidra 0x0059bc90). @@ -2892,6 +2893,37 @@ public sealed class GameWindow : IDisposable new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource( _landblockPresentationPipeline!, _cellVisibility)); + _terrainDrawDiagnostics = + new AcDream.App.Rendering.TerrainDrawDiagnosticsController( + _frameDiag, + _worldRenderDiagnostics!, + new AcDream.App.Rendering.RuntimeFramePipelineDiagnosticFactsSource( + _terrain, + _landblockPresentationPipeline, + _renderFrameLivePreparation, + _wbDrawDispatcher, + _streamingController, + _liveEntities!, + _worldState), + _renderDiagnosticLog); + _retailPViewCells = new AcDream.App.Rendering.RetailPViewCellSource( + _cellVisibility); + _retailPViewPassExecutor = + new AcDream.App.Rendering.RetailPViewPassExecutor( + _gl!, + new AcDream.App.Rendering.SilkRetailPViewFramebufferSource( + _window!), + _clipFrame!, + _terrain, + _envCellRenderer!, + _wbDrawDispatcher!, + _skyRenderer, + _particleSystem, + _particleRenderer, + _portalDepthMask, + _retailAlphaQueue, + _worldRenderDiagnostics!, + _terrainDrawDiagnostics); var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( liveObjectFrame, _worldState, @@ -3527,7 +3559,6 @@ public sealed class GameWindow : IDisposable int sigOutdoorRootObjectCount = 0; int sigLiveDynamicDrawnCount = 0; string sigSceneParticles = "none"; - _outdoorSceneParticleEntityIds.Clear(); _visibleSceneParticleEntityIds.Clear(); // Retail entry ownership: GameWindow never builds a second indoor PView product. // Outdoor frames begin no-clip; indoor frames skip the global landscape block and let @@ -3591,10 +3622,10 @@ public sealed class GameWindow : IDisposable // Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup. EnableClipDistances(); - _worldRenderDiagnostics?.BeginTerrainDraw(); + _terrainDrawDiagnostics!.Begin(); sigTerrainDrawn = true; _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); - PublishTerrainDrawDiagnostics(); + _terrainDrawDiagnostics.Complete(); } @@ -3617,7 +3648,7 @@ public sealed class GameWindow : IDisposable if (_retailPViewRenderer is null) throw new InvalidOperationException("Retail PView renderer is required for indoor frames."); - var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext + var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewFrameInput { RootCell = clipRoot, // R-A2: outdoor root floods each nearby building per-building (not via the root). @@ -3626,7 +3657,7 @@ public sealed class GameWindow : IDisposable NearbyBuildingCells = nearbyBuildingCells, ViewerEyePos = viewerEyePos, ViewProjection = envCellViewProj, - CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, + Cells = _retailPViewCells!, Camera = camera, CameraWorldPosition = camPos, // (#176 correction: the former RebuildScopedLights callback — @@ -3640,94 +3671,20 @@ public sealed class GameWindow : IDisposable RenderCenterLbY = renderCenterLbY, RenderRadius = _nearRadius, LandblockEntries = _worldState.LandblockEntries, - SetTerrainClipUbo = binding => _terrain?.SetClipUbo(binding), - DrawLandscapeSlice = sliceCtx => - DrawRetailPViewLandscapeSlice( - sliceCtx, - camera, - frustum, - camPos, - playerLb, - animatedIds, - renderSky, - renderWeather: playerSeenOutside, - kf, - environOverrideActive), - // #131/#132: the late phase — dynamics meshes + scene - // particles + weather AFTER the look-ins (FlushAlphaList - // deferral). - DrawLandscapeSliceLate = lateCtx => - DrawRetailPViewLandscapeSliceLate( - lateCtx, - camera, - frustum, - camPos, - playerLb, - animatedIds, - renderSky, - renderWeather: playerSeenOutside, - kf, - environOverrideActive, - isOutdoorRoot: clipRoot.IsOutdoorNode), - // T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840). - // INTERIOR roots: one FULL depth clear between the outside stage and - // the interior stage, then SEALS re-stamp every outside-leading - // portal's TRUE depth (#108's protective mechanism). OUTDOOR roots: - // no clear (the world's depth must survive) — instead each flooded - // building's entry aperture gets a far-Z PUNCH so its interior shows - // through the doorway. Both are safe ONLY because dynamics draw LAST - // (DrawDynamicsLast) — the first BR-2 attempt punched after dynamics - // and erased the player (reverted 88be519). - ClearDepthForInterior = clipRoot.IsOutdoorNode - ? null - : () => - { - gl.Disable(EnableCap.ScissorTest); - gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson) - gl.Clear(ClearBufferMask.DepthBufferBit); - }, - DrawExitPortalMasks = sliceCtx => - DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, - forceFarZ: clipRoot.IsOutdoorNode), - // #124: look-in apertures are ALWAYS the punch (retail - // maxZ1), independent of the root-keyed selector above. - DrawLookInPortalPunch = sliceCtx => - DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, - forceFarZ: true), - // #131: unattached emitters under an interior root — the - // landscape-stage pass (the outdoor T3 pass below is gated - // IsOutdoorNode, so the two never both run). - DrawUnattachedSceneParticles = () => - { - if (_particleSystem is null || _particleRenderer is null) - return; - DisableClipDistances(); - _particleRenderer.DrawForOwners( - camera, - camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene, - _noSceneParticleEntityIds, - includeUnattached: true); - }, - FlushLandscapeAlpha = _retailAlphaQueue.Flush, - DrawCellParticles = sliceCtx => - DrawRetailPViewCellParticles(sliceCtx, camera, camPos), - DrawDynamicsParticles = survivors => - DrawRetailPViewDynamicsParticles(survivors, camera, camPos), - EmitDiagnostics = result => - _worldRenderDiagnostics?.EmitRetailPViewDiagnostics( - AcDream.Core.Rendering.RenderingDiagnostics.ProbeViewerEnabled, - AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled, - AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled, - result, - clipRoot, - viewerCellId, - playerCellId, - camPos, - playerViewPos, - camera.View, - _cellVisibility.LastCameraCellResolution), - }); + RenderSky = renderSky, + RenderWeather = playerSeenOutside, + DayFraction = (float)WorldTime.DayFraction, + ActiveDayGroup = _activeDayGroup, + SkyKeyframe = kf, + EnvironOverrideActive = environOverrideActive, + ViewerCellId = viewerCellId, + PlayerCellId = playerCellId, + PlayerViewPosition = playerViewPos, + CameraView = camera.View, + CameraCellResolution = _cellVisibility.LastCameraCellResolution, + }, _retailPViewPassExecutor + ?? throw new InvalidOperationException( + "Retail PView pass executor is required for indoor frames.")); pvFrame = pviewResult.PortalFrame; clipAssembly = pviewResult.ClipAssembly; envCellShellFilter = pviewResult.DrawableCells; @@ -3833,7 +3790,7 @@ public sealed class GameWindow : IDisposable AcDream.Core.Vfx.ParticleRenderPass.Scene, _visibleSceneParticleEntityIds, includeUnattached: true, - excludedAttachedOwnerIds: _outdoorSceneParticleEntityIds); + excludedAttachedOwnerIds: _noSceneParticleEntityIds); } else { @@ -3865,13 +3822,13 @@ public sealed class GameWindow : IDisposable camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, - _outdoorSceneParticleEntityIds, + _retailPViewPassExecutor!.OutdoorSceneParticleEntityIds, includeUnattached: true); } // Bug A fix (post-#26 worktree, 2026-04-26): weather sky // Outdoor LScape post-scene weather. Indoor weather through an exit portal is - // drawn by RetailPViewRenderer.DrawInside via DrawRetailPViewLandscapeSlice. + // drawn by RetailPViewRenderer.DrawInside via RetailPViewPassExecutor. if (clipRoot is null && drawSkyThisFrame) { sigSkyDrawn = true; @@ -4257,308 +4214,6 @@ public sealed class GameWindow : IDisposable } } - private void DrawRetailPViewLandscapeSlice( - AcDream.App.Rendering.RetailPViewLandscapeSliceContext sliceCtx, - ICamera camera, - FrustumPlanes? frustum, - System.Numerics.Vector3 camPos, - uint? playerLb, - HashSet? animatedIds, - bool renderSky, - bool renderWeather, - AcDream.Core.World.SkyKeyframe kf, - bool environOverrideActive) - { - var slice = sliceCtx.Slice; - bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); - _worldRenderDiagnostics?.EmitClipRouteScissorProbe( - AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled, - scissor, - slice.NdcAabb); - - _clipFrame!.BindTerrainClip(_gl!); - - EnableClipDistances(); - if (renderSky) - _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf, environOverrideActive); - - DisableClipDistances(); - if (renderSky && _particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(camera, camPos, - AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); - - EnableClipDistances(); - _worldRenderDiagnostics?.BeginTerrainDraw(); - _terrain?.Draw( - camera, - frustum, - neverCullLandblockId: playerLb, - clipPlanes: slice.Planes, - ndcClipAabb: slice.NdcAabb); - PublishTerrainDrawDiagnostics(); - - // T3 (BR-5): entities draw OUTSIDE the clip bracket — retail meshes - // are viewcone-CHECKED, never hard-clipped (Ghidra 0x0054c250); the - // sphere pre-filter already ran in RetailPViewRenderer (OutdoorEntities - // is the per-slice survivor set). - DisableClipDistances(); - if (sliceCtx.OutdoorEntities.Count > 0) - { - var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, - sliceCtx.OutdoorEntities, - (IReadOnlyDictionary?)null); - _wbDrawDispatcher!.Draw(camera, new[] { sceneryEntry }, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: null, - animatedEntityIds: animatedIds); - } - - // #131/#132: scene particles + weather MOVED to the LATE phase - // (DrawRetailPViewLandscapeSliceLate) — they must composite AFTER the - // #124 look-ins (retail's FlushAlphaList deferral, DrawCells - // pc:432722); drawn here they were overpainted by far-building - // interiors wherever a look-in aperture sat behind them. - - if (scissor) - _gl!.Disable(EnableCap.ScissorTest); - - DisableClipDistances(); - } - - // #131/#132: the LATE landscape phase — per slice, invoked by the renderer - // AFTER the #124 look-in sub-pass, still pre-clear. Outside-stage - // dynamics' meshes (a translucent portal swirl blends over a far interior - // instead of being overpainted by it — translucents write no depth to - // protect themselves) + ALL attached scene particles (statics' flames - // included — the #132 candle) + weather. Retail equivalent: alpha draws - // collected during LScape::draw flush ONCE after it - // (D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722). - private void DrawRetailPViewLandscapeSliceLate( - AcDream.App.Rendering.RetailPViewLandscapeLateSliceContext lateCtx, - ICamera camera, - FrustumPlanes? frustum, - System.Numerics.Vector3 camPos, - uint? playerLb, - HashSet? animatedIds, - bool renderSky, - bool renderWeather, - AcDream.Core.World.SkyKeyframe kf, - bool environOverrideActive, - bool isOutdoorRoot) - { - var slice = lateCtx.Slice; - bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); - - _clipFrame!.BindTerrainClip(_gl!); - - // Outside-stage dynamics' meshes — viewcone pre-filtered by the - // renderer, never hard-clipped (T3). - DisableClipDistances(); - if (lateCtx.Dynamics.Count > 0) - { - var dynamicsEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, - lateCtx.Dynamics, - (IReadOnlyDictionary?)null); - _wbDrawDispatcher!.Draw(camera, new[] { dynamicsEntry }, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: null, - animatedEntityIds: animatedIds); - } - - _outdoorSceneParticleEntityIds.Clear(); - foreach (var entity in lateCtx.ParticleOwners) - _outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity)); - - // #131 [outstage-pt] probe: the slice Scene-particle id set + how many - // live emitters the filter would actually match, plus the distinct - // UNMATCHED attached owner ids (the portal-identification handle — - // an emitter whose owner never lands in the set draws nowhere - // indoors). Print-on-change. - _worldRenderDiagnostics?.EmitOutStageParticles( - AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled, - _particleSystem, - _outdoorSceneParticleEntityIds); - - // #132 outdoor sibling: under an OUTDOOR root the merged building - // interiors draw AFTER this stage (DrawEnvCellShells) — a flame drawn - // here is overpainted whenever a punched aperture sits behind it - // (user-confirmed at the outdoor candle). Outdoor roots therefore - // SKIP the slice Scene pass and draw attached scene particles in the - // post-frame pass alongside the T3 unattached pass (the id set built - // above carries over — the outdoor root has a single full-screen - // slice). Interior roots draw here: the look-ins already ran and the - // post-clear seal discipline owns the rest of the frame. - if (!isOutdoorRoot - && _outdoorSceneParticleEntityIds.Count > 0 - && _particleSystem is not null - && _particleRenderer is not null) - { - _particleRenderer.DrawForOwners( - camera, - camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene, - _outdoorSceneParticleEntityIds); - } - - // T3 (BR-5): weather gates on the PLAYER being outside, not the viewer - // root — retail draws weather only when is_player_outside (the rain - // cylinder rides the player; an indoor player gets NO rain even while - // looking out a doorway). Closes the rain-through-doorways divergence - // (weather-gate-player-vs-viewer, adjusted-confirmed). - EnableClipDistances(); - if (renderSky && renderWeather) - { - _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf, environOverrideActive); - DisableClipDistances(); - if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(camera, camPos, - AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); - } - else - { - DisableClipDistances(); - } - - if (scissor) - _gl!.Disable(EnableCap.ScissorTest); - - DisableClipDistances(); - } - - // T1: retail's invisible portal depth writes on every outside-leading - // portal (other_cell_id==0xFFFF) of this cell, clipped to the slice's view - // region — D3DPolyRender::DrawPortalPolyInternal (Ghidra 0x0059bc90), - // dispatched by PView::DrawCells (Ghidra 0x005a4840). forceFarZ is - // retail's maxZ1(true)/maxZ2(false) selector: - // • INTERIOR root (false → SEAL, true depth): after the full depth clear, - // stamp the door plane so interior geometry beyond the door z-fails - // inside the aperture and the terrain drawn through the outside view - // keeps its pixels (#108's protective mechanism). - // • OUTDOOR root / look-in (true → PUNCH, far depth): erase the world's - // depth inside a flooded building's entry aperture so the interior - // drawn next shows THROUGH the doorway. - // Both are safe ONLY because dynamics draw last (DrawDynamicsLast) — the - // first BR-2 attempt punched after dynamics and erased the player - // (reverted 88be519). Wiring only — the draw lives in - // PortalDepthMaskRenderer. - private void DrawRetailPViewPortalDepthWrite( - AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx, - System.Numerics.Matrix4x4 viewProjection, - bool forceFarZ) - { - if (_portalDepthMask is null) - return; - if (!_cellVisibility.TryGetCell(sliceCtx.CellId, out var cell) || cell is null) - return; - - Span world = stackalloc System.Numerics.Vector3[32]; - for (int i = 0; i < cell.Portals.Count; i++) - { - if (cell.Portals[i].OtherCellId != 0xFFFF) - continue; // depth writes apply to portals leading OUTSIDE only - if (i >= cell.PortalPolygons.Count) - break; - var localVerts = cell.PortalPolygons[i]; - if (localVerts.Length < 3) - continue; - - // cell.WorldTransform is the PHYSICS (unlifted) transform (f35cb8b); - // the shell that rasterizes this aperture draws +ShellDrawLiftZ - // higher. The seal/punch is a DRAW — stamp depth in the same lifted - // space or the stamp sits 2 cm below the drawn hole (#130 family). - int n = System.Math.Min(localVerts.Length, world.Length); - for (int v = 0; v < n; v++) - { - world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform); - world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ; - } - - // #176 seam-draw probe: a sealed dungeon must emit ZERO depth fans - // (only OtherCellId==0xFFFF portals reach here) — any line in a - // target cell is a finding (a depth stamp fighting the shell floor). - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled - && AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(sliceCtx.CellId)) - { - float seamZMin = float.MaxValue, seamZMax = float.MinValue; - for (int v = 0; v < n; v++) - { - seamZMin = System.Math.Min(seamZMin, world[v].Z); - seamZMax = System.Math.Max(seamZMax, world[v].Z); - } - Console.WriteLine(System.FormattableString.Invariant( - $"[seam-mask] t={System.Environment.TickCount64} cell=0x{sliceCtx.CellId:X8} portal={i} far={forceFarZ} n={n} z=[{seamZMin:F3},{seamZMax:F3}]")); - } - - _portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ); - } - } - - private void DrawRetailPViewCellParticles( - AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx, - ICamera camera, - System.Numerics.Vector3 camPos) - { - if (_particleSystem is null || _particleRenderer is null || sliceCtx.CellEntities.Count == 0) - return; - - _visibleSceneParticleEntityIds.Clear(); - foreach (var entity in sliceCtx.CellEntities) - _visibleSceneParticleEntityIds.Add(ParticleEntityKey(entity)); - - if (_visibleSceneParticleEntityIds.Count == 0) - return; - - // T3 (BR-5): the scissor-AABB gate is DELETED — retail gates particles - // like meshes (viewcone on the owner; depth does the pixels). The - // CellEntities set is already the cone-surviving owner list, so the - // id-predicate below IS the cone gate; the punch/seal depth discipline - // composites the pixels. - DisableClipDistances(); - _particleRenderer.DrawForOwners( - camera, - camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene, - _visibleSceneParticleEntityIds); - DisableClipDistances(); - } - - // #121: the dynamics-owner particle pass — Scene-pass emitters attached to - // the frame's cone-surviving dynamics (portal swirls on server-spawned - // portal entities, creature effects). Retail draws emitters with their - // owner object; before this pass, dynamics' emitters fell through every - // pview particle filter (landscape slice = outdoor statics + #118 - // outside-stage dynamics; cell callback = cell statics) once T4 deleted - // the clipRoot==null global pass from normal frames — every world portal - // went invisible. Mirror of DrawRetailPViewCellParticles with the - // survivors' ids as the filter. - private readonly HashSet _dynamicsSceneParticleEntityIds = new(); - - private void DrawRetailPViewDynamicsParticles( - IReadOnlyList survivors, - ICamera camera, - System.Numerics.Vector3 camPos) - { - if (_particleSystem is null || _particleRenderer is null || survivors.Count == 0) - return; - - _dynamicsSceneParticleEntityIds.Clear(); - foreach (var entity in survivors) - _dynamicsSceneParticleEntityIds.Add(ParticleEntityKey(entity)); - - if (_dynamicsSceneParticleEntityIds.Count == 0) - return; - - DisableClipDistances(); - _particleRenderer.DrawForOwners( - camera, - camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene, - _dynamicsSceneParticleEntityIds); - DisableClipDistances(); - } - private void EnableClipDistances() { for (int i = 0; i < ClipFrame.MaxPlanes; i++) @@ -4571,23 +4226,6 @@ public sealed class GameWindow : IDisposable _gl!.Disable(EnableCap.ClipDistance0 + i); } - // Phase W Stage 4: set a glScissor to an NDC AABB (the doorway / OutsideView region) in - // framebuffer pixels and enable the scissor test; returns true iff applied (the caller then - // disables EnableCap.ScissorTest after its draw/clear). Used to bracket the landscape slice - // (sky, terrain, statics, weather — particle.vert has no gl_ClipDistance). Returns false - // (no scissor) when not applied (outdoor / no window). The box is the CONSERVATIVE outer - // bound (NdcScissorRect): the previous Floor(origin)+Ceiling(size) form cut up to one pixel - // off the TOP/RIGHT edges at unlucky alignments — the #130 doorway top-edge background strip. - private bool BeginDoorwayScissor(bool apply, System.Numerics.Vector4 ndcAabb) - { - if (!apply || _window is null) return false; - var fb = _window.FramebufferSize; - var box = NdcScissorRect.ToPixels(ndcAabb, fb.X, fb.Y); - _gl!.Enable(EnableCap.ScissorTest); - _gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height); - return true; - } - // ── Phase I.2 — DebugPanel helpers ──────────────────────────────── // // The ImGui DebugPanel reads through DebugVM closures that ask @@ -5414,60 +5052,6 @@ public sealed class GameWindow : IDisposable } } - private void PublishTerrainDrawDiagnostics() - { - long now = _frameDiag ? Environment.TickCount64 : 0L; - _worldRenderDiagnostics?.EndTerrainDraw(); - if (!_frameDiag || now - _frameDiagLastPublicationMilliseconds <= 5000) - return; - - // Preserve the original one-timestamp diagnostic transaction. A - // failure in either write leaves the shared cadence uncommitted, so - // the next terrain draw retries both TERRAIN and FRAME diagnostics. - _worldRenderDiagnostics?.PublishTerrainDiagnostics( - new AcDream.App.Rendering.TerrainRenderDiagnosticFacts( - _terrain?.VisibleSlots ?? 0, - _terrain?.LoadedSlots ?? 0, - _terrain?.CapacitySlots ?? 0)); - PublishFrameDiagnostics(); - _frameDiagLastPublicationMilliseconds = now; - } - - private void PublishFrameDiagnostics() - { - AcDream.App.Streaming.LandblockPresentationDiagnostics presentation = - _landblockPresentationPipeline?.Diagnostics ?? default; - AcDream.App.Streaming.LandblockRenderPublisherDiagnostics render = - presentation.Render; - AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physics = - presentation.Physics; - AcDream.App.Streaming.LandblockStaticPresentationDiagnostics statics = - presentation.Statics; - double ticksToMicros = - 1_000_000.0 / System.Diagnostics.Stopwatch.Frequency; - AcDream.App.Rendering.RollingTimingPercentiles upload = - _renderFrameLivePreparation?.UploadTiming ?? default; - double uploadMedian = upload.MedianHundredthsMicroseconds / 100.0; - double uploadP95 = upload.Percentile95HundredthsMicroseconds / 100.0; - int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0; - _renderDiagnosticLog.WriteLine( - $"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} " + - $"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} " + - $"begin={render.BeginPublishTicks * ticksToMicros:F1} " + - $"complete={render.CompletePublishTicks * ticksToMicros:F1}] " + - $"physics_total_us=[base={physics.BasePublishTicks * ticksToMicros:F1} " + - $"gfx={physics.GfxCacheTicks * ticksToMicros:F1} " + - $"complete={physics.CompletePublishTicks * ticksToMicros:F1}] " + - $"static={statics.BeginCount}/{statics.CompleteCount}" + - $"(active={statics.ActiveEntityCount}) " + - $"entUpl_us={uploadMedian:F1}m/{uploadP95:F1}p95 " + - $"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " + - $"fullRetire={_streamingController?.FullWindowRetirementCount ?? 0}" + - $"(landblocks={_streamingController?.LastFullWindowRetirementLandblockCount ?? 0}) " + - $"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " + - $"resident={_worldState.Entities.Count} walked={walked}"); - } - private void OnClosing() => CompleteShutdown(); @@ -5521,6 +5105,10 @@ public sealed class GameWindow : IDisposable { _worldRenderFrameBuilder = null; _skyPesFrame = null; + _retailPViewPassExecutor = null; + _retailPViewCells = null; + _terrainDrawDiagnostics = null; + _retailPViewRenderer = null; }), ]), new ResourceShutdownStage("session dependents", diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs index d9c20c7f..00c4426c 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -12,7 +12,7 @@ namespace AcDream.App.Rendering; /// (Ghidra 0x0059bc90, pc:424490). /// /// Wired by T1 (BR-3, `579c8b0`): seal on interior roots, punch -/// on outdoor / look-in roots, via GameWindow.DrawRetailPViewPortalDepthWrite +/// on outdoor / look-in roots, via RetailPViewPassExecutor.DrawPortalDepthWrite /// (the DrawExitPortalMasks slice callback) — safe alongside the /// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after /// dynamics and erased the player; reverted 88be519). #117 (2026-06-11) diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs new file mode 100644 index 00000000..632fcd74 --- /dev/null +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -0,0 +1,576 @@ +using System.Numerics; +using AcDream.App.Rendering.Sky; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Rendering; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using Silk.NET.OpenGL; +using Silk.NET.Windowing; + +namespace AcDream.App.Rendering; + +internal readonly record struct RetailPViewFramebufferSize(int Width, int Height); + +internal interface IRetailPViewFramebufferSource +{ + RetailPViewFramebufferSize Capture(); +} + +internal sealed class SilkRetailPViewFramebufferSource(IWindow window) : + IRetailPViewFramebufferSource +{ + private readonly IWindow _window = window + ?? throw new ArgumentNullException(nameof(window)); + + public RetailPViewFramebufferSize Capture() + { + var size = _window.FramebufferSize; + return new RetailPViewFramebufferSize(size.X, size.Y); + } +} + +internal sealed class RetailPViewCellSource : IRetailPViewCellSource +{ + private readonly CellVisibility _cells; + + public RetailPViewCellSource(CellVisibility cells) => + _cells = cells ?? throw new ArgumentNullException(nameof(cells)); + + public LoadedCell? Find(uint cellId) => + _cells.TryGetCell(cellId, out LoadedCell? cell) ? cell : null; +} + +internal sealed class RetailPViewParticleClassifications +{ + private readonly HashSet _outdoor = []; + private readonly HashSet _visible = []; + private readonly HashSet _dynamics = []; + + public IReadOnlySet Outdoor => _outdoor; + public HashSet Visible => _visible; + public HashSet Dynamics => _dynamics; + + public void BeginFrame() + { + _outdoor.Clear(); + _visible.Clear(); + _dynamics.Clear(); + } + + public void ReplaceOutdoor(IReadOnlyList owners) + { + _outdoor.Clear(); + foreach (WorldEntity owner in owners) + _outdoor.Add(owner.Id); + } +} + +/// +/// Concrete GL implementation of the named passes ordered by +/// . It owns reusable pass-local particle +/// classifications but borrows every renderer and world source. +/// The order it implements is retail PView::DrawCells @ 0x005A4840: +/// landscape, delayed-alpha flush, optional interior depth clear, exit masks, +/// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather +/// placement follows LScape::draw @ 0x00506330. +/// +internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor +{ + private readonly GL _gl; + private readonly IRetailPViewFramebufferSource _framebuffer; + private readonly ClipFrame _clipFrame; + private readonly TerrainModernRenderer? _terrain; + private readonly EnvCellRenderer _envCells; + private readonly WbDrawDispatcher _entities; + private readonly SkyRenderer? _sky; + private readonly ParticleSystem? _particles; + private readonly ParticleRenderer? _particleRenderer; + private readonly PortalDepthMaskRenderer? _portalDepthMask; + private readonly RetailAlphaQueue _alpha; + private readonly WorldRenderDiagnostics _diagnostics; + private readonly TerrainDrawDiagnosticsController _terrainDiagnostics; + private readonly RetailPViewParticleClassifications _particleClassifications = new(); + private readonly HashSet _noSceneParticleEntityIds = []; + + /// + /// Borrowed until the next late landscape pass. The outdoor-root post-world + /// particle pass consumes this synchronously before another PView frame. + /// + public IReadOnlySet OutdoorSceneParticleEntityIds => + _particleClassifications.Outdoor; + + public RetailPViewPassExecutor( + GL gl, + IRetailPViewFramebufferSource framebuffer, + ClipFrame clipFrame, + TerrainModernRenderer? terrain, + EnvCellRenderer envCells, + WbDrawDispatcher entities, + SkyRenderer? sky, + ParticleSystem? particles, + ParticleRenderer? particleRenderer, + PortalDepthMaskRenderer? portalDepthMask, + RetailAlphaQueue alpha, + WorldRenderDiagnostics diagnostics, + TerrainDrawDiagnosticsController terrainDiagnostics) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _framebuffer = framebuffer + ?? throw new ArgumentNullException(nameof(framebuffer)); + _clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame)); + _terrain = terrain; + _envCells = envCells ?? throw new ArgumentNullException(nameof(envCells)); + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + _sky = sky; + _particles = particles; + _particleRenderer = particleRenderer; + _portalDepthMask = portalDepthMask; + _alpha = alpha ?? throw new ArgumentNullException(nameof(alpha)); + _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _terrainDiagnostics = terrainDiagnostics + ?? throw new ArgumentNullException(nameof(terrainDiagnostics)); + } + + public void BeginFrame() + { + _particleClassifications.BeginFrame(); + } + + public ClipFrameAssembly AssembleClipFrame( + PortalVisibilityFrame portalFrame, + ClipFrameAssembly reuseAssembly) => + ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly); + + public void PrepareClipFrame(int terrainUploadCount) + { + // Allocate every terrain record before issuing the first draw. BufferData + // must not replace the arena while an earlier slice can reference it. + _clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount); + _clipFrame.UploadRegions(_gl); + _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); + _envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); + UploadTerrainClip(); + } + + public void SetTerrainClip(ReadOnlySpan planes) + { + _clipFrame.SetTerrainClip(planes); + UploadTerrainClip(); + } + + public void ClearClipRouting() => _entities.ClearClipRouting(); + + public void UseIndoorMembershipOnlyRouting() + { + // Retail viewcone-checks meshes and draws whole cell shells. This clears + // any terrain-slice routing before the indoor membership passes. + _envCells.SetClipRouting(null); + _entities.ClearClipRouting(); + } + + public void PrepareCellBatches( + RetailPViewFrameInput frame, + HashSet visibleCellIds) => + _envCells.PrepareRenderBatches( + frame.ViewProjection, + frame.CameraWorldPosition, + filter: visibleCellIds, + centerLbX: frame.RenderCenterLbX, + centerLbY: frame.RenderCenterLbY, + renderRadius: frame.RenderRadius); + + public void DrawOpaqueCellShells(HashSet cellIds) => + _envCells.Render(WbRenderPass.Opaque, cellIds); + + public bool CellHasTransparentShell(uint cellId) => + _envCells.CellHasTransparent(cellId); + + public void DrawTransparentCellShells(HashSet cellIds) => + _envCells.Render(WbRenderPass.Transparent, cellIds); + + public void DrawTransparentCellShellsOrdered(IReadOnlyList cellIds) => + _envCells.RenderTransparentOrdered(cellIds); + + public void DrawEntityBucket( + RetailPViewFrameInput frame, + IReadOnlyList entities, + HashSet? visibleCellIds) + { + uint landblockId = frame.PlayerLandblockId ?? 0u; + var entry = ( + landblockId, + Vector3.Zero, + Vector3.Zero, + entities, + (IReadOnlyDictionary?)null); + + _entities.Draw( + frame.Camera, + new[] { entry }, + frame.Frustum, + neverCullLandblockId: frame.PlayerLandblockId, + visibleCellIds: visibleCellIds, + animatedEntityIds: frame.AnimatedEntityIds); + } + + public void EmitClipRouteProbe( + ClipFrameAssembly clipAssembly, + ClipViewSlice slice, + int sliceIndex) => + _diagnostics.EmitClipRouteProbe( + RenderingDiagnostics.ProbeClipRouteEnabled, + _clipFrame, + clipAssembly, + slice, + sliceIndex); + + public void EmitOutStageOwner( + WorldEntity entity, + Vector3 sphereCenter, + float sphereRadius, + int sliceIndex, + bool passed) => + _diagnostics.EmitOutStageOwner( + RenderingDiagnostics.ProbeOutStageEnabled, + RenderingDiagnostics.DumpEntitySourceIds, + entity, + sphereCenter, + sphereRadius, + sliceIndex, + passed); + + public void EmitOutStageRouting( + int sliceIndex, + IReadOnlyList entities, + ViewconeCuller viewcone) => + _diagnostics.EmitOutStageRouting( + RenderingDiagnostics.ProbeOutStageEnabled, + sliceIndex, + entities, + viewcone); + + public void EmitPhantomObjects(uint cellId, int survivorCount) => + _diagnostics.EmitPhantomObjects( + RenderingDiagnostics.ProbePhantomEnabled, + cellId, + survivorCount); + + public void DrawLandscapeSlice( + RetailPViewFrameInput frame, + RetailPViewLandscapeSliceContext context) + { + ClipViewSlice slice = context.Slice; + bool scissor = BeginDoorwayScissor(slice.NdcAabb); + _diagnostics.EmitClipRouteScissorProbe( + RenderingDiagnostics.ProbeClipRouteEnabled, + scissor, + slice.NdcAabb); + + _clipFrame.BindTerrainClip(_gl); + EnableClipDistances(); + if (frame.RenderSky) + { + _sky?.RenderSky( + frame.Camera, + frame.CameraWorldPosition, + frame.DayFraction, + frame.ActiveDayGroup, + frame.SkyKeyframe, + frame.EnvironOverrideActive); + } + + DisableClipDistances(); + if (frame.RenderSky && _particles is not null && _particleRenderer is not null) + { + _particleRenderer.Draw( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.SkyPreScene); + } + + EnableClipDistances(); + _terrainDiagnostics.Begin(); + _terrain?.Draw( + frame.Camera, + frame.Frustum, + neverCullLandblockId: frame.PlayerLandblockId, + clipPlanes: slice.Planes, + ndcClipAabb: slice.NdcAabb); + _terrainDiagnostics.Complete(); + + DisableClipDistances(); + if (context.OutdoorEntities.Count > 0) + { + var sceneryEntry = ( + frame.PlayerLandblockId ?? 0u, + Vector3.Zero, + Vector3.Zero, + context.OutdoorEntities, + (IReadOnlyDictionary?)null); + _entities.Draw( + frame.Camera, + new[] { sceneryEntry }, + frame.Frustum, + neverCullLandblockId: frame.PlayerLandblockId, + visibleCellIds: null, + animatedEntityIds: frame.AnimatedEntityIds); + } + + if (scissor) + _gl.Disable(EnableCap.ScissorTest); + DisableClipDistances(); + } + + public void DrawLandscapeSliceLate( + RetailPViewFrameInput frame, + RetailPViewLandscapeLateSliceContext context) + { + ClipViewSlice slice = context.Slice; + bool scissor = BeginDoorwayScissor(slice.NdcAabb); + _clipFrame.BindTerrainClip(_gl); + + DisableClipDistances(); + if (context.Dynamics.Count > 0) + { + var dynamicsEntry = ( + frame.PlayerLandblockId ?? 0u, + Vector3.Zero, + Vector3.Zero, + context.Dynamics, + (IReadOnlyDictionary?)null); + _entities.Draw( + frame.Camera, + new[] { dynamicsEntry }, + frame.Frustum, + neverCullLandblockId: frame.PlayerLandblockId, + visibleCellIds: null, + animatedEntityIds: frame.AnimatedEntityIds); + } + + _particleClassifications.ReplaceOutdoor(context.ParticleOwners); + + _diagnostics.EmitOutStageParticles( + RenderingDiagnostics.ProbeOutStageEnabled, + _particles, + _particleClassifications.Outdoor); + + if (!frame.RootCell.IsOutdoorNode + && _particleClassifications.Outdoor.Count > 0 + && _particles is not null + && _particleRenderer is not null) + { + _particleRenderer.DrawForOwners( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.Scene, + _particleClassifications.Outdoor); + } + + EnableClipDistances(); + if (frame.RenderSky && frame.RenderWeather) + { + _sky?.RenderWeather( + frame.Camera, + frame.CameraWorldPosition, + frame.DayFraction, + frame.ActiveDayGroup, + frame.SkyKeyframe, + frame.EnvironOverrideActive); + DisableClipDistances(); + if (_particles is not null && _particleRenderer is not null) + { + _particleRenderer.Draw( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.SkyPostScene); + } + } + else + { + DisableClipDistances(); + } + + if (scissor) + _gl.Disable(EnableCap.ScissorTest); + DisableClipDistances(); + } + + public void ClearInteriorDepth() + { + _gl.Disable(EnableCap.ScissorTest); + _gl.DepthMask(true); + _gl.Clear(ClearBufferMask.DepthBufferBit); + } + + public void DrawExitPortalMask( + RetailPViewFrameInput frame, + RetailPViewCellSliceContext context) => + DrawPortalDepthWrite(context, frame, forceFarZ: frame.RootCell.IsOutdoorNode); + + public void DrawLookInPortalPunch( + RetailPViewFrameInput frame, + RetailPViewCellSliceContext context) => + DrawPortalDepthWrite(context, frame, forceFarZ: true); + + public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame) + { + if (_particles is null || _particleRenderer is null) + return; + + DisableClipDistances(); + _particleRenderer.DrawForOwners( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.Scene, + _noSceneParticleEntityIds, + includeUnattached: true); + } + + public void FlushLandscapeAlpha() => _alpha.Flush(); + + public void DrawCellParticles( + RetailPViewFrameInput frame, + RetailPViewCellSliceContext context) + { + if (_particles is null + || _particleRenderer is null + || context.CellEntities.Count == 0) + { + return; + } + + HashSet visible = _particleClassifications.Visible; + visible.Clear(); + foreach (WorldEntity entity in context.CellEntities) + visible.Add(entity.Id); + if (visible.Count == 0) + return; + + DisableClipDistances(); + _particleRenderer.DrawForOwners( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.Scene, + visible); + DisableClipDistances(); + } + + public void DrawDynamicsParticles( + RetailPViewFrameInput frame, + IReadOnlyList survivors) + { + if (_particles is null || _particleRenderer is null || survivors.Count == 0) + return; + + HashSet dynamics = _particleClassifications.Dynamics; + dynamics.Clear(); + foreach (WorldEntity entity in survivors) + dynamics.Add(entity.Id); + if (dynamics.Count == 0) + return; + + DisableClipDistances(); + _particleRenderer.DrawForOwners( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.Scene, + dynamics); + DisableClipDistances(); + } + + public void EmitDiagnostics( + RetailPViewFrameInput frame, + RetailPViewFrameResult result) => + _diagnostics.EmitRetailPViewDiagnostics( + RenderingDiagnostics.ProbeViewerEnabled, + RenderingDiagnostics.ProbeVisibilityEnabled, + RenderingDiagnostics.ProbeFlapEnabled, + result, + frame.RootCell, + frame.ViewerCellId, + frame.PlayerCellId, + frame.CameraWorldPosition, + frame.PlayerViewPosition, + frame.CameraView, + frame.CameraCellResolution); + + private void UploadTerrainClip() + { + TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl); + _terrain?.SetClipUbo(binding); + } + + private void DrawPortalDepthWrite( + RetailPViewCellSliceContext context, + RetailPViewFrameInput frame, + bool forceFarZ) + { + // Retail D3DPolyRender::DrawPortalPolyInternal @ 0x0059BC90. + // Main interior roots stamp true depth (seal); outdoor and look-in + // apertures stamp far depth (punch). The renderer owns that choice. + if (_portalDepthMask is null) + return; + LoadedCell? cell = frame.Cells.Find(context.CellId); + if (cell is null) + return; + + Span world = stackalloc Vector3[32]; + for (int index = 0; index < cell.Portals.Count; index++) + { + if (cell.Portals[index].OtherCellId != 0xFFFF) + continue; + if (index >= cell.PortalPolygons.Count) + break; + Vector3[] localVertices = cell.PortalPolygons[index]; + if (localVertices.Length < 3) + continue; + + int count = Math.Min(localVertices.Length, world.Length); + for (int vertex = 0; vertex < count; vertex++) + { + world[vertex] = Vector3.Transform( + localVertices[vertex], + cell.WorldTransform); + world[vertex].Z += PortalVisibilityBuilder.ShellDrawLiftZ; + } + + _diagnostics.EmitSeamMask( + RenderingDiagnostics.ProbeSeamDrawEnabled, + RenderingDiagnostics.SeamDrawTargetCells, + context.CellId, + index, + forceFarZ, + world[..count]); + + _portalDepthMask.DrawDepthFan( + world[..count], + frame.ViewProjection, + context.Slice.Planes, + forceFarZ); + } + } + + private bool BeginDoorwayScissor(Vector4 ndcAabb) + { + RetailPViewFramebufferSize framebuffer = _framebuffer.Capture(); + var box = NdcScissorRect.ToPixels( + ndcAabb, + framebuffer.Width, + framebuffer.Height); + _gl.Enable(EnableCap.ScissorTest); + _gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height); + return true; + } + + private void EnableClipDistances() + { + for (int index = 0; index < ClipFrame.MaxPlanes; index++) + _gl.Enable(EnableCap.ClipDistance0 + index); + } + + private void DisableClipDistances() + { + for (int index = 0; index < ClipFrame.MaxPlanes; index++) + _gl.Disable(EnableCap.ClipDistance0 + index); + } +} diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 0191ed01..9f79896a 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; -using AcDream.App.Rendering.Wb; using AcDream.Core.World; -using Silk.NET.OpenGL; namespace AcDream.App.Rendering; @@ -14,10 +12,6 @@ namespace AcDream.App.Rendering; /// public sealed class RetailPViewRenderer { - private readonly GL _gl; - private readonly ClipFrame _clipFrame; - private readonly EnvCellRenderer _envCells; - private readonly WbDrawDispatcher _entities; private readonly PortalVisibilityFrame _mainPortalFrameScratch = new(); private readonly ClipFrameAssembly _clipAssemblyScratch = new(); private readonly ViewconeCuller _viewconeScratch = new(); @@ -76,28 +70,20 @@ public sealed class RetailPViewRenderer // gather); seeds themselves are unbounded. private const float OutdoorBuildingSeedDistance = float.PositiveInfinity; - public RetailPViewRenderer( - GL gl, - ClipFrame clipFrame, - EnvCellRenderer envCells, - WbDrawDispatcher entities) - { - _gl = gl; - _clipFrame = clipFrame; - _envCells = envCells; - _entities = entities; - } - - public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx) + public RetailPViewFrameResult DrawInside( + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes) { ArgumentNullException.ThrowIfNull(ctx); + ArgumentNullException.ThrowIfNull(passes); + passes.BeginFrame(); RecycleLookInFrames(); ResetBuildingGroups(); var pvFrame = PortalVisibilityBuilder.Build( ctx.RootCell, ctx.ViewerEyePos, - ctx.CellLookup, + ctx.Cells.Find, ctx.ViewProjection, buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ, @@ -131,12 +117,11 @@ public sealed class RetailPViewRenderer && pvFrame.OutsideView.Polygons.Count > 0) BuildInteriorRootLookIns(ctx, pvFrame); - var clipAssembly = ClipFrameAssembler.Assemble( - _clipFrame, + var clipAssembly = passes.AssembleClipFrame( pvFrame, _clipAssemblyScratch); int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2); - PrepareClipFrame(ctx.SetTerrainClipUbo, terrainUploadCount); + passes.PrepareClipFrame(terrainUploadCount); // R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the // assembler handed a clip-slot. This feeds the Prepare filter + entity partition, @@ -146,7 +131,7 @@ public sealed class RetailPViewRenderer _drawableCellsScratch.Clear(); _drawableCellsScratch.UnionWith(pvFrame.OrderedVisibleCells); var drawableCells = _drawableCellsScratch; - UseIndoorMembershipOnlyRouting(); + passes.UseIndoorMembershipOnlyRouting(); // #124: look-in cells need prepared shell batches + their statics routed // into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main @@ -169,13 +154,7 @@ public sealed class RetailPViewRenderer // the RESIDENT-cell registry, not the frame flood — and is deleted. The pool // is built once per frame in GameWindow, player-anchored.) - _envCells.PrepareRenderBatches( - ctx.ViewProjection, - ctx.CameraWorldPosition, - filter: prepareCells, - centerLbX: ctx.RenderCenterLbX, - centerLbY: ctx.RenderCenterLbY, - renderRadius: ctx.RenderRadius); + passes.PrepareCellBatches(ctx, prepareCells); InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries); var partition = _partitionResult; @@ -185,7 +164,7 @@ public sealed class RetailPViewRenderer drawableCells, partition); - ctx.EmitDiagnostics?.Invoke(result); + passes.EmitDiagnostics(ctx, result); // T1 (fused BR-2/3): retail's frame order — static world, then the // aperture depth writes, then interior cells WHOLE far→near, then @@ -226,17 +205,17 @@ public sealed class RetailPViewRenderer foreach (var e in partition.Dynamics) { EntitySphere(e, out var c, out float r); - if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.CellLookup)) + if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.Cells)) _outsideStageDynamics.Add(e); } } - DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone); - UseIndoorMembershipOnlyRouting(); - DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); - DrawEnvCellShells(pvFrame); - DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone); - DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode); + DrawLandscapeThroughOutsideView(ctx, passes, clipAssembly, partition, viewcone); + passes.UseIndoorMembershipOnlyRouting(); + DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells); + DrawEnvCellShells(passes, pvFrame); + DrawCellObjectLists(ctx, passes, pvFrame, clipAssembly, drawableCells, partition, viewcone); + DrawDynamicsLast(ctx, passes, partition, viewcone, ctx.RootCell.IsOutdoorNode); return result; } @@ -244,7 +223,7 @@ public sealed class RetailPViewRenderer // R-A2: group the nearby building cells by BuildingId and run one per-building flood per group // (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The // grouping dict contains only this frame's keys; lists are pooled across frames. - private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame) + private void MergeNearbyBuildingFloods(RetailPViewFrameInput ctx, PortalVisibilityFrame pvFrame) { RebuildBuildingGroups(ctx.NearbyBuildingCells!); @@ -255,7 +234,7 @@ public sealed class RetailPViewRenderer var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding( group, ctx.ViewerEyePos, - ctx.CellLookup, + ctx.Cells.Find, ctx.ViewProjection, OutdoorBuildingSeedDistance, reuseFrame: _outdoorBuildingFrameScratch); @@ -304,7 +283,7 @@ public sealed class RetailPViewRenderer // doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip // 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own // building self-excludes via the seed eye-side test. - private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame) + private void BuildInteriorRootLookIns(RetailPViewFrameInput ctx, PortalVisibilityFrame pvFrame) { RebuildBuildingGroups(ctx.NearbyBuildingCells!); @@ -316,7 +295,7 @@ public sealed class RetailPViewRenderer ? _lookInFramePool.Pop() : new PortalVisibilityFrame(); var frame = PortalVisibilityBuilder.ConstructViewBuilding( - group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, + group, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection, OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons, reuseFrame: frameScratch); if (frame.OrderedVisibleCells.Count > 0) @@ -365,7 +344,8 @@ public sealed class RetailPViewRenderer // here is color-safe; statics draw whole (the main viewcone has no entry // for look-in cells; over-include is the safe direction). private void DrawBuildingLookIns( - RetailPViewDrawContext ctx, + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result partition) { @@ -375,30 +355,27 @@ public sealed class RetailPViewRenderer foreach (var frame in _lookInFrames) { // Pass 1: far-Z punch every aperture of this building. - if (ctx.DrawLookInPortalPunch is not null) + foreach (uint cellId in frame.OrderedVisibleCells) { - foreach (uint cellId in frame.OrderedVisibleCells) + if (!frame.CellViews.TryGetValue(cellId, out var view)) + continue; + foreach (var poly in view.Polygons) { - if (!frame.CellViews.TryGetValue(cellId, out var view)) + var cps = ClipPlaneSet.From(poly); + if (cps.IsNothingVisible) continue; - foreach (var poly in view.Polygons) - { - var cps = ClipPlaneSet.From(poly); - if (cps.IsNothingVisible) - continue; - ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext( - cellId, - new ClipViewSlice( - 0, - new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), - cps.PlaneArray), - Array.Empty())); - } + passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext( + cellId, + new ClipViewSlice( + 0, + new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), + cps.PlaneArray), + Array.Empty())); } } // Pass 2: shells + statics, far→near. - UseIndoorMembershipOnlyRouting(); + passes.UseIndoorMembershipOnlyRouting(); // Opaque shells batched per building into ONE Render (this building's // aperture punches above already ran; z-buffer handles order and @@ -408,7 +385,7 @@ public sealed class RetailPViewRenderer foreach (uint cid in frame.OrderedVisibleCells) _shellBatch.Add(cid); if (_shellBatch.Count > 0) - _envCells.Render(WbRenderPass.Opaque, _shellBatch); + passes.DrawOpaqueCellShells(_shellBatch); for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) { @@ -417,8 +394,8 @@ public sealed class RetailPViewRenderer _oneCell.Add(cellId); // Opaque shell batched above. Transparent stays per-cell (far→near) // for correct compositing; skipped for opaque-only cells. - if (_envCells.CellHasTransparent(cellId)) - _envCells.Render(WbRenderPass.Transparent, _oneCell); + if (passes.CellHasTransparentShell(cellId)) + passes.DrawTransparentCellShells(_oneCell); _cellStaticScratch.Clear(); if (partition.ByCell.TryGetValue(cellId, out var bucket)) @@ -443,12 +420,12 @@ public sealed class RetailPViewRenderer if (_cellStaticScratch.Count > 0) { - DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); + passes.DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); // The cell-particles pass for look-in cells — retail's // nested DrawCells draws objects WITH their emitters. foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) - ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext( + passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( cellId, slice, _cellStaticScratch)); } } @@ -456,7 +433,8 @@ public sealed class RetailPViewRenderer } private void DrawLandscapeThroughOutsideView( - RetailPViewDrawContext ctx, + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result partition, ViewconeCuller viewcone) @@ -479,16 +457,15 @@ public sealed class RetailPViewRenderer int probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { - _clipFrame.SetTerrainClip(slice.Planes); - UploadTerrainClip(ctx.SetTerrainClipUbo); + passes.SetTerrainClip(slice.Planes); // T3 (BR-5): entities are never hard-clipped — retail viewcone- // CHECKS each mesh's sphere against the view (Ghidra 0x0054c250) // and draws it whole. The old per-slice entity clip routing // (gl_ClipDistance via SetClipRouting) is replaced by the sphere // pre-filter below; terrain/sky keep their per-slice plane clip. - _entities.ClearClipRouting(); + passes.ClearClipRouting(); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled) - EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex); + passes.EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex); _outdoorStaticScratch.Clear(); foreach (var e in partition.OutdoorStatic) @@ -498,14 +475,14 @@ public sealed class RetailPViewRenderer _outdoorStaticScratch.Add(e); } probeSliceIndex++; - ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); + passes.DrawLandscapeSlice(ctx, new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); } // #124: far-building look-ins draw HERE — still inside the landscape // stage (their punches mark against the terrain/exterior depth just // drawn), strictly BEFORE the depth clear + seals below, matching // retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785). - DrawBuildingLookIns(ctx, clipAssembly, partition); + DrawBuildingLookIns(ctx, passes, clipAssembly, partition); // LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn // pre-clear so the seal protects their aperture pixels; AFTER the @@ -515,9 +492,8 @@ public sealed class RetailPViewRenderer probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { - _clipFrame.SetTerrainClip(slice.Planes); - UploadTerrainClip(ctx.SetTerrainClipUbo); - _entities.ClearClipRouting(); + passes.SetTerrainClip(slice.Planes); + passes.ClearClipRouting(); _outdoorStaticScratch.Clear(); // late: dynamics survivors _lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors @@ -530,14 +506,12 @@ public sealed class RetailPViewRenderer // #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids // double as an ENTITY-id watchlist here — one line per watched // outdoor-static owner per CHANGE of its cone verdict. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled - && AcDream.Core.Rendering.RenderingDiagnostics.DumpEntitySourceIds.Contains(e.Id) - && (!_outStageOwnerVerdicts.TryGetValue(e.Id, out bool prev) || prev != ownerPass)) - { - _outStageOwnerVerdicts[e.Id] = ownerPass; - Console.WriteLine(System.FormattableString.Invariant( - $"[outstage-own] id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} pos=({e.Position.X:F1},{e.Position.Y:F1},{e.Position.Z:F1}) c=({c.X:F1},{c.Y:F1},{c.Z:F1}) r={r:F1} slice={probeSliceIndex} {(ownerPass ? "PASS" : "CULL")}")); - } + passes.EmitOutStageOwner( + e, + c, + r, + probeSliceIndex, + ownerPass); } foreach (var e in _outsideStageDynamics) { @@ -548,10 +522,12 @@ public sealed class RetailPViewRenderer _lateParticleOwnerScratch.Add(e); } } - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled) - EmitOutStageProbe(probeSliceIndex, viewcone); + passes.EmitOutStageRouting( + probeSliceIndex, + _outsideStageDynamics, + viewcone); probeSliceIndex++; - ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext( + passes.DrawLandscapeSliceLate(ctx, new RetailPViewLandscapeLateSliceContext( slice, _outdoorStaticScratch, _lateParticleOwnerScratch)); } @@ -563,12 +539,12 @@ public sealed class RetailPViewRenderer // must not double-draw, the #121 lesson), at the END of the landscape // stage: after the clear they would z-fail against the doorway seal. if (!ctx.RootCell.IsOutdoorNode) - ctx.DrawUnattachedSceneParticles?.Invoke(); + passes.DrawUnattachedSceneParticles(ctx); // Retail PView::DrawCells 0x005A4872 drains the landscape alpha list // immediately after LScape::draw and before the optional depth clear. // The queue remains active for the post-clear/final-world scope. - ctx.FlushLandscapeAlpha?.Invoke(); + passes.FlushLandscapeAlpha(); // T1: retail clears the FULL depth buffer ONCE between the outside // stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 — @@ -577,133 +553,19 @@ public sealed class RetailPViewRenderer // every outside-leading portal's TRUE depth (the seals, // DrawExitPortalMasks). Replaces the old per-slice scissored AABB // clear (wrong shape, no seal after it). - if (clipAssembly.OutsideViewSlices.Length > 0) - ctx.ClearDepthForInterior?.Invoke(); + if (clipAssembly.OutsideViewSlices.Length > 0 && !ctx.RootCell.IsOutdoorNode) + passes.ClearInteriorDepth(); - UseIndoorMembershipOnlyRouting(); - } - - // #131 [outstage] probe state (2026-06-12, throwaway): print-on-change — - // which outdoor dynamics were routed to the outside stage and which - // survived the slice viewcone. Strip with the probe when #131 closes. - private string? _lastOutStageSig; - private readonly Dictionary _outStageOwnerVerdicts = new(); - - private void EmitOutStageProbe(int sliceIndex, ViewconeCuller viewcone) - { - var sb = new System.Text.StringBuilder(192); - sb.Append("slice=").Append(sliceIndex) - .Append(" outStage=").Append(_outsideStageDynamics.Count).Append(" ["); - for (int i = 0; i < _outsideStageDynamics.Count; i++) - { - var e = _outsideStageDynamics[i]; - EntitySphere(e, out var c, out float r); - bool pass = viewcone.SphereVisibleInOutsideSlice(sliceIndex, c, r); - if (i > 0) sb.Append(' '); - sb.Append(System.FormattableString.Invariant( - $"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}(s{e.SourceGfxObjOrSetupId:X8}):{(pass ? "PASS" : "CULL")}:r={r:F1}")); - } - sb.Append(']'); - string sig = sb.ToString(); - if (sig == _lastOutStageSig) return; - _lastOutStageSig = sig; - Console.WriteLine("[outstage] " + sig); - } - - // §4 flap [clip-route] probe state (2026-06-10, throwaway): print-on-change signature + - // monotonic sequence so held-flap vs healthy frames diff cleanly in one capture. - private string? _lastClipRouteSig; - private long _clipRouteSeq; - private readonly List _clipRouteCellKeys = new(); - - // §4 flap apparatus (2026-06-10): the decisive probe between the surviving suspects - // (handoff 2026-06-09 §1). Emits the EXACT clip inputs the landscape pass draws under: - // the outside slice's slot + NDC AABB + planes (CPU side), the region-SSBO bytes decoded - // at that slot (what mesh_modern.vert reads for routed instances), the terrain-UBO head - // (what terrain/sky gate against), and the CellIdToSlot routing table. Fires AFTER - // SetTerrainClip + UploadTerrainClip + SetClipRouting, BEFORE DrawLandscapeSlice — so the - // printed bytes are exactly what this slice's draws consume. - private void EmitClipRouteProbe(ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex) - { - var sb = new System.Text.StringBuilder(256); - sb.Append(System.FormattableString.Invariant( - $"slice={sliceIndex}/{clipAssembly.OutsideViewSlices.Length} slot={slice.Slot}")); - sb.Append(System.FormattableString.Invariant( - $" ndc=({slice.NdcAabb.X:F3},{slice.NdcAabb.Y:F3},{slice.NdcAabb.Z:F3},{slice.NdcAabb.W:F3})")); - sb.Append(System.FormattableString.Invariant($" planes={slice.Planes.Length}[")); - for (int i = 0; i < slice.Planes.Length; i++) - { - var p = slice.Planes[i]; - if (i > 0) sb.Append(' '); - sb.Append(System.FormattableString.Invariant($"({p.X:F3},{p.Y:F3},{p.Z:F3},{p.W:F3})")); - } - - // CellIdToSlot sorted by cell id so dictionary enumeration order can't fake a change. - sb.Append("] cells={"); - _clipRouteCellKeys.Clear(); - foreach (uint key in clipAssembly.CellIdToSlot.Keys) - _clipRouteCellKeys.Add(key); - _clipRouteCellKeys.Sort(); - for (int i = 0; i < _clipRouteCellKeys.Count; i++) - { - if (i > 0) sb.Append(','); - sb.Append(System.FormattableString.Invariant( - $"0x{_clipRouteCellKeys[i]:X8}:{clipAssembly.CellIdToSlot[_clipRouteCellKeys[i]]}")); - } - sb.Append('}'); - - // Region-SSBO content decoded at the routed slot, from the packed bytes UploadRegions - // just uploaded — slot stride 144: count uint at +0, planes[8] at +16. - var rb = _clipFrame.RegionBytesForTest; - int off = slice.Slot * ClipFrame.CellClipStrideBytes; - if (off >= 0 && off + ClipFrame.CellClipStrideBytes <= rb.Length) - { - uint ssboCount = System.BitConverter.ToUInt32(rb.Slice(off, 4)); - sb.Append(System.FormattableString.Invariant($" ssbo[{slice.Slot}]: n={ssboCount}")); - int planeN = (int)System.Math.Min(ssboCount, (uint)ClipFrame.MaxPlanes); - for (int i = 0; i < planeN; i++) - { - int po = off + ClipFrame.CellClipPlanesOffset + i * 16; - float px = System.BitConverter.ToSingle(rb.Slice(po, 4)); - float py = System.BitConverter.ToSingle(rb.Slice(po + 4, 4)); - float pz = System.BitConverter.ToSingle(rb.Slice(po + 8, 4)); - float pw = System.BitConverter.ToSingle(rb.Slice(po + 12, 4)); - sb.Append(System.FormattableString.Invariant($" ({px:F3},{py:F3},{pz:F3},{pw:F3})")); - } - } - else - { - sb.Append(System.FormattableString.Invariant( - $" ssbo[{slice.Slot}]: OUT-OF-RANGE len={rb.Length}")); - } - - // Terrain-UBO head as uploaded (std140: int count at +0, planes[8] at +16). - var tb = _clipFrame.TerrainBytesForTest; - int uboCount = System.BitConverter.ToInt32(tb.Slice(0, 4)); - float u0 = System.BitConverter.ToSingle(tb.Slice(16, 4)); - float u1 = System.BitConverter.ToSingle(tb.Slice(20, 4)); - float u2 = System.BitConverter.ToSingle(tb.Slice(24, 4)); - float u3 = System.BitConverter.ToSingle(tb.Slice(28, 4)); - sb.Append(System.FormattableString.Invariant( - $" ubo: n={uboCount} p0=({u0:F3},{u1:F3},{u2:F3},{u3:F3})")); - - string sig = sb.ToString(); - _clipRouteSeq++; - if (sig == _lastClipRouteSig) - return; - _lastClipRouteSig = sig; - Console.WriteLine($"[clip-route] n={_clipRouteSeq} {sig}"); + passes.UseIndoorMembershipOnlyRouting(); } private void DrawExitPortalMasks( - IRetailPViewCellDrawCallbacks ctx, + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells) { - if (ctx.DrawExitPortalMasks is null) - return; - for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = pvFrame.OrderedVisibleCells[i]; @@ -711,11 +573,15 @@ public sealed class RetailPViewRenderer continue; foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) - ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty())); + passes.DrawExitPortalMask( + ctx, + new RetailPViewCellSliceContext(cellId, slice, Array.Empty())); } } - private void DrawEnvCellShells(PortalVisibilityFrame pvFrame) + private void DrawEnvCellShells( + IRetailPViewPassExecutor passes, + PortalVisibilityFrame pvFrame) { // T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's // shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once. @@ -727,7 +593,7 @@ public sealed class RetailPViewRenderer // (927fd8f/9ce335e, #114) is deleted with this rewrite. // Per-cell opaque+transparent keeps the far→near transparent // compositing the per-cell loop already provided. - UseIndoorMembershipOnlyRouting(); + passes.UseIndoorMembershipOnlyRouting(); // Opaque: ONE batched Render for all shell cells (was one heavy per-frame // Render call PER cell — the dense-town FPS sink, ~94 calls/24.75ms at @@ -739,7 +605,7 @@ public sealed class RetailPViewRenderer foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) _shellBatch.Add(entry.CellId); if (_shellBatch.Count > 0) - _envCells.Render(WbRenderPass.Opaque, _shellBatch); + passes.DrawOpaqueCellShells(_shellBatch); // Transparent: far-to-near order matters for compositing. The ordered // list retains ShellPass cell boundaries while EnvCellRenderer shares @@ -747,11 +613,11 @@ public sealed class RetailPViewRenderer _orderedTransparentShellCells.Clear(); foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) { - if (_envCells.CellHasTransparent(entry.CellId)) + if (passes.CellHasTransparentShell(entry.CellId)) _orderedTransparentShellCells.Add(entry.CellId); } if (_orderedTransparentShellCells.Count > 0) - _envCells.RenderTransparentOrdered(_orderedTransparentShellCells); + passes.DrawTransparentCellShellsOrdered(_orderedTransparentShellCells); } // T1: the frame's single LAST entity pass — ALL server-spawned dynamics @@ -767,7 +633,8 @@ public sealed class RetailPViewRenderer // the draw list; the partition keeps routing it so the CULL (not the // visibility set) drops it, exactly retail's shape. private void DrawDynamicsLast( - IRetailPViewCellDrawContext ctx, + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes, InteriorEntityPartition.Result partition, ViewconeCuller viewcone, bool rootIsOutdoor) @@ -811,8 +678,8 @@ public sealed class RetailPViewRenderer if (_dynamicsScratch.Count == 0) return; - UseIndoorMembershipOnlyRouting(); - DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null); + passes.UseIndoorMembershipOnlyRouting(); + passes.DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null); // #121: dynamics' attached emitters (portal swirls, creature effects) // gate through the SAME cone-surviving owner set as their meshes — @@ -824,19 +691,17 @@ public sealed class RetailPViewRenderer // portals went invisible. Outside-stage dynamics are excluded here: // their emitters already drew in the landscape slice (alpha-blended // particles must not double-draw, unlike the depth-idempotent meshes). - if (ctx.DrawDynamicsParticles is not null) - { - _dynamicsParticleScratch.Clear(); - foreach (var e in _dynamicsScratch) - if (!_outsideStageDynamics.Contains(e)) - _dynamicsParticleScratch.Add(e); - if (_dynamicsParticleScratch.Count > 0) - ctx.DrawDynamicsParticles(_dynamicsParticleScratch); - } + _dynamicsParticleScratch.Clear(); + foreach (var e in _dynamicsScratch) + if (!_outsideStageDynamics.Contains(e)) + _dynamicsParticleScratch.Add(e); + if (_dynamicsParticleScratch.Count > 0) + passes.DrawDynamicsParticles(ctx, _dynamicsParticleScratch); } private void DrawCellObjectLists( - IRetailPViewCellDrawContext ctx, + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, @@ -889,8 +754,7 @@ public sealed class RetailPViewRenderer _cellObjCells.Add(cellId); // BR-2 phantom-site probe (T3-updated): post-viewcone survivors. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled) - EmitPhantomObjsProbe(cellId, survivors); + passes.EmitPhantomObjects(cellId, survivors); } // ONE batched static-object draw for every visible cell (was N per-cell @@ -900,26 +764,27 @@ public sealed class RetailPViewRenderer // cull; only the draw is batched). if (_allCellStatics.Count > 0) { - UseIndoorMembershipOnlyRouting(); - DrawEntityBucket(ctx, _allCellStatics, _cellObjCells); + passes.UseIndoorMembershipOnlyRouting(); + passes.DrawEntityBucket(ctx, _allCellStatics, _cellObjCells); } // Cell-particle pass — consolidated across ALL visible cells into ONE // draw. Was per-cell, and each call re-walked the ENTIRE live particle set - // (DrawRetailPViewCellParticles → ParticleRenderer.Draw enumerates every + // (RetailPViewPassExecutor.DrawCellParticles → ParticleRenderer.Draw enumerates every // live emitter), i.e. O(cells × particles) — the dense-town cellobjects // sink (~5 ms at Arwic). Static owners are disjoint per cell, so the UNION // (= _allCellStatics, already accumulated above for the batched draw) draws // EXACTLY the same emitters: the callback gates on owner id (the cone- // surviving set), the renderer sorts globally back-to-front, and the per- // cell slice was never used for clipping (the scissor gate was deleted in - // T3 — DrawRetailPViewCellParticles disables clip distances). Runs after + // T3 — RetailPViewPassExecutor.DrawCellParticles disables clip distances). Runs after // the batched static draw so emitters depth-test against the statics now in // the buffer (the statics-before-particles order). cellId/slice are unused // by the particle pass — pass NoClipSlice + the union owner list. This also // drops the per-cell BuildDrawList allocations (N → 1). if (_allCellStatics.Count > 0) - ctx.DrawCellParticles?.Invoke( + passes.DrawCellParticles( + ctx, new RetailPViewCellSliceContext(0u, NoClipSlice, _allCellStatics)); } @@ -960,7 +825,7 @@ public sealed class RetailPViewRenderer Vector3 sphereCenter, float sphereRadius, HashSet drawableCells, - Func cellLookup) + IRetailPViewCellSource cells) { if (!InteriorEntityPartition.IsIndoorCellId(parentCellId)) return true; @@ -968,7 +833,7 @@ public sealed class RetailPViewRenderer uint cellId = parentCellId!.Value; if (!drawableCells.Contains(cellId)) return false; // not in the flood — the last-pass cone cull owns it - var cell = cellLookup(cellId); + var cell = cells.Find(cellId); if (cell is null) return false; @@ -998,20 +863,6 @@ public sealed class RetailPViewRenderer radius = (e.AabbMax - e.AabbMin).Length() * 0.5f; } - // BR-2 phantom-site probe state: print-on-change per cell so the log stays - // diffable while the condition persists. Throwaway apparatus — strip when - // the #113 phantom residual closes. (The [phantom-shell] half died with - // the T1 chop deletion — shells draw whole, there is no slice state left - // to report.) - private readonly Dictionary _phantomObjsSig = new(); - - private void EmitPhantomObjsProbe(uint cellId, int bucketCount) - { - if (_phantomObjsSig.TryGetValue(cellId, out var prev) && prev == bucketCount) return; - _phantomObjsSig[cellId] = bucketCount; - Console.WriteLine($"[phantom-objs] cell=0x{cellId:X8} entities={bucketCount} (drawn unclipped, no viewcone)"); - } - private static ClipViewSlice[] GetCellSlicesOrNoClip( ClipFrameAssembly clipAssembly, uint cellId) @@ -1023,80 +874,65 @@ public sealed class RetailPViewRenderer return NoClipSlices; } - private void UseIndoorMembershipOnlyRouting() - { - // T1: NOTHING in the world passes hard-clips geometry anymore — retail - // viewcone-CHECKS meshes (sphere vs view planes, T3) and never clips - // cell shells (DrawEnvCell draws the whole prebuilt mesh, pc:427905). - // This clears any clip routing left by the landscape slices. - _envCells.SetClipRouting(null); - _entities.ClearClipRouting(); - } - - private void DrawEntityBucket( - IRetailPViewCellDrawContext ctx, - IReadOnlyList bucket, - HashSet? visibleCellIds) - { - uint lbId = ctx.PlayerLandblockId ?? 0u; - var entry = (lbId, Vector3.Zero, Vector3.Zero, - (IReadOnlyList)bucket, - (IReadOnlyDictionary?)null); - - _entities.Draw( - ctx.Camera, - new[] { entry }, - ctx.Frustum, - neverCullLandblockId: ctx.PlayerLandblockId, - visibleCellIds: visibleCellIds, - animatedEntityIds: ctx.AnimatedEntityIds); - } - - private void PrepareClipFrame( - Action setTerrainClipUbo, - int terrainUploadCount) - { - // Allocate every terrain record before issuing the first draw. BufferData - // therefore never replaces the arena's backing store while an earlier - // slice can still reference it. The large region table is immutable for - // this assembled PView frame and is uploaded exactly once. - _clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount); - _clipFrame.UploadRegions(_gl); - _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); - _envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); - UploadTerrainClip(setTerrainClipUbo); - } - - private void UploadTerrainClip(Action setTerrainClipUbo) - { - TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl); - setTerrainClipUbo(binding); - } } -public interface IRetailPViewCellDrawCallbacks +public interface IRetailPViewCellSource { - public Action? DrawExitPortalMasks { get; } - public Action? DrawCellParticles { get; } - - /// #124: far-Z punch one look-in aperture (a clipped view polygon - /// of a looked-into building cell) — always the PUNCH variant regardless - /// of root kind (retail maxZ1; the root-keyed forceFarZ selector only - /// governs the MAIN frame's exit-portal masks). - public Action? DrawLookInPortalPunch { get; } + LoadedCell? Find(uint cellId); } -public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks +/// +/// Typed execution seam for the GL passes ordered by +/// . Implementations execute the requested +/// pass only; visibility construction and draw ordering remain renderer-owned. +/// All frame inputs and results are borrowed for the duration of the call. +/// +public interface IRetailPViewPassExecutor { - public ICamera Camera { get; } - public FrustumPlanes? Frustum { get; } - public uint? PlayerLandblockId { get; } - public HashSet? AnimatedEntityIds { get; } - - /// #121: draw the Scene-pass emitters attached to the frame's - /// cone-surviving dynamics (portal swirls, creature effects). Invoked once - /// per frame after the last entity pass with the survivor list. - public Action>? DrawDynamicsParticles { get; } + void BeginFrame(); + ClipFrameAssembly AssembleClipFrame( + PortalVisibilityFrame portalFrame, + ClipFrameAssembly reuseAssembly); + void PrepareClipFrame(int terrainUploadCount); + void SetTerrainClip(ReadOnlySpan planes); + void ClearClipRouting(); + void UseIndoorMembershipOnlyRouting(); + void PrepareCellBatches( + RetailPViewFrameInput frame, + HashSet visibleCellIds); + void DrawOpaqueCellShells(HashSet cellIds); + bool CellHasTransparentShell(uint cellId); + void DrawTransparentCellShells(HashSet cellIds); + void DrawTransparentCellShellsOrdered(IReadOnlyList cellIds); + void DrawEntityBucket( + RetailPViewFrameInput frame, + IReadOnlyList entities, + HashSet? visibleCellIds); + void EmitClipRouteProbe( + ClipFrameAssembly clipAssembly, + ClipViewSlice slice, + int sliceIndex); + void EmitOutStageOwner( + WorldEntity entity, + Vector3 sphereCenter, + float sphereRadius, + int sliceIndex, + bool passed); + void EmitOutStageRouting( + int sliceIndex, + IReadOnlyList entities, + ViewconeCuller viewcone); + void EmitPhantomObjects(uint cellId, int survivorCount); + void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context); + void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context); + void ClearInteriorDepth(); + void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); + void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); + void DrawUnattachedSceneParticles(RetailPViewFrameInput frame); + void FlushLandscapeAlpha(); + void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); + void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlyList survivors); + void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result); } /// @@ -1261,7 +1097,7 @@ internal sealed class BuildingGroupScratch } } -public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext +public sealed class RetailPViewFrameInput { public required LoadedCell RootCell { get; init; } @@ -1271,7 +1107,7 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext public required Vector3 ViewerEyePos { get; init; } public required Matrix4x4 ViewProjection { get; init; } - public required Func CellLookup { get; init; } + public required IRetailPViewCellSource Cells { get; init; } public required ICamera Camera { get; init; } public required Vector3 CameraWorldPosition { get; init; } public required FrustumPlanes? Frustum { get; init; } @@ -1283,37 +1119,20 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } - public required Action SetTerrainClipUbo { get; init; } - public required Action DrawLandscapeSlice { get; init; } - /// #131/#132: the LATE landscape phase, per slice, after the #124 - /// look-ins — outside-stage dynamics' meshes + all scene particles + - /// weather (the FlushAlphaList deferral; see DrawLandscapeThroughOutsideView). - public Action? DrawLandscapeSliceLate { get; init; } - /// T1: one full-buffer depth clear between the outside stage and the - /// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor - /// roots — outdoors the interiors must depth-test against terrain + exteriors and - /// appear only through punched apertures. - public Action? ClearDepthForInterior { get; init; } - public Action? DrawExitPortalMasks { get; init; } - public Action? DrawCellParticles { get; init; } - public Action? DrawLookInPortalPunch { get; init; } - - /// #131: Scene-pass draw of UNATTACHED emitters - /// (AttachedObjectId == 0) for interior-root frames — invoked once at the - /// end of the landscape stage (pre-clear). Outdoor roots draw them via - /// GameWindow's dedicated post-frame pass instead. - public Action? DrawUnattachedSceneParticles { get; init; } - /// Drains retail's delayed landscape alpha list before the - /// optional outside-to-inside depth clear. The same frame-scoped queue - /// remains open for the final world alpha scope. - public Action? FlushLandscapeAlpha { get; init; } - public Action>? DrawDynamicsParticles { get; init; } - /// - /// Synchronous observation of the borrowed current-frame result. The - /// callback must copy anything it needs to retain beyond this invocation. - /// - public Action? EmitDiagnostics { get; init; } + // Pass-presentation and diagnostic values consumed synchronously by the + // typed executor. This input is data-only and is never retained. + public required bool RenderSky { get; init; } + public required bool RenderWeather { get; init; } + public required float DayFraction { get; init; } + public required DayGroupData? ActiveDayGroup { get; init; } + public required SkyKeyframe SkyKeyframe { get; init; } + public required bool EnvironOverrideActive { get; init; } + public required uint ViewerCellId { get; init; } + public required uint PlayerCellId { get; init; } + public required Vector3 PlayerViewPosition { get; init; } + public required Matrix4x4 CameraView { get; init; } + public required CameraCellResolution CameraCellResolution { get; init; } } /// diff --git a/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs b/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs new file mode 100644 index 00000000..e8a1cd5b --- /dev/null +++ b/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs @@ -0,0 +1,148 @@ +using System.Diagnostics; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Rendering; + +internal readonly record struct FramePipelineDiagnosticFacts( + LandblockPresentationDiagnostics Presentation, + RollingTimingPercentiles UploadTiming, + int EntitiesWalked, + int DeferredApplyBacklog, + int FullWindowRetirementCount, + int LastFullWindowRetirementLandblockCount, + int MaterializedEntityCount, + int SpawnSnapshotCount, + int ResidentEntityCount); + +internal interface IFramePipelineDiagnosticFactsSource +{ + TerrainRenderDiagnosticFacts CaptureTerrain(); + + FramePipelineDiagnosticFacts CaptureFrame(); +} + +/// +/// Samples the existing canonical runtime owners only when the five-second +/// frame diagnostic cadence publishes. It owns no world or render resource. +/// +internal sealed class RuntimeFramePipelineDiagnosticFactsSource : + IFramePipelineDiagnosticFactsSource +{ + private readonly TerrainModernRenderer? _terrain; + private readonly LandblockPresentationPipeline? _presentation; + private readonly RuntimeRenderFrameLivePreparation? _livePreparation; + private readonly WbDrawDispatcher? _dispatcher; + private readonly StreamingController? _streaming; + private readonly LiveEntityRuntime _liveEntities; + private readonly GpuWorldState _world; + + public RuntimeFramePipelineDiagnosticFactsSource( + TerrainModernRenderer? terrain, + LandblockPresentationPipeline? presentation, + RuntimeRenderFrameLivePreparation? livePreparation, + WbDrawDispatcher? dispatcher, + StreamingController? streaming, + LiveEntityRuntime liveEntities, + GpuWorldState world) + { + _terrain = terrain; + _presentation = presentation; + _livePreparation = livePreparation; + _dispatcher = dispatcher; + _streaming = streaming; + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _world = world ?? throw new ArgumentNullException(nameof(world)); + } + + public TerrainRenderDiagnosticFacts CaptureTerrain() => new( + _terrain?.VisibleSlots ?? 0, + _terrain?.LoadedSlots ?? 0, + _terrain?.CapacitySlots ?? 0); + + public FramePipelineDiagnosticFacts CaptureFrame() => new( + _presentation?.Diagnostics ?? default, + _livePreparation?.UploadTiming ?? default, + _dispatcher?.LastDrawStats.EntitiesWalked ?? 0, + _streaming?.DeferredApplyBacklog ?? 0, + _streaming?.FullWindowRetirementCount ?? 0, + _streaming?.LastFullWindowRetirementLandblockCount ?? 0, + _liveEntities.MaterializedWorldEntities.Count, + _liveEntities.Snapshots.Count, + _world.Entities.Count); +} + +/// +/// Keeps the terrain timing sample and the frame-pipeline rollup on one +/// failure-atomic publication cadence. The owner is shared by fallback and +/// PView terrain passes, so extraction cannot silently split their telemetry. +/// +internal sealed class TerrainDrawDiagnosticsController +{ + internal const long PublicationIntervalMilliseconds = 5_000; + + private readonly bool _enabled; + private readonly WorldRenderDiagnostics _world; + private readonly IFramePipelineDiagnosticFactsSource _facts; + private readonly IRenderFrameDiagnosticLog _log; + private long _lastPublicationMilliseconds; + + public TerrainDrawDiagnosticsController( + bool enabled, + WorldRenderDiagnostics world, + IFramePipelineDiagnosticFactsSource facts, + IRenderFrameDiagnosticLog log) + { + _enabled = enabled; + _world = world ?? throw new ArgumentNullException(nameof(world)); + _facts = facts ?? throw new ArgumentNullException(nameof(facts)); + _log = log ?? throw new ArgumentNullException(nameof(log)); + } + + public void Begin() => _world.BeginTerrainDraw(); + + public void Complete() => Complete(_enabled ? Environment.TickCount64 : 0L); + + internal void Complete(long nowMilliseconds) + { + _world.EndTerrainDraw(); + if (!_enabled + || nowMilliseconds - _lastPublicationMilliseconds <= PublicationIntervalMilliseconds) + { + return; + } + + // Both writes are one diagnostic transaction. Any failure leaves the + // cadence uncommitted so a later terrain draw retries both publications. + _world.PublishTerrainDiagnostics(_facts.CaptureTerrain()); + _log.WriteLine(FormatFrame(_facts.CaptureFrame())); + _lastPublicationMilliseconds = nowMilliseconds; + } + + internal static string FormatFrame(FramePipelineDiagnosticFacts facts) + { + LandblockRenderPublisherDiagnostics render = facts.Presentation.Render; + LandblockPhysicsPublisherDiagnostics physics = facts.Presentation.Physics; + LandblockStaticPresentationDiagnostics statics = facts.Presentation.Statics; + double ticksToMicros = 1_000_000.0 / Stopwatch.Frequency; + double uploadMedian = facts.UploadTiming.MedianHundredthsMicroseconds / 100.0; + double uploadP95 = facts.UploadTiming.Percentile95HundredthsMicroseconds / 100.0; + return + $"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} " + + $"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} " + + $"begin={render.BeginPublishTicks * ticksToMicros:F1} " + + $"complete={render.CompletePublishTicks * ticksToMicros:F1}] " + + $"physics_total_us=[base={physics.BasePublishTicks * ticksToMicros:F1} " + + $"gfx={physics.GfxCacheTicks * ticksToMicros:F1} " + + $"complete={physics.CompletePublishTicks * ticksToMicros:F1}] " + + $"static={statics.BeginCount}/{statics.CompleteCount}" + + $"(active={statics.ActiveEntityCount}) " + + $"entUpl_us={uploadMedian:F1}m/{uploadP95:F1}p95 " + + $"deferred={facts.DeferredApplyBacklog} " + + $"fullRetire={facts.FullWindowRetirementCount}" + + $"(landblocks={facts.LastFullWindowRetirementLandblockCount}) " + + $"esg={facts.MaterializedEntityCount} spawn={facts.SpawnSnapshotCount} " + + $"resident={facts.ResidentEntityCount} walked={facts.EntitiesWalked}"; + } +} diff --git a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs index 57da6334..50cdb85d 100644 --- a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs +++ b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs @@ -2,6 +2,7 @@ using System.Numerics; using System.Diagnostics; using System.Text; using AcDream.Core.Vfx; +using AcDream.Core.World; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; @@ -123,6 +124,12 @@ internal sealed class WorldRenderDiagnostics private string? _lastScissorSignature; private long _scissorSequence; private string? _lastOutStageSignature; + private string? _lastOutStageRoutingSignature; + private readonly Dictionary _outStageOwnerVerdicts = []; + private readonly Dictionary _phantomObjectSignatures = []; + private string? _lastClipRouteSignature; + private long _clipRouteSequence; + private readonly List _clipRouteCellKeys = []; public WorldRenderDiagnostics( IRenderGlStateReader gl, @@ -192,6 +199,177 @@ internal sealed class WorldRenderDiagnostics _log.WriteLine($"[clip-route-scis] n={_scissorSequence} {signature}"); } + public void EmitClipRouteProbe( + bool enabled, + ClipFrame clipFrame, + ClipFrameAssembly clipAssembly, + ClipViewSlice slice, + int sliceIndex) + { + if (!enabled) + return; + + var text = new StringBuilder(256); + text.Append(FormattableString.Invariant( + $"slice={sliceIndex}/{clipAssembly.OutsideViewSlices.Length} slot={slice.Slot}")); + text.Append(FormattableString.Invariant( + $" ndc=({slice.NdcAabb.X:F3},{slice.NdcAabb.Y:F3},{slice.NdcAabb.Z:F3},{slice.NdcAabb.W:F3})")); + text.Append(FormattableString.Invariant($" planes={slice.Planes.Length}[")); + for (int i = 0; i < slice.Planes.Length; i++) + { + Vector4 plane = slice.Planes[i]; + if (i > 0) + text.Append(' '); + text.Append(FormattableString.Invariant( + $"({plane.X:F3},{plane.Y:F3},{plane.Z:F3},{plane.W:F3})")); + } + + text.Append("] cells={"); + _clipRouteCellKeys.Clear(); + foreach (uint key in clipAssembly.CellIdToSlot.Keys) + _clipRouteCellKeys.Add(key); + _clipRouteCellKeys.Sort(); + for (int i = 0; i < _clipRouteCellKeys.Count; i++) + { + if (i > 0) + text.Append(','); + text.Append(FormattableString.Invariant( + $"0x{_clipRouteCellKeys[i]:X8}:{clipAssembly.CellIdToSlot[_clipRouteCellKeys[i]]}")); + } + text.Append('}'); + + ReadOnlySpan regionBytes = clipFrame.RegionBytesForTest; + int offset = slice.Slot * ClipFrame.CellClipStrideBytes; + if (offset >= 0 && offset + ClipFrame.CellClipStrideBytes <= regionBytes.Length) + { + uint count = BitConverter.ToUInt32(regionBytes.Slice(offset, 4)); + text.Append(FormattableString.Invariant($" ssbo[{slice.Slot}]: n={count}")); + int planeCount = (int)Math.Min(count, (uint)ClipFrame.MaxPlanes); + for (int i = 0; i < planeCount; i++) + { + int planeOffset = offset + ClipFrame.CellClipPlanesOffset + i * 16; + float x = BitConverter.ToSingle(regionBytes.Slice(planeOffset, 4)); + float y = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 4, 4)); + float z = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 8, 4)); + float w = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 12, 4)); + text.Append(FormattableString.Invariant($" ({x:F3},{y:F3},{z:F3},{w:F3})")); + } + } + else + { + text.Append(FormattableString.Invariant( + $" ssbo[{slice.Slot}]: OUT-OF-RANGE len={regionBytes.Length}")); + } + + ReadOnlySpan terrainBytes = clipFrame.TerrainBytesForTest; + int terrainCount = BitConverter.ToInt32(terrainBytes[..4]); + float p0 = BitConverter.ToSingle(terrainBytes.Slice(16, 4)); + float p1 = BitConverter.ToSingle(terrainBytes.Slice(20, 4)); + float p2 = BitConverter.ToSingle(terrainBytes.Slice(24, 4)); + float p3 = BitConverter.ToSingle(terrainBytes.Slice(28, 4)); + text.Append(FormattableString.Invariant( + $" ubo: n={terrainCount} p0=({p0:F3},{p1:F3},{p2:F3},{p3:F3})")); + + string signature = text.ToString(); + _clipRouteSequence++; + if (signature == _lastClipRouteSignature) + return; + _lastClipRouteSignature = signature; + _log.WriteLine($"[clip-route] n={_clipRouteSequence} {signature}"); + } + + public void EmitOutStageOwner( + bool enabled, + IReadOnlySet watchedEntityIds, + WorldEntity entity, + Vector3 sphereCenter, + float sphereRadius, + int sliceIndex, + bool passed) + { + if (!enabled + || !watchedEntityIds.Contains(entity.Id) + || (_outStageOwnerVerdicts.TryGetValue(entity.Id, out bool previous) + && previous == passed)) + { + return; + } + + _outStageOwnerVerdicts[entity.Id] = passed; + _log.WriteLine(FormattableString.Invariant( + $"[outstage-own] id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) c=({sphereCenter.X:F1},{sphereCenter.Y:F1},{sphereCenter.Z:F1}) r={sphereRadius:F1} slice={sliceIndex} {(passed ? "PASS" : "CULL")}")); + } + + public void EmitOutStageRouting( + bool enabled, + int sliceIndex, + IReadOnlyList entities, + ViewconeCuller viewcone) + { + if (!enabled) + return; + + var text = new StringBuilder(192); + text.Append("slice=").Append(sliceIndex) + .Append(" outStage=").Append(entities.Count).Append(" ["); + for (int i = 0; i < entities.Count; i++) + { + WorldEntity entity = entities[i]; + EntitySphere(entity, out Vector3 center, out float radius); + bool passed = viewcone.SphereVisibleInOutsideSlice( + sliceIndex, + center, + radius); + if (i > 0) + text.Append(' '); + text.Append(FormattableString.Invariant( + $"0x{(entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id):X8}(s{entity.SourceGfxObjOrSetupId:X8}):{(passed ? "PASS" : "CULL")}:r={radius:F1}")); + } + text.Append(']'); + string signature = text.ToString(); + if (signature == _lastOutStageRoutingSignature) + return; + _lastOutStageRoutingSignature = signature; + _log.WriteLine("[outstage] " + signature); + } + + public void EmitPhantomObjects(bool enabled, uint cellId, int survivorCount) + { + if (!enabled + || (_phantomObjectSignatures.TryGetValue(cellId, out int previous) + && previous == survivorCount)) + { + return; + } + + _phantomObjectSignatures[cellId] = survivorCount; + _log.WriteLine( + $"[phantom-objs] cell=0x{cellId:X8} entities={survivorCount} (drawn unclipped, no viewcone)"); + } + + public void EmitSeamMask( + bool enabled, + IReadOnlySet targetCells, + uint cellId, + int portalIndex, + bool forceFarZ, + ReadOnlySpan vertices) + { + if (!enabled || !targetCells.Contains(cellId)) + return; + + float minimumZ = float.MaxValue; + float maximumZ = float.MinValue; + foreach (Vector3 vertex in vertices) + { + minimumZ = Math.Min(minimumZ, vertex.Z); + maximumZ = Math.Max(maximumZ, vertex.Z); + } + + _log.WriteLine(FormattableString.Invariant( + $"[seam-mask] t={Environment.TickCount64} cell=0x{cellId:X8} portal={portalIndex} far={forceFarZ} n={vertices.Length} z=[{minimumZ:F3},{maximumZ:F3}]")); + } + public void EmitPViewInput( bool enabled, PortalVisibilityFrame portalFrame, @@ -590,4 +768,15 @@ internal sealed class WorldRenderDiagnostics .Append(" live=").Append(partition.Dynamics.Count) .ToString(); } + + private static void EntitySphere( + WorldEntity entity, + out Vector3 center, + out float radius) + { + if (entity.AabbDirty) + entity.RefreshAabb(); + center = (entity.AabbMin + entity.AabbMax) * 0.5f; + radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f; + } } diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 889f5517..20fa9dac 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -192,7 +192,7 @@ public static class RenderingDiagnostics /// slot, and the terrain-UBO head as uploaded; WbDrawDispatcher.Draw emits one /// [clip-route-disp] line (print-on-change, routed draws only) with the per-slot /// instance histogram exactly as uploaded to binding=3 plus the culled-entity count; and - /// GameWindow.DrawRetailPViewLandscapeSlice emits one [clip-route-scis] line + /// RetailPViewPassExecutor.DrawLandscapeSlice emits one [clip-route-scis] line /// (print-on-change) with the ACTUAL GL scissor enable + box the landscape pass draws under. /// Throwaway apparatus — strip once §4 ships. Initial state from ACDREAM_PROBE_CLIPROUTE=1. /// @@ -325,7 +325,7 @@ public static class RenderingDiagnostics /// parented to a target cell — position, culled/slot, resolved light set. A /// floor-coincident entity (plate/static) would be the z-fight's second draw; /// the player entity doubles as the probe's positive control. - /// [seam-mask]GameWindow.DrawRetailPViewPortalDepthWrite: + /// [seam-mask]RetailPViewPassExecutor.DrawPortalDepthWrite: /// every portal depth fan drawn in a target cell. A sealed dungeon must show /// ZERO (seals fire only for OtherCellId==0xFFFF) — any line is a finding. /// diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index b56d9b31..6b1c9628 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -106,6 +106,14 @@ public sealed class GameWindowRenderLeafCompositionTests Assert.Contains("_renderFrameResources = null;", source); Assert.Contains("_renderFrameLivePreparation = null;", source); Assert.Contains("_renderWeatherFrame = null;", source); + AssertAppearsInOrder( + source, + "new ResourceShutdownStage(\"frame borrowers\"", + "_retailPViewPassExecutor = null;", + "_retailPViewCells = null;", + "_terrainDrawDiagnostics = null;", + "_retailPViewRenderer = null;", + "new ResourceShutdownStage(\"session dependents\""); AssertAppearsInOrder( source, "new(\"frame pacing\", _displayFramePacing.Dispose)", @@ -143,15 +151,15 @@ public sealed class GameWindowRenderLeafCompositionTests } [Fact] - public void TerrainAndFrameDiagnostics_CommitOneSharedCadenceAfterBothWrites() + public void TerrainAndFrameDiagnostics_AreComposedAsOneFocusedOwner() { string source = GameWindowSource(); - AssertAppearsInOrder( - source, - "_worldRenderDiagnostics?.PublishTerrainDiagnostics(", - "PublishFrameDiagnostics();", - "_frameDiagLastPublicationMilliseconds = now;"); + Assert.Contains( + "new AcDream.App.Rendering.TerrainDrawDiagnosticsController(", + source); + Assert.Contains("_terrainDrawDiagnostics!.Begin();", source); + Assert.Contains("_terrainDrawDiagnostics.Complete();", source); } private static string GameWindowSource() => File.ReadAllText(Path.Combine( diff --git a/tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs b/tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs index a011beb5..e0c93dd8 100644 --- a/tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs @@ -22,7 +22,7 @@ namespace AcDream.App.Tests.Rendering; /// → the exact DrawDynamicsLast visibility predicate /// (RetailPViewRenderer.cs:375-401), PLUS the depth relationship DrawDynamicsLast is /// subject to: under an INTERIOR root the exit-portal SEAL stamps the door fan at TRUE -/// depth (GameWindow.DrawRetailPViewPortalDepthWrite, forceFarZ=false) after the full +/// depth (RetailPViewPassExecutor.DrawPortalDepthWrite, forceFarZ=false) after the full /// depth clear, and dynamics draw depth-tested AFTER it. /// /// The four candidates this pins (handoff §5 + this session's read): @@ -261,7 +261,11 @@ public class HouseExitWalkReplayTests var drawable = new HashSet(pv.OrderedVisibleCells); bool outsideStage = rootCellId != 0u && RetailPViewRenderer.DynamicDrawsInOutsideStage( - playerCell, sphereC, PlayerSphereRadius, drawable, lookup); + playerCell, + sphereC, + PlayerSphereRadius, + drawable, + new TestCellSource(lookup)); // Candidate 4: the seal depth check. Applies when the root is INTERIOR // (ClearDepthForInterior + the TRUE-depth seal run, GameWindow:7719-7729), @@ -483,4 +487,10 @@ public class HouseExitWalkReplayTests $"eye {d * 100,5:F1} cm OUTSIDE + root still 0x{ExitCellId:X8}: outPolys={pv.OutsideView.Polygons.Count} flood={pv.OrderedVisibleCells.Count} playerConeVisible={playerVisible}")); } } + + private sealed class TestCellSource(Func find) : + IRetailPViewCellSource + { + public LoadedCell? Find(uint cellId) => find(cellId); + } } diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs new file mode 100644 index 00000000..3d1a21d7 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -0,0 +1,540 @@ +using System.Numerics; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RetailPViewPassExecutorTests +{ + [Fact] + public void DrawInside_outdoor_executes_the_real_typed_sequence_without_interior_clear() + { + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor(); + LoadedCell root = OutdoorCellNode.Build(0xA9B40000u); + + renderer.DrawInside(Frame(root), executor); + + Assert.Equal( + [ + "begin", + "assemble", + "prepare-clip:3", + "indoor-routing", + "prepare-cells", + "diagnostics", + "terrain-clip", + "clear-routing", + "landscape-early", + "terrain-clip", + "clear-routing", + "outstage-routing", + "landscape-late", + "landscape-alpha", + "indoor-routing", + "indoor-routing", + "exit-mask", + "indoor-routing", + "opaque-shells", + ], + executor.Operations); + } + + [Fact] + public void DrawInside_interior_exit_flushes_before_clear_and_resets_borrowed_result() + { + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor(); + LoadedCell outdoor = OutdoorCellNode.Build(0xA9B40000u); + RetailPViewFrameResult borrowed = renderer.DrawInside(Frame(outdoor), executor); + Assert.Contains(outdoor.CellId, borrowed.DrawableCells); + + executor.Operations.Clear(); + LoadedCell interior = InteriorWithExit(0xA9B40100u); + RetailPViewFrameResult reused = renderer.DrawInside(Frame(interior), executor); + + Assert.Same(borrowed, reused); + Assert.DoesNotContain(outdoor.CellId, reused.DrawableCells); + Assert.Contains(interior.CellId, reused.DrawableCells); + AssertAppearsInOrder( + string.Join('|', executor.Operations), + "landscape-early", + "landscape-late", + "unattached-particles", + "landscape-alpha", + "interior-depth-clear", + "indoor-routing", + "exit-mask"); + } + + [Fact] + public void DrawInside_interior_without_an_outside_slice_skips_landscape_and_depth_clear() + { + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor(); + var root = new LoadedCell + { + CellId = 0xA9B40100u, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + + renderer.DrawInside(Frame(root), executor); + + Assert.DoesNotContain("terrain-clip", executor.Operations); + Assert.DoesNotContain("landscape-early", executor.Operations); + Assert.DoesNotContain("landscape-late", executor.Operations); + Assert.DoesNotContain("landscape-alpha", executor.Operations); + Assert.DoesNotContain("interior-depth-clear", executor.Operations); + } + + [Fact] + public void Particle_classifications_reset_before_an_empty_following_frame() + { + var classifications = new RetailPViewParticleClassifications(); + classifications.ReplaceOutdoor([Entity(7u)]); + classifications.Visible.Add(8u); + classifications.Dynamics.Add(9u); + + classifications.BeginFrame(); + + Assert.Empty(classifications.Outdoor); + Assert.Empty(classifications.Visible); + Assert.Empty(classifications.Dynamics); + } + + [Fact] + public void DrawInside_real_fixtures_reach_entity_particle_and_transparent_shell_operations() + { + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor { HasTransparentShells = true }; + LoadedCell interior = InteriorWithExit(0xA9B40100u); + WorldEntity cellStatic = Entity(10u, parentCellId: interior.CellId); + WorldEntity dynamic = Entity( + 11u, + serverGuid: 0x80000011u, + parentCellId: interior.CellId); + + renderer.DrawInside(Frame(interior, [cellStatic, dynamic]), executor); + + Assert.Contains("opaque-shells", executor.Operations); + Assert.Contains("transparent-shells-ordered", executor.Operations); + Assert.Contains("entity-bucket", executor.Operations); + Assert.Contains("cell-particles", executor.Operations); + Assert.Contains("dynamics-particles", executor.Operations); + Assert.Contains("phantom-objects", executor.Operations); + } + + [Fact] + public void DrawInside_interior_root_executes_the_nearby_building_look_in_punch() + { + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor(); + LoadedCell root = InteriorWithExit(0xA9B40100u); + root.BuildingId = 1u; + LoadedCell[] building = NearbyTwoCellBuilding(); + + renderer.DrawInside( + Frame( + root, + nearbyBuildingCells: building, + additionalCells: building), + executor); + + Assert.Contains("look-in-punch", executor.Operations); + } + + [Fact] + public void Renderer_source_preserves_retail_stage_order_through_typed_operations() + { + string source = RendererSource(); + string drawInside = MethodBody(source, "public RetailPViewFrameResult DrawInside("); + AssertAppearsInOrder( + drawInside, + "passes.BeginFrame();", + "passes.EmitDiagnostics(ctx, result);", + "DrawLandscapeThroughOutsideView(ctx, passes", + "DrawExitPortalMasks(ctx, passes", + "DrawEnvCellShells(passes, pvFrame);", + "DrawCellObjectLists(ctx, passes", + "DrawDynamicsLast(ctx, passes"); + + string landscape = MethodBody( + source, + "private void DrawLandscapeThroughOutsideView("); + AssertAppearsInOrder( + landscape, + "passes.SetTerrainClip(slice.Planes);", + "passes.DrawLandscapeSlice(ctx", + "DrawBuildingLookIns(ctx, passes", + "passes.DrawLandscapeSliceLate(ctx", + "passes.DrawUnattachedSceneParticles(ctx);", + "passes.FlushLandscapeAlpha();", + "passes.ClearInteriorDepth();"); + + string lookIns = MethodBody(source, "private void DrawBuildingLookIns("); + AssertAppearsInOrder( + lookIns, + "passes.DrawLookInPortalPunch(ctx", + "passes.DrawOpaqueCellShells(_shellBatch);", + "passes.DrawCellParticles(ctx"); + } + + [Fact] + public void Extracted_contracts_retain_no_window_callbacks_or_visibility_owner() + { + Assert.DoesNotContain( + typeof(RetailPViewFrameInput).GetProperties(), + property => typeof(Delegate).IsAssignableFrom(property.PropertyType)); + + FieldInfo[] fields = typeof(RetailPViewPassExecutor).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(CellVisibility)); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(RetailPViewFrameInput)); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(RetailPViewFrameResult)); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(ClipFrameAssembly)); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + [Fact] + public void Concrete_executor_forwards_frame_reset_and_brackets_terrain_diagnostics() + { + string source = ExecutorClassSource(); + string begin = MethodBody(source, "public void BeginFrame()"); + Assert.Contains("_particleClassifications.BeginFrame();", begin); + + string landscape = MethodBody(source, "public void DrawLandscapeSlice("); + AssertAppearsInOrder( + landscape, + "_terrainDiagnostics.Begin();", + "_terrain?.Draw(", + "_terrainDiagnostics.Complete();"); + + Assert.DoesNotContain("Console.WriteLine", source); + Assert.DoesNotContain("Console.WriteLine", RendererSource()); + } + + [Fact] + public void GameWindow_composes_one_executor_instead_of_a_draw_callback_bag() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.Contains("new AcDream.App.Rendering.RetailPViewPassExecutor(", source); + Assert.Contains("new AcDream.App.Rendering.RetailPViewFrameInput", source); + Assert.DoesNotContain("RetailPViewDrawContext", source); + Assert.DoesNotContain("DrawLandscapeSlice =", source); + Assert.DoesNotContain("DrawExitPortalMasks =", source); + } + + private static string RendererSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "RetailPViewRenderer.cs")); + + private static string ExecutorSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "RetailPViewPassExecutor.cs")); + + private static string ExecutorClassSource() + { + string source = ExecutorSource(); + int start = source.IndexOf( + "internal sealed class RetailPViewPassExecutor", + StringComparison.Ordinal); + Assert.True(start >= 0, "Missing concrete RetailPViewPassExecutor."); + return source[start..]; + } + + private static RetailPViewFrameInput Frame( + LoadedCell root, + IReadOnlyList? entities = null, + IReadOnlyList? nearbyBuildingCells = null, + IReadOnlyList? additionalCells = null) + { + var cells = new Dictionary { [root.CellId] = root }; + if (additionalCells is not null) + { + foreach (LoadedCell cell in additionalCells) + cells[cell.CellId] = cell; + } + + var entries = new List<( + uint LandblockId, + Vector3 AabbMin, + Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)>(); + if (entities is not null) + { + entries.Add(( + root.CellId & 0xFFFF0000u, + Vector3.Zero, + Vector3.Zero, + entities, + null)); + } + + return new RetailPViewFrameInput + { + RootCell = root, + NearbyBuildingCells = nearbyBuildingCells, + ViewerEyePos = Vector3.Zero, + ViewProjection = TestCamera.ViewProjection, + Cells = new DictionaryCellSource(cells), + Camera = new TestCamera(), + CameraWorldPosition = Vector3.Zero, + Frustum = null, + PlayerLandblockId = root.CellId & 0xFFFF0000u, + AnimatedEntityIds = null, + RenderCenterLbX = 0, + RenderCenterLbY = 0, + RenderRadius = 1, + LandblockEntries = entries, + RenderSky = true, + RenderWeather = true, + DayFraction = 0f, + ActiveDayGroup = null, + SkyKeyframe = default, + EnvironOverrideActive = false, + ViewerCellId = root.CellId, + PlayerCellId = root.CellId, + PlayerViewPosition = Vector3.Zero, + CameraView = TestCamera.ViewMatrix, + CameraCellResolution = CameraCellResolution.None, + }; + } + + private static LoadedCell InteriorWithExit(uint cellId) + { + var cell = new LoadedCell + { + CellId = cellId, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Portals = [new CellPortalInfo(0xFFFF, 0, 0, 0)], + }; + cell.PortalPolygons.Add( + [ + new Vector3(-1f, -1f, -2f), + new Vector3(1f, -1f, -2f), + new Vector3(1f, 1f, -2f), + new Vector3(-1f, 1f, -2f), + ]); + return cell; + } + + private static LoadedCell[] NearbyTwoCellBuilding() + { + const uint vestibuleId = 0xA9B40170u; + const uint roomId = 0xA9B40171u; + var vestibule = new LoadedCell + { + CellId = vestibuleId, + BuildingId = 2u, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Portals = + [ + new CellPortalInfo(0xFFFF, 0, 0, 0), + new CellPortalInfo(0x0171, 1, 0, 0), + ], + ClipPlanes = + [ + new PortalClipPlane + { + Normal = new Vector3(0, 0, 1), + D = 3f, + InsideSide = 1, + }, + ], + }; + vestibule.PortalPolygons.Add( + [ + new Vector3(-0.5f, -0.5f, -2f), + new Vector3(0.5f, -0.5f, -2f), + new Vector3(0.5f, 0.5f, -2f), + new Vector3(-0.5f, 0.5f, -2f), + ]); + vestibule.PortalPolygons.Add( + [ + new Vector3(-0.6f, -0.6f, -4f), + new Vector3(0.6f, -0.6f, -4f), + new Vector3(0.6f, 0.6f, -4f), + new Vector3(-0.6f, 0.6f, -4f), + ]); + + var room = new LoadedCell + { + CellId = roomId, + BuildingId = 2u, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Portals = [new CellPortalInfo(0x0170, 0, 0, 1)], + }; + room.PortalPolygons.Add( + [ + new Vector3(-0.6f, -0.6f, -4f), + new Vector3(0.6f, -0.6f, -4f), + new Vector3(0.6f, 0.6f, -4f), + new Vector3(-0.6f, 0.6f, -4f), + ]); + return [vestibule, room]; + } + + private static WorldEntity Entity( + uint id, + uint serverGuid = 0, + uint? parentCellId = null) => new() + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)], + ParentCellId = parentCellId, + }; + + private static string MethodBody(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, $"Missing method: {signature}"); + int open = source.IndexOf('{', start); + int depth = 0; + for (int index = open; index < source.Length; index++) + { + if (source[index] == '{') + depth++; + else if (source[index] == '}' && --depth == 0) + return source[start..(index + 1)]; + } + + throw new InvalidOperationException($"Unterminated method: {signature}"); + } + + private static void AssertAppearsInOrder(string source, params string[] needles) + { + int cursor = -1; + foreach (string needle in needles) + { + int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal); + Assert.True(next > cursor, $"Missing or out-of-order fragment: {needle}"); + cursor = next; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } + + private sealed class DictionaryCellSource( + IReadOnlyDictionary cells) : IRetailPViewCellSource + { + public LoadedCell? Find(uint cellId) => + cells.TryGetValue(cellId, out LoadedCell? cell) ? cell : null; + } + + private sealed class TestCamera : ICamera + { + public static Matrix4x4 ViewMatrix { get; } = Matrix4x4.CreateLookAt( + Vector3.Zero, + new Vector3(0, 0, -1), + Vector3.UnitY); + + public static Matrix4x4 ViewProjection { get; } = ViewMatrix + * Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f); + + public Matrix4x4 View => ViewMatrix; + public Matrix4x4 Projection { get; } = + Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f); + public float Aspect { get; set; } = 1f; + } + + private sealed class RecordingExecutor : IRetailPViewPassExecutor, IDisposable + { + private readonly ClipFrame _clipFrame = ClipFrame.NoClip(); + + public List Operations { get; } = []; + public bool HasTransparentShells { get; init; } + + public void BeginFrame() => Operations.Add("begin"); + public ClipFrameAssembly AssembleClipFrame( + PortalVisibilityFrame portalFrame, + ClipFrameAssembly reuseAssembly) + { + Operations.Add("assemble"); + return ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly); + } + + public void PrepareClipFrame(int terrainUploadCount) => + Operations.Add($"prepare-clip:{terrainUploadCount}"); + + public void SetTerrainClip(ReadOnlySpan planes) => + Operations.Add("terrain-clip"); + + public void ClearClipRouting() => Operations.Add("clear-routing"); + public void UseIndoorMembershipOnlyRouting() => Operations.Add("indoor-routing"); + public void PrepareCellBatches(RetailPViewFrameInput frame, HashSet visibleCellIds) => + Operations.Add("prepare-cells"); + public void DrawOpaqueCellShells(HashSet cellIds) => Operations.Add("opaque-shells"); + public bool CellHasTransparentShell(uint cellId) => HasTransparentShells; + public void DrawTransparentCellShells(HashSet cellIds) => Operations.Add("transparent-shells"); + public void DrawTransparentCellShellsOrdered(IReadOnlyList cellIds) => + Operations.Add("transparent-shells-ordered"); + public void DrawEntityBucket( + RetailPViewFrameInput frame, + IReadOnlyList entities, + HashSet? visibleCellIds) => Operations.Add("entity-bucket"); + public void EmitClipRouteProbe( + ClipFrameAssembly clipAssembly, + ClipViewSlice slice, + int sliceIndex) => Operations.Add("clip-probe"); + public void EmitOutStageOwner( + WorldEntity entity, + Vector3 sphereCenter, + float sphereRadius, + int sliceIndex, + bool passed) => Operations.Add("outstage-owner"); + public void EmitOutStageRouting( + int sliceIndex, + IReadOnlyList entities, + ViewconeCuller viewcone) => Operations.Add("outstage-routing"); + public void EmitPhantomObjects(uint cellId, int survivorCount) => + Operations.Add("phantom-objects"); + public void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context) => Operations.Add("landscape-early"); + public void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context) => Operations.Add("landscape-late"); + public void ClearInteriorDepth() => Operations.Add("interior-depth-clear"); + public void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("exit-mask"); + public void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("look-in-punch"); + public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame) => Operations.Add("unattached-particles"); + public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha"); + public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles"); + public void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlyList survivors) => Operations.Add("dynamics-particles"); + public void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result) => Operations.Add("diagnostics"); + + public void Dispose() => _clipFrame.Dispose(); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs new file mode 100644 index 00000000..e15516a7 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs @@ -0,0 +1,153 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class TerrainDrawDiagnosticsControllerTests +{ + [Fact] + public void Disabled_controller_still_closes_the_terrain_sample_without_publication() + { + var log = new RecordingLog(); + var facts = new RecordingFacts(); + var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var controller = new TerrainDrawDiagnosticsController(false, world, facts, log); + + controller.Begin(); + controller.Complete(10_000); + + Assert.Equal(0, facts.TerrainCaptureCount); + Assert.Equal(0, facts.FrameCaptureCount); + Assert.Empty(log.Messages); + } + + [Fact] + public void Publication_failure_does_not_commit_the_shared_cadence() + { + var log = new ThrowOnceLog(); + var facts = new RecordingFacts(); + var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var controller = new TerrainDrawDiagnosticsController(true, world, facts, log); + + controller.Begin(); + Assert.Throws(() => controller.Complete(10_000)); + + controller.Begin(); + controller.Complete(10_001); + + Assert.Equal(2, facts.TerrainCaptureCount); + Assert.Equal(1, facts.FrameCaptureCount); + Assert.Collection( + log.Messages, + terrain => Assert.StartsWith("[TERRAIN-DIAG]", terrain), + frame => Assert.StartsWith("[FRAME-DIAG]", frame)); + } + + [Fact] + public void Frame_log_failure_retries_both_shared_publications() + { + var log = new ThrowOnFirstFrameLog(); + var facts = new RecordingFacts(); + var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var controller = new TerrainDrawDiagnosticsController(true, world, facts, log); + + controller.Begin(); + Assert.Throws(() => controller.Complete(10_000)); + + controller.Begin(); + controller.Complete(10_001); + + Assert.Equal(2, facts.TerrainCaptureCount); + Assert.Equal(2, facts.FrameCaptureCount); + Assert.Collection( + log.Messages, + firstTerrain => Assert.StartsWith("[TERRAIN-DIAG]", firstTerrain), + retryTerrain => Assert.StartsWith("[TERRAIN-DIAG]", retryTerrain), + frame => Assert.StartsWith("[FRAME-DIAG]", frame)); + } + + [Fact] + public void Successful_publication_waits_for_the_next_interval() + { + var log = new RecordingLog(); + var facts = new RecordingFacts(); + var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var controller = new TerrainDrawDiagnosticsController(true, world, facts, log); + + controller.Begin(); + controller.Complete(10_000); + controller.Begin(); + controller.Complete(14_999); + controller.Begin(); + controller.Complete(15_000); + controller.Begin(); + controller.Complete(15_001); + + Assert.Equal(2, facts.TerrainCaptureCount); + Assert.Equal(2, facts.FrameCaptureCount); + Assert.Equal(4, log.Messages.Count); + } + + private sealed class RecordingFacts : IFramePipelineDiagnosticFactsSource + { + public int TerrainCaptureCount { get; private set; } + public int FrameCaptureCount { get; private set; } + + public TerrainRenderDiagnosticFacts CaptureTerrain() + { + TerrainCaptureCount++; + return new TerrainRenderDiagnosticFacts(1, 2, 3); + } + + public FramePipelineDiagnosticFacts CaptureFrame() + { + FrameCaptureCount++; + return default; + } + } + + private sealed class NullGlStateReader : IRenderGlStateReader + { + public RenderGlStateSnapshot CaptureState() => default; + public RenderGlScissorSnapshot CaptureScissor() => default; + } + + private sealed class RecordingLog : IRenderFrameDiagnosticLog + { + public List Messages { get; } = []; + public void WriteLine(string message) => Messages.Add(message); + } + + private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog + { + private bool _throw = true; + public List Messages { get; } = []; + + public void WriteLine(string message) + { + if (_throw) + { + _throw = false; + throw new InvalidOperationException("diagnostic failure"); + } + + Messages.Add(message); + } + } + + private sealed class ThrowOnFirstFrameLog : IRenderFrameDiagnosticLog + { + private bool _throw = true; + public List Messages { get; } = []; + + public void WriteLine(string message) + { + if (_throw && message.StartsWith("[FRAME-DIAG]", StringComparison.Ordinal)) + { + _throw = false; + throw new InvalidOperationException("frame diagnostic failure"); + } + + Messages.Add(message); + } + } +} From 28e1cf80290cf0a303f9a739e8a53e9d74be8b7e Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 07:22:09 +0200 Subject: [PATCH 87/98] refactor(render): extract world scene frame owner Move fallback and PView world drawing, shared alpha, particles, visibility, selection, and diagnostics behind focused frame owners. Make exceptional frames failure-atomic by restoring the shared GL baseline and preserving primary alpha failures through cleanup. Co-authored-by: Codex --- .../retail-divergence-register.md | 2 +- ...ndow-slice-7-render-frame-orchestration.md | 18 +- src/AcDream.App/Rendering/GameWindow.cs | 982 ++++-------------- .../Rendering/PortalDepthMaskRenderer.cs | 4 +- .../Rendering/RenderFrameGlStateController.cs | 113 ++ .../RenderFrameResourceController.cs | 19 +- src/AcDream.App/Rendering/RetailAlphaQueue.cs | 154 ++- .../Rendering/RetailPViewPassExecutor.cs | 35 +- .../Rendering/RetailPViewRenderer.cs | 4 +- .../Selection/RetailSelectionScene.cs | 23 +- .../Vfx/ParticleVisibilityController.cs | 20 +- .../Rendering/WorldRenderFrameBuilder.cs | 14 +- .../WorldSceneDiagnosticsController.cs | 274 +++++ .../Rendering/WorldScenePassExecutor.cs | 328 ++++++ .../Rendering/WorldSceneRenderer.cs | 345 ++++++ .../Rendering/WorldSceneRuntimeSources.cs | 117 +++ .../GameWindowRenderLeafCompositionTests.cs | 13 +- .../RenderFrameGlStateControllerTests.cs | 106 ++ .../RenderFrameResourceControllerTests.cs | 4 +- .../Rendering/RetailAlphaQueueTests.cs | 94 ++ .../Rendering/RetailPViewPassExecutorTests.cs | 23 +- .../Rendering/RetailSelectionSceneTests.cs | 23 + .../Vfx/ParticleVisibilityControllerTests.cs | 31 + .../Rendering/WorldRenderFrameBuilderTests.cs | 33 +- .../Rendering/WorldSceneRendererTests.cs | 744 +++++++++++++ 25 files changed, 2679 insertions(+), 844 deletions(-) create mode 100644 src/AcDream.App/Rendering/RenderFrameGlStateController.cs create mode 100644 src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs create mode 100644 src/AcDream.App/Rendering/WorldScenePassExecutor.cs create mode 100644 src/AcDream.App/Rendering/WorldSceneRenderer.cs create mode 100644 src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RenderFrameGlStateControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 5f8ee709..bf61148e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -82,7 +82,7 @@ accepted-divergence entries (#96, #49, #50). | AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) | | AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw` → `DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 | | AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 | -| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameRootSource`, `WorldRenderFrame.ClipRoot`); `src/AcDream.App/Rendering/GameWindow.cs` (null-root safety draw) | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 | +| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameRootSource`, `WorldRenderFrame.ClipRoot`); `src/AcDream.App/Rendering/WorldSceneRenderer.cs` (null-root safety draw) | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 | | AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams | | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md index cfbaaa50..d10a9126 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md +++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md @@ -26,7 +26,7 @@ port, or resource-lifetime redesign. run the dependent audio, sky, lighting, and listener preparation. - [x] E — replace `RetailPViewDrawContext`'s callback bag with a typed `RetailPViewPassExecutor` and data-only inputs. -- [ ] F — extract `WorldSceneRenderer`, including both shared-alpha scopes, +- [x] F — extract `WorldSceneRenderer`, including both shared-alpha scopes, PView/fallback world branches, particles, debug world draw, and completion. - [ ] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one handoff, and delete obsolete frame bodies, fields, helpers, and callbacks. @@ -451,6 +451,22 @@ corrected-diff reviews are clean. - return a small `WorldRenderFrameOutcome` for diagnostics/presentation; - delete the world draw body and helper closure from `GameWindow`. +Completed 2026-07-22. `WorldSceneRenderer` now owns the normal-world +transaction, one shared-alpha frame, retail selection accumulation, PView versus +null-root fallback branching, post-world particles/weather, visibility +publication, and world diagnostics. `WorldScenePassExecutor`, focused runtime +sources, and `WorldSceneDiagnosticsController` carry the concrete GL and data +seams without retaining `GameWindow`, delegates, or borrowed PView products. +Failed frames abort PView, pass routing, particle visibility, alpha, and +selection in reverse ownership order; one shared `RenderFrameGlStateController` +restores scissor/stencil/blend/clip/mask/depth/cull/program/buffer state before +the next clear. Normal portal-depth exits now restore the same cull-off +convention, and alpha cleanup preserves the original draw failure while +attempting every source reset. `GameWindow.cs` is 4,765 raw lines, 69.7% below +the 15,723-line campaign baseline. Release build and the complete suite pass +(7,313 passed / 5 skipped). Retail, architecture, and adversarial +corrected-diff reviews are clean. + ### G — final orchestration cutover - compose concrete resource, world, private-presentation, and diagnostic owners; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 85c3458f..57f1e7a9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -65,8 +65,8 @@ public sealed class GameWindow : IDisposable // wireframes are noisy outdoors and confuse first-time users into // thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel // → Diagnostics → "Toggle collision wires" button toggles too. - private bool _debugCollisionVisible = false; - private int _debugDrawLogOnce = 0; + private readonly AcDream.App.Rendering.WorldSceneDebugState + _worldSceneDebugState = new(); // Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in // favor of the ImGui-backed DebugPanel (see _debugVm below). The @@ -107,6 +107,8 @@ public sealed class GameWindow : IDisposable _renderWeatherFrame; private AcDream.App.Rendering.WorldRenderFrameBuilder? _worldRenderFrameBuilder; + private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState; + private AcDream.App.Rendering.WorldSceneRenderer? _worldSceneRenderer; private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame; private ResourceShutdownTransaction? _shutdown; private readonly DisplayFramePacingController _displayFramePacing; @@ -219,9 +221,8 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer; private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum; - // R1 (render redesign): the per-cell DrawInside flood + its per-frame entity partition. - // _interiorRenderer is constructed once both renderers exist; _interiorPartition is rebuilt - // each frame on an indoor root (null on the outdoor root). + // R1 (render redesign): the per-cell DrawInside flood and concrete pass + // executor are borrowed by WorldSceneRenderer for each normal-world frame. private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor; private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells; @@ -229,7 +230,6 @@ public sealed class GameWindow : IDisposable _terrainDrawDiagnostics; private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel; - private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition; // Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain // UBO). In U.3 a single ClipFrame.NoClip() instance is created lazily (??=) and @@ -240,11 +240,6 @@ public sealed class GameWindow : IDisposable // U.4 replaces the NoClip() frame with one built from the portal-visibility result. private ClipFrame? _clipFrame; - // Flat-world fallback particle scratch. PView classifications belong to - // RetailPViewPassExecutor. - private readonly HashSet _visibleSceneParticleEntityIds = []; - private readonly HashSet _noSceneParticleEntityIds = []; - /// /// Phase 6.4: per-entity animation playback state for entities whose /// MotionTable resolved to a real cycle. The render loop ticks each @@ -470,7 +465,6 @@ public sealed class GameWindow : IDisposable // unconditionally installs a provider. See r12 §11 for the roller // semantics. private long _loadedSkyDayIndex = long.MinValue; - private AcDream.Core.World.DayGroupData? _activeDayGroup; // F7 / F10 debug-cycle steps for time + weather. Initialized out of // range of the real values so the first press hits index 0 of the @@ -589,31 +583,6 @@ public sealed class GameWindow : IDisposable // single-flag reads instead of env-var lookups. True iff // ACDREAM_LIVE=1 was set when the window came up. // Backed by RuntimeOptions.LiveMode via the _options field. - private bool LiveModeEnabled => _options.LiveMode; - - /// - /// K-fix1 (2026-04-26): true iff live mode is configured AND we have - /// NOT yet handed control to the chase camera. Gates initial - /// streaming + scene rendering so the user never sees Holtburg flash - /// before login completes — the screen stays at the sky/fog clear - /// color until the player entity has spawned + the auto-entry guard - /// has triggered the player-mode controller's auto-entry edge. - /// Offline (LiveModeEnabled false) returns false → unchanged - /// orbit-camera Holtburg view stays the foreground. Once chase mode - /// is active the property latches false for the rest of the - /// session, even if the user later toggles into fly mode (we don't - /// want to re-blank the world after they've seen it). - /// - private bool IsLiveModeWaitingForLogin => - LiveModeEnabled - && !_chaseModeEverEntered; - - // Latches true the first time chase mode becomes active. Used by - // IsLiveModeWaitingForLogin to suppress the pre-login render gate - // for subsequent fly-mode toggles. - private bool _chaseModeEverEntered - => _localPlayerMode.ChaseModeEverEntered; - /// /// Phase 6.6/6.7: server-guid → local WorldEntity lookup so /// UpdateMotion and UpdatePosition handlers can find the entity the @@ -715,10 +684,10 @@ public sealed class GameWindow : IDisposable // Samples (MSAA) is baked into WindowOptions correctly. GL context // sample count cannot change at runtime; all other quality fields are // applied again in OnLoad after the full settings load. - var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( + var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); var startupDisplay = startupStore.LoadDisplay(); - var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); + var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); FramePacingPolicy startupPacing = _displayFramePacing.InitializeStartup(startupDisplay.VSync); @@ -769,6 +738,8 @@ public sealed class GameWindow : IDisposable private void OnLoad() { + _worldSceneSkyState ??= new AcDream.App.Rendering.WorldSceneSkyState( + WorldTime); // Task 7: wire the physics data cache into the engine so Transition can // run narrow-phase BSP tests during FindObjCollisions. _physicsEngine.DataCache = _physicsDataCache; @@ -1080,7 +1051,7 @@ public sealed class GameWindow : IDisposable // delegates, no panel-vs-renderer-vs-state coupling. var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat) { - FpsProvider = () => + FpsProvider = () => (float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0), PositionProvider = () => GetDebugPlayerPosition(), }; @@ -1095,52 +1066,53 @@ public sealed class GameWindow : IDisposable // buttons back to the same routines the F2/F7/F10 // keybinds use. _debugVm = new AcDream.UI.Abstractions.Panels.Debug.DebugVM( - getPlayerPosition: () => GetDebugPlayerPosition(), - getPlayerHeadingDeg: () => GetDebugPlayerHeadingDeg(), - getPlayerCellId: () => GetDebugPlayerCellId(), - getPlayerOnGround: () => GetDebugPlayerOnGround(), - getInPlayerMode: () => _playerMode, - getInFlyMode: () => _cameraController?.IsFlyMode ?? false, - getVerticalVelocity: () => _playerController?.VerticalVelocity ?? 0f, - getEntityCount: () => _worldState.Entities.Count, - getAnimatedCount: () => _animatedEntities.Count, + getPlayerPosition: () => GetDebugPlayerPosition(), + getPlayerHeadingDeg: () => GetDebugPlayerHeadingDeg(), + getPlayerCellId: () => GetDebugPlayerCellId(), + getPlayerOnGround: () => GetDebugPlayerOnGround(), + getInPlayerMode: () => _playerMode, + getInFlyMode: () => _cameraController?.IsFlyMode ?? false, + getVerticalVelocity: () => _playerController?.VerticalVelocity ?? 0f, + getEntityCount: () => _worldState.Entities.Count, + getAnimatedCount: () => _animatedEntities.Count, getLandblocksVisible: () => _debugVmRenderFacts.DebugVmFacts.VisibleLandblocks, - getLandblocksTotal: () => + getLandblocksTotal: () => _debugVmRenderFacts.DebugVmFacts.TotalLandblocks, getShadowObjectCount: () => _physicsEngine.ShadowObjects.TotalRegistered, - getNearestObjDist: () => + getNearestObjDist: () => _debugVmRenderFacts.DebugVmFacts.NearestObjectDistance, - getNearestObjLabel: () => + getNearestObjLabel: () => _debugVmRenderFacts.DebugVmFacts.NearestObjectLabel, - getColliding: () => + getColliding: () => _debugVmRenderFacts.DebugVmFacts.Colliding, - getDebugWireframes: () => _debugCollisionVisible, - getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier + getDebugWireframes: () => + _worldSceneDebugState.CollisionWireframesVisible, + getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier - getMouseSensitivity: () => GetActiveSensitivity(), - getChaseDistance: () => _chaseCamera?.Distance ?? 0f, - getRmbOrbit: () => _rmbHeld, - getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(), - getDayFraction: () => (float)WorldTime.DayFraction, - getWeather: () => Weather.Kind.ToString(), - getActiveLights: () => Lighting.ActiveCount, - getRegisteredLights: () => Lighting.RegisteredCount, - getParticleCount: () => _particleSystem?.ActiveParticleCount ?? 0, - getFps: () => + getMouseSensitivity: () => GetActiveSensitivity(), + getChaseDistance: () => _chaseCamera?.Distance ?? 0f, + getRmbOrbit: () => _rmbHeld, + getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(), + getDayFraction: () => (float)WorldTime.DayFraction, + getWeather: () => Weather.Kind.ToString(), + getActiveLights: () => Lighting.ActiveCount, + getRegisteredLights: () => Lighting.RegisteredCount, + getParticleCount: () => _particleSystem?.ActiveParticleCount ?? 0, + getFps: () => (float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0), - getFrameMs: () => + getFrameMs: () => (float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7), - combat: Combat); - _debugVm.CycleTimeOfDay = CycleTimeOfDay; - _debugVm.CycleWeather = CycleWeather; + combat: Combat); + _debugVm.CycleTimeOfDay = CycleTimeOfDay; + _debugVm.CycleWeather = CycleWeather; _debugVm.ToggleCollisionWires = ToggleCollisionWires; // Phase K.2: free-fly toggle button — same routine the // legacy F-key alias hits. Cancels the one-shot // auto-entry if the user opts out of player mode before // it fires, so the chase camera doesn't snap on top of // the fly camera mid-inspection. - _debugVm.ToggleFlyMode = () => + _debugVm.ToggleFlyMode = () => _playerModeController?.ToggleFlyOrChase(); _combatFeedback.ViewModel = _debugVm; var debugPanel = @@ -1497,11 +1469,11 @@ public sealed class GameWindow : IDisposable TerrainTypeToLayer: terrainTypeToLayerBytes, RoadLayer: roadLayer, CornerAlphaLayers: terrainAtlas.CornerAlphaLayers, - SideAlphaLayers: terrainAtlas.SideAlphaLayers, - RoadAlphaLayers: terrainAtlas.RoadAlphaLayers, + SideAlphaLayers: terrainAtlas.SideAlphaLayers, + RoadAlphaLayers: terrainAtlas.RoadAlphaLayers, CornerAlphaTCodes: terrainAtlas.CornerAlphaTCodes, - SideAlphaTCodes: terrainAtlas.SideAlphaTCodes, - RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes); + SideAlphaTCodes: terrainAtlas.SideAlphaTCodes, + RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes); _heightTable = heightTable; _surfaceCache = new System.Collections.Concurrent.ConcurrentDictionary(); @@ -2386,20 +2358,20 @@ public sealed class GameWindow : IDisposable // LoadAndApplyPersistedSettings() above, absorbing all env-var overrides. // Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat. _nearRadius = _resolvedQuality.NearRadius; - _farRadius = _resolvedQuality.FarRadius; + _farRadius = _resolvedQuality.FarRadius; // Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and // ensures farRadius >= streamRadius. Parsed once into // RuntimeOptions.LegacyStreamRadius (null when unset or invalid). if (_options.LegacyStreamRadius is { } sr) { - _nearRadius = sr; + _nearRadius = sr; _streamingRadius = sr; // keep debug overlay in sync - _farRadius = System.Math.Max(sr, _farRadius); + _farRadius = System.Math.Max(sr, _farRadius); } Console.WriteLine( - $"streaming: nearRadius={_nearRadius} (window={2*_nearRadius+1}x{2*_nearRadius+1})" + - $" farRadius={_farRadius} (window={2*_farRadius+1}x{2*_farRadius+1})"); + $"streaming: nearRadius={_nearRadius} (window={2 * _nearRadius + 1}x{2 * _nearRadius + 1})" + + $" farRadius={_farRadius} (window={2 * _farRadius + 1}x{2 * _farRadius + 1})"); // Phase A.5 T11+: the streamer now runs on a dedicated worker thread. // loadLandblock acquires _datLock (T10) before touching DatCollection. @@ -2439,9 +2411,9 @@ public sealed class GameWindow : IDisposable _liveCenterY))), enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation), drainCompletions: _streamer.DrainCompletions, - state: _worldState, - nearRadius: _nearRadius, - farRadius: _farRadius, + state: _worldState, + nearRadius: _nearRadius, + farRadius: _farRadius, clearPendingLoads: _streamer.ClearPendingLoads, // Retained-object recovery runs after each // (dungeon-exit expand or Far→Near promote). ACE won't re-send the @@ -2805,15 +2777,18 @@ public sealed class GameWindow : IDisposable var teleportRenderState = new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource( _localPlayerTeleport); + var renderLoginState = new AcDream.App.Rendering.RenderLoginStateSource( + _options.LiveMode, + _localPlayerMode); + var renderFrameGlState = new AcDream.App.Rendering.RenderFrameGlStateController( + new AcDream.App.Rendering.SilkRenderFrameGlStateApi(_gl!)); _renderFrameLivePreparation = new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation( _textureCache, _wbMeshAdapter, _worldReveal, teleportRenderState, - new AcDream.App.Rendering.RenderLoginStateSource( - _options.LiveMode, - _localPlayerMode), + renderLoginState, new AcDream.App.Rendering.LiveLoginRevealCellSource( _liveEntities!, _localPlayerIdentity), @@ -2841,7 +2816,8 @@ public sealed class GameWindow : IDisposable Weather, teleportRenderState, _particleVisibility, - _worldRenderDiagnostics!), + _worldRenderDiagnostics!, + renderFrameGlState), _renderFrameLivePreparation); _renderWeatherFrame = new AcDream.App.Rendering.RenderWeatherFrameController( @@ -2911,6 +2887,7 @@ public sealed class GameWindow : IDisposable _retailPViewPassExecutor = new AcDream.App.Rendering.RetailPViewPassExecutor( _gl!, + renderFrameGlState, new AcDream.App.Rendering.SilkRetailPViewFramebufferSource( _window!), _clipFrame!, @@ -2924,6 +2901,48 @@ public sealed class GameWindow : IDisposable _retailAlphaQueue, _worldRenderDiagnostics!, _terrainDrawDiagnostics); + var worldSceneDiagnostics = + new AcDream.App.Rendering.WorldSceneDiagnosticsController( + _worldRenderDiagnostics!, + new AcDream.App.Rendering.RuntimeWorldScenePViewDiagnosticSource( + _playerControllerSlot, + _physicsEngine, + _cellVisibility), + _worldSceneDebugState, + _debugLines, + _physicsEngine, + _localPlayerMode, + _playerControllerSlot, + _debugVmRenderFacts, + _debugVm is not null); + var worldScenePasses = new AcDream.App.Rendering.WorldScenePassExecutor( + _gl!, + renderFrameGlState, + _clipFrame!, + _wbDrawDispatcher!, + _envCellRenderer!, + _terrain, + _terrainDrawDiagnostics, + _skyRenderer, + _particleSystem, + _particleRenderer); + _worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer( + _renderFrameResources, + renderLoginState, + _worldSceneSkyState!, + _worldRenderFrameBuilder, + new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState), + _retailSelectionScene, + _retailAlphaQueue, + _particleVisibility, + new AcDream.App.Rendering.WorldScenePViewRenderer( + _retailPViewRenderer!, + _retailPViewPassExecutor, + _retailPViewPassExecutor), + _retailPViewCells, + worldScenePasses, + _renderRange, + worldSceneDiagnostics); var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( liveObjectFrame, _worldState, @@ -3352,7 +3371,7 @@ public sealed class GameWindow : IDisposable 0x79u => "Thunder4Sound", 0x7Au => "Thunder5Sound", 0x7Bu => "Thunder6Sound", - _ => $"Unknown(0x{environChangeType:X2})", + _ => $"Unknown(0x{environChangeType:X2})", }; Console.WriteLine( $"live: AdminEnvirons sound cue = {name} " + @@ -3400,653 +3419,101 @@ public sealed class GameWindow : IDisposable Exception? renderFailure = null; try { - GL gl = _gl!; - var frameInput = new AcDream.App.Rendering.RenderFrameInput( - deltaSeconds, - _window!.Size.X, - _window.Size.Y); - _renderFrameResources!.Prepare(frameInput); - AcDream.App.Rendering.RenderFrameFoundation foundation = - _renderFrameResources.Foundation; - - // gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the - // normal SmartBox viewport and UIElement_Viewport's portal - // CreatureMode; it does not redraw the world behind portal space. - bool portalViewportVisible = foundation.PortalViewportVisible; - - AcDream.Core.World.SkyKeyframe kf = foundation.Sky; - AcDream.Core.World.AtmosphereSnapshot atmo = foundation.Atmosphere; - bool environOverrideActive = foundation.EnvironOverrideActive; - - // Phase D.2a — begin ImGui frame. Paired with the Render() call - // after the scene draws (below). ImGuiController.Update() - // consumes buffered Silk.NET input events and calls ImGui.NewFrame. - _devToolsFramePresenter?.BeginFrame((float)deltaSeconds); - - // Phase G.1: weather state machine — deterministic per-day roll - // + transitions + lightning flash. - _renderWeatherFrame!.Tick(deltaSeconds); - - // (Pre-Bug-A code spawned camera-attached rain/snow particle - // emitters here as a workaround for missing weather-mesh - // rendering. Deleted 2026-04-26 once the retail-faithful world- - // space mesh path landed in SkyRenderer.RenderWeather. Retail - // rain is GfxObj 0x01004C42/0x01004C44 — a hollow octagonal - // cylinder anchored at player_pos + (0, 0, -120m) per - // GameSky::UpdatePosition at 0x00506dd0 — drawn after the - // landblock pass per LScape::draw at 0x00506330. There is no - // server-driven weather event and no camera-attached emitter - // in retail. Snow renders identically when a Snowy DayGroup is - // active in some other Region; the partition by Properties&0x04 - // and the SkyRenderer.RenderWeather pass both pick up snow - // weather meshes for free.) - - int visibleLandblocks = 0; - int totalLandblocks = 0; - bool normalWorldDrawn = false; - - _retailSelectionScene?.BeginFrame(); - if (_cameraController is not null && !portalViewportVisible) - { - _retailAlphaQueue.BeginFrame(); - AcDream.App.Rendering.WorldRenderFrame worldFrame = - _worldRenderFrameBuilder!.Build( - in foundation, - IsLiveModeWaitingForLogin, - _activeDayGroup); - AcDream.App.Rendering.WorldCameraFrame cameraFrame = worldFrame.Camera; - AcDream.App.Rendering.WorldRootFrame rootFrame = worldFrame.Roots; - var camera = cameraFrame.Camera; - var worldProjection = cameraFrame.Projection; - var frustum = cameraFrame.Frustum; - var camPos = cameraFrame.Position; - // The frame builder resolves retail's player-lighting root and - // collided-viewer render root before any draw decision is made. - LoadedCell? playerRoot = rootFrame.PlayerRoot; - bool playerSeenOutside = rootFrame.PlayerSeenOutside; - uint viewerCellId = rootFrame.ViewerCellId; - var viewerEyePos = rootFrame.ViewerEyePosition; - var playerViewPos = rootFrame.PlayerViewPosition; - LoadedCell? viewerRoot = rootFrame.ViewerRoot; - bool cameraInsideCell = rootFrame.CameraInsideCell; - - // Never cull the landblock the player is currently on. - uint? playerLb = rootFrame.PlayerLandblockId; - int renderCenterLbX = rootFrame.RenderCenterLandblockX; - int renderCenterLbY = rootFrame.RenderCenterLandblockY; - - var envCellViewProj = cameraFrame.ViewProjection; - HashSet animatedIds = worldFrame.AnimatedEntityIds; - - // Phase G.1: sky renderer — draws the far-plane-infinity - // celestial meshes FIRST so the rest of the scene z-tests - // on top of them (depth mask off, no depth writes). - // - // Suppressed inside cells (the camera is in a sealed interior; - // no sky is visible). Mirrors retail's LScape::draw at 0x00506330 - // which calls GameSky::Draw(0) (sky pass) BEFORE the landblock - // DrawBlock loop and GameSky::Draw(1) (weather pass) AFTER. The - // split matters because weather meshes (the 815m-tall rain - // cylinder 0x01004C42/0x01004C44) need to overlay terrain and - // entities to look volumetric — see the post-scene RenderWeather - // call further below. - // Stage 3 (2026-06-02): sky gate uses seen_outside per retail RenderNormalMode:92649. - // Outdoor root (cameraInsideCell=false): always render sky. - // Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky — clipped - // to the doorway via the OutsideView (Stage 4, below). - // Sealed dungeon (cameraInsideCell=true, rootSeenOutside=false): no sky. - bool renderSky = rootFrame.RenderSky; - // Phase W Stage 4 (2026-06-02): the sky/weather DRAW moved DOWN to its retail LScape - // position — AFTER the portal-visibility ClipFrame is assembled — so it can be clipped to - // the doorway (OutsideView) by sky.vert's gl_ClipDistance. See the "[Stage 4] sky - // pre-scene" block after UploadShared. renderSky is the seen_outside policy gate; the draw - // additionally requires an exit portal in view when indoors (drawSkyThisFrame, below). - - // K-fix1 (2026-04-26): the pre-login world-suppression gate (goto SkipWorldGeometry) - // moved DOWN — below the sky pre-scene draw (Phase W Stage 4) — so the live sky still - // draws during the connection + EnterWorld handshake while the world geometry is skipped. - // See the gate just before the world-geometry clip bracket. - - // Phase U.4: build the SHARED per-frame clip data from the portal- - // visibility result, ahead of both terrain and entity draws. - // - // Root: a non-null CameraCell means the camera is INSIDE a cell (indoor - // root) — run the portal-frame BFS (PortalVisibilityBuilder) and assemble - // a real ClipFrame (slot 0 no-clip, slot 1.. per visible cell + the - // OutsideView) + a cellId→slot map. A null CameraCell is the OUTDOOR root: - // no pvFrame, the frame stays no-clip, every instance is slot 0 and terrain - // draws normally — bit-identical to U.3 (outdoor→building peering is U.5). - // - // The single _clipFrame instance is RESET + repacked in place each frame. - // One SSBO and one range-addressed terrain UBO arena are reused per - // GPU-fenced frame slot; each renderer re-binds binding=2 defensively. - _clipFrame ??= ClipFrame.NoClip(); - - IReadOnlyList nearbyBuildingCells = - worldFrame.Buildings.NearbyBuildingCells; - bool playerIndoorGate = rootFrame.PlayerIndoorGate; - uint playerCellId = rootFrame.PlayerCellId; - // Render unification (outdoor-as-cell, 2026-06-07 cutover): ONE render path rooted at the - // VIEWER cell. Eye indoors -> its interior EnvCell (viewerRoot); eye outdoors -> the - // frame builder's synthetic outdoor node, built from nearby building entrances. The - // result is null ONLY when neither exists (pre-spawn / login / legacy non-chase camera) -> - // the outdoor LScape block below still runs as the safety path (and login still shows the - // live sky). There is no inside/outside branch to TOGGLE as the chase eye crosses the - // doorway boundary, so the indoor FLAP dies by construction. playerIndoorGate stays - // computed for the [render-sig] probe but no longer selects the path (handoff - // docs/research/2026-06-07-render-unification-cutover-flip-handoff.md section 4 Step B). - var clipRoot = worldFrame.ClipRoot; - string renderBranch = clipRoot is null - ? "OutdoorRoot" - : "RetailPViewInside"; - ClipFrameAssembly? clipAssembly = null; - PortalVisibilityFrame? pvFrame = null; // R1: hoisted so the binary decision below reads OrderedVisibleCells - var terrainClipMode = TerrainClipMode.Planes; // overwritten below for indoor root - HashSet? envCellShellFilter = null; // drawable visible cells (cellIdToSlot keys) - PortalVisibilityFrame? sigPvFrame = null; - ClipFrameAssembly? sigClipAssembly = null; - IReadOnlySet? sigDrawableCells = null; - AcDream.App.Rendering.InteriorEntityPartition.Result? sigPartition = null; - PortalVisibilityFrame? sigExteriorPvFrame = null; - ClipFrameAssembly? sigExteriorClipAssembly = null; - IReadOnlySet? sigExteriorDrawableCells = null; - AcDream.App.Rendering.InteriorEntityPartition.Result? sigExteriorPartition = null; - bool sigTerrainDrawn = false; - bool sigSkyDrawn = false; - bool sigDepthClear = false; - bool sigOutdoorPortalDrawn = false; - bool sigOutdoorSceneryDrawn = false; - int sigOutdoorRootObjectCount = 0; - int sigLiveDynamicDrawnCount = 0; - string sigSceneParticles = "none"; - _visibleSceneParticleEntityIds.Clear(); - // Retail entry ownership: GameWindow never builds a second indoor PView product. - // Outdoor frames begin no-clip; indoor frames skip the global landscape block and let - // RetailPViewRenderer.DrawInside own ConstructView -> DrawCells. - _clipFrame.Reset(); - _wbDrawDispatcher?.ClearClipRouting(); - _envCellRenderer?.SetClipRouting(null); - _interiorPartition = null; - if (clipRoot is not null) - { - clipAssembly = null; - pvFrame = null; - terrainClipMode = TerrainClipMode.Skip; - envCellShellFilter = null; - _interiorPartition = null; - } - - if (clipRoot is null) - { - // Outdoor frames have one no-clip terrain state. Indoor frames - // defer both allocations to RetailPViewRenderer, which knows the - // exact slice count and packs every terrain state into one arena. - _clipFrame.ReserveTerrainUploads(gl, 1); - _clipFrame.UploadRegions(gl); - TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(gl); - _wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo); - _envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo); - _terrain?.SetClipUbo(terrainBinding); - } - - bool drawSkyThisFrame = false; - - if (clipRoot is null) - { - // ── Outdoor LScape entry ───────────────────────────────────────────────── - // Retail indoor frames do not pass through this block. If the player is indoors, - // PView::DrawInside owns landscape drawing through outside_view and the depth-only - // clear. This outdoor-only block is the LScape half of RenderNormalMode. - drawSkyThisFrame = renderSky; - sigSkyDrawn = drawSkyThisFrame; - if (drawSkyThisFrame) - { - _clipFrame.BindTerrainClip(gl); - for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - gl.Enable(EnableCap.ClipDistance0 + _cp); - _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf, environOverrideActive); - for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - gl.Disable(EnableCap.ClipDistance0 + _cp); - - if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(camera, camPos, - AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); - } - - // K-fix1 (2026-04-26): suppress terrain + entity rendering while live mode is configured - // but the chase camera hasn't engaged yet. The sky (above) still draws during login so the - // user sees a live, time-of-day-correct sky through the connection + EnterWorld handshake. - if (IsLiveModeWaitingForLogin) - goto SkipWorldGeometry; - - // Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup. - EnableClipDistances(); - _terrainDrawDiagnostics!.Begin(); - sigTerrainDrawn = true; - _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); - _terrainDrawDiagnostics.Complete(); - - } - - // Reaching the retail normal-world decision below proves this is - // neither a portal replacement nor the sky-only login-wait path. - normalWorldDrawn = true; - - // R1 — the binary render decision (retail RenderNormalMode @ 0x453aa0): - // INDOOR root (clipRoot != null): run ONLY the per-cell DrawInside flood. The global - // entity pass + global shell pass are NOT issued — visibility IS the cull, so the - // outdoor world cannot bleed (it is never iterated; outdoor scenery entered above, - // clipped to the doorway). DrawInside follows retail DrawCells order: reverse - // cell_draw_list shell stage, then reverse object-list stage, per portal_view slice. - // OUTDOOR root: draw the landscape/outdoor bucket first, then seed a reciprocal - // portal frame from exterior-facing cell portals so peering through an open door - // draws the indoor SHELL + its statics together. The old global pass drew indoor - // statics without the EnvCell shells, which made walls look transparent from outside. - if (clipRoot is not null) - { - if (_retailPViewRenderer is null) - throw new InvalidOperationException("Retail PView renderer is required for indoor frames."); - - var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewFrameInput - { - RootCell = clipRoot, - // R-A2: outdoor root floods each nearby building per-building (not via the root). - // #124: interior roots get the gather too — the renderer routes them to the - // landscape-stage look-in sub-pass instead of the merge. - NearbyBuildingCells = nearbyBuildingCells, - ViewerEyePos = viewerEyePos, - ViewProjection = envCellViewProj, - Cells = _retailPViewCells!, - Camera = camera, - CameraWorldPosition = camPos, - // (#176 correction: the former RebuildScopedLights callback — - // rebuilding the light pool from the frame's FLOOD — was the - // flicker mechanism and is deleted. The pool is built once per - // frame above, player-anchored, from all resident lights.) - Frustum = frustum, - PlayerLandblockId = playerLb, - AnimatedEntityIds = animatedIds, - RenderCenterLbX = renderCenterLbX, - RenderCenterLbY = renderCenterLbY, - RenderRadius = _nearRadius, - LandblockEntries = _worldState.LandblockEntries, - RenderSky = renderSky, - RenderWeather = playerSeenOutside, - DayFraction = (float)WorldTime.DayFraction, - ActiveDayGroup = _activeDayGroup, - SkyKeyframe = kf, - EnvironOverrideActive = environOverrideActive, - ViewerCellId = viewerCellId, - PlayerCellId = playerCellId, - PlayerViewPosition = playerViewPos, - CameraView = camera.View, - CameraCellResolution = _cellVisibility.LastCameraCellResolution, - }, _retailPViewPassExecutor - ?? throw new InvalidOperationException( - "Retail PView pass executor is required for indoor frames.")); - pvFrame = pviewResult.PortalFrame; - clipAssembly = pviewResult.ClipAssembly; - envCellShellFilter = pviewResult.DrawableCells; - _interiorPartition = pviewResult.Partition; - _particleVisibility.MarkVisibleCells(pviewResult.DrawableCells); - - // A7.L1: snapshot this frame's drawn cell set for NEXT frame's light- - // pool scoping. DrawableCells is the renderer's own reused scratch set - // (cleared/rebuilt every DrawInside call) — copy it, don't hold a - // reference to it. - _worldRenderFrameBuilder!.ObserveDrawableCells(pviewResult.DrawableCells); - - // Flap root-cause apparatus (2026-06-07): per-frame, the EXACT Build inputs at 6 dp + - // the resulting flood count. The live flap shows flood flipping 2↔6 at an eye/player - // identical to cm; this answers whether the inputs differ sub-cm (jitter) or are - // byte-identical (nondeterminism). See RenderingDiagnostics.ProbePvInputEnabled. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePvInputEnabled - && pvFrame is not null) - { - // 2026-06-08: disambiguate the idle flap. eye=camera eye-point (drives the flood); - // player=RenderPosition (Lerp of physics, what the eye chases); rawPlayer=raw physics - // body Position; yaw=camera/player heading (F8 rad to catch micro-drift). If the flood - // flickers while idle, exactly one of {eye, player, rawPlayer, yaw} is the varying input. - var pvRawPlayer = _playerController?.Position ?? playerViewPos; - float pvYaw = _playerController?.Yaw ?? 0f; - // §4 outdoor flap (2026-06-09): terrain height under the EYE — if the - // camera boom is buried in a hillside (eyeAbove < 0), every nearby - // terrain triangle backface-culls and the view punches through the - // world to the clear color. Pin or refute eye-under-terrain. - float? pvTerrZ = _physicsEngine.SampleTerrainZ(camPos.X, camPos.Y); - _worldRenderDiagnostics?.EmitPViewInput( - enabled: true, - pvFrame, - envCellViewProj, - clipRoot.IsOutdoorNode, - camPos, - playerViewPos, - pvRawPlayer, - pvYaw, - pvTerrZ); - } - - sigPvFrame = pviewResult.PortalFrame; - sigClipAssembly = pviewResult.ClipAssembly; - sigDrawableCells = pviewResult.DrawableCells; - sigPartition = pviewResult.Partition; - sigTerrainDrawn = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; - sigSkyDrawn = renderSky && pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; - sigDepthClear = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; - sigSceneParticles = (pviewResult.Partition.ByCell.Count > 0 - || pviewResult.ClipAssembly.OutsideViewSlices.Length > 0) - ? "pviewScoped" - : sigSceneParticles; - sigOutdoorSceneryDrawn = pviewResult.Partition.OutdoorStatic.Count > 0 - && pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; - - // T1: DrawInside now draws ALL dynamics itself in its single - // last entity pass (DrawDynamicsLast) — the old LiveDynamic - // top-up draw is gone. - sigLiveDynamicDrawnCount = pviewResult.Partition.Dynamics.Count; - } - else - { - // T4 (BR-6): the old clipRoot==null mini-pipeline (outdoor - // partition + Chebyshev look-in gather + DrawPortal + dynamics - // fallback) is DELETED — it was the SECOND render path the - // one-gate rule forbids (legacy-outdoor-branch-remnant, - // adjusted-confirmed). clipRoot is null only when NO viewer - // cell exists at all (pre-login, fly/debug cameras, transient - // streaming gaps — the outdoor node covers every normal outdoor - // frame): draw the world flat through the dispatcher; floods - // resume the moment a viewer cell resolves. - _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: null, - animatedEntityIds: animatedIds); - - // A7.L1: no indoor draw this frame — fail open (unscoped) rather than - // scoping next frame's light pool by a stale dungeon's cell ids. - _worldRenderFrameBuilder!.ClearDrawableCells(); - } - - // Phase U.3: close the world-geometry clip bracket opened above. From here down the - // scene particles, debug lines, and UI use shaders that do NOT write gl_ClipDistance, so - // the planes must be OFF to avoid the undefined-behavior clip. (The weather/rain pass - // below DOES use sky.vert — it re-enables the planes in its OWN local bracket; the sky - // pre-scene pass above already did the same. Both are scissored to the doorway too.) - for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - gl.Disable(EnableCap.ClipDistance0 + _cp); - - // Phase G.1 / E.3: draw all live particles after opaque - // scene geometry so alpha blending composites correctly. - // Runs with depth test on (particles occluded by walls) - // but depth write off (no self-occlusion sorting needed). - if (clipRoot is null && _particleSystem is not null && _particleRenderer is not null) - { - if (clipAssembly is not null) - { - sigSceneParticles = sigSceneParticles == "none" ? "filtered" : sigSceneParticles + "+filtered"; - _particleRenderer.DrawForOwners( - camera, - camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene, - _visibleSceneParticleEntityIds, - includeUnattached: true, - excludedAttachedOwnerIds: _noSceneParticleEntityIds); - } - else - { - sigSceneParticles = sigSceneParticles == "none" ? "global" : sigSceneParticles + "+global"; - _particleRenderer.Draw( - camera, - camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene); - } - } - else if (clipRoot is { IsOutdoorNode: true } - && _particleSystem is not null && _particleRenderer is not null) - { - // T3 (BR-5): unattached emitters (campfires, ground effects — - // AttachedObjectId == 0) under the OUTDOOR root. The outdoor - // root's outside view is full-screen (cone pass-all); depth - // test composites them against the world. - // #132 outdoor sibling: ATTACHED outdoor-static scene emitters - // (lantern/candle flames) moved here too — drawn in the - // landscape slice they were overpainted by merged building - // interiors (drawn later) whenever a punched aperture sat - // behind them. Post-frame, depth is complete and the flames - // composite correctly. The owner-id set is the late slice's - // (full-screen cone outdoors). Cell-pass and dynamics-pass - // emitters keep their own passes (no double-draw: their owners - // are never in the outdoor-static id set). - sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached"; - _particleRenderer.DrawForOwners( - camera, - camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene, - _retailPViewPassExecutor!.OutdoorSceneParticleEntityIds, - includeUnattached: true); - } - - // Bug A fix (post-#26 worktree, 2026-04-26): weather sky - // Outdoor LScape post-scene weather. Indoor weather through an exit portal is - // drawn by RetailPViewRenderer.DrawInside via RetailPViewPassExecutor. - if (clipRoot is null && drawSkyThisFrame) - { - sigSkyDrawn = true; - _clipFrame.BindTerrainClip(gl); - for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - gl.Enable(EnableCap.ClipDistance0 + _cp); - _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf, environOverrideActive); - for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - gl.Disable(EnableCap.ClipDistance0 + _cp); - - if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(camera, camPos, - AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); - } - - _worldRenderDiagnostics?.EmitRenderSignatureIfChanged( - AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled, - renderBranch, - clipRoot, - viewerRoot, - playerRoot, - viewerCellId, - playerCellId, - playerIndoorGate, - cameraInsideCell, - renderSky, - drawSkyThisFrame, - sigTerrainDrawn, - terrainClipMode, - sigSkyDrawn, - sigDepthClear, - sigOutdoorSceneryDrawn, - sigOutdoorPortalDrawn, - sigOutdoorRootObjectCount, - sigLiveDynamicDrawnCount, - sigSceneParticles, - sigPvFrame, - sigClipAssembly, - sigDrawableCells, - sigPartition, - sigExteriorPvFrame, - sigExteriorClipAssembly, - sigExteriorDrawableCells, - sigExteriorPartition, - camPos, - playerViewPos); - - // Debug: draw collision shapes as wireframe cylinders around the - // player so we can visually verify alignment with scenery meshes. - if (_debugCollisionVisible && _debugLines is not null) - { - _debugLines.Begin(); - - // Pick the center for the debug radius. Prefer player - // position in player mode, otherwise use camPos. - System.Numerics.Vector3 center; - if (_playerMode && _playerController is not null) - center = _playerController.Position; - else - center = camPos; - - // Draw ALL registered shadow objects regardless of distance — - // if it has collision, it gets a wireframe. This lets the user - // see exactly what's in the collision registry at any moment. - int drawn = 0; - foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug()) - { - var dx = obj.Position.X - center.X; - var dy = obj.Position.Y - center.Y; - - if (obj.CollisionType == AcDream.Core.Physics.ShadowCollisionType.Cylinder) - { - float h = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 2f; - _debugLines.AddCylinder( - obj.Position, obj.Radius, h, - new System.Numerics.Vector3(0f, 1f, 0f)); // green cylinders - } - else - { - // BSP: show a bounding sphere as a cylinder for visibility - _debugLines.AddCylinder( - obj.Position - new System.Numerics.Vector3(0, 0, obj.Radius), - obj.Radius, obj.Radius * 2f, - new System.Numerics.Vector3(1f, 0.5f, 0f)); // orange BSP - } - drawn++; - } - - // Draw the player's collision sphere as a red cylinder (0.48m radius, 1.8m tall) - if (_playerMode && _playerController is not null) - { - var pp = _playerController.Position; - _debugLines.AddCylinder( - new System.Numerics.Vector3(pp.X, pp.Y, pp.Z - 0.0f), - 0.48f, 1.8f, - new System.Numerics.Vector3(1f, 0f, 0f)); // red player - } - - if (_debugDrawLogOnce < 5 && _playerMode && _playerController is not null) - { - var pp = _playerController.Position; - Console.WriteLine( - $"debug frame {_debugDrawLogOnce}: player=({pp.X:F1},{pp.Y:F1},{pp.Z:F1}) drew={drawn} " + - $"totalReg={_physicsEngine.ShadowObjects.TotalRegistered}"); - // Sample 3 nearest shadow objects - int logged = 0; - foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug()) - { - var dx = o.Position.X - pp.X; - var dy = o.Position.Y - pp.Y; - float dh = MathF.Sqrt(dx * dx + dy * dy); - if (dh < 10f) - { - Console.WriteLine($" near id=0x{o.EntityId:X8} type={o.CollisionType} pos=({o.Position.X:F1},{o.Position.Y:F1},{o.Position.Z:F1}) r={o.Radius:F2} h={o.CylHeight:F2} dh={dh:F2}"); - if (++logged >= 5) break; - } - } - _debugDrawLogOnce++; - } - _debugLines.Flush(camera.View, worldProjection); - } - - // Count visible vs total for the perf overlay. - foreach (var entry in _worldState.LandblockBounds) - { - totalLandblocks++; - if (AcDream.App.Rendering.FrustumCuller.IsAabbVisible(frustum, entry.AabbMin, entry.AabbMax)) - visibleLandblocks++; - } - - // Phase I.2: refresh per-frame fields that DebugVM closures - // can't compute lazily (frustum-derived counters + nearest- - // object scan). Every other DebugVM field reads through to - // the live source via its closure. Skipped entirely when - // devtools are off — avoids the nearest-object O(N) scan in - // the hot path of an offline render. - if (_debugVm is not null) - { - System.Numerics.Vector3 debugNearestOrigin = - _playerMode && _playerController is not null - ? _playerController.Position - : camPos; - _debugVmRenderFacts.PublishDebugVmFacts( - consumerActive: true, - visibleLandblocks, - totalLandblocks, - debugNearestOrigin, - _physicsEngine.ShadowObjects.AllEntriesForDebug()); - } - - // K-fix1 (2026-04-26): jump target for IsLiveModeWaitingForLogin — - // skips the world geometry pass before login. ImGui (chat, - // debug, settings panels) and the menu bar still render - // below. Sky has already drawn before this label so the - // pre-login screen shows a live, correctly-tinted sky and - // nothing else. - SkipWorldGeometry: - _retailAlphaQueue.EndFrame(); - if (_terrain is not null) - _particleVisibility.MarkVisibleCells(_terrain.VisibleCellIds); - _particleVisibility.CompleteFrame(); - } - _retailSelectionScene?.CompleteFrame(); - - // Retail gmSmartBoxUI swaps the world SmartBox viewport for a - // CreatureMode portal-space viewport. The world block above is skipped - // while this replacement scene is visible. This scene and - // its projection transition draws BEFORE retained UI, so chat, windows, - // cursor, and toolbar remain visible and interactive in transit. - _localPlayerTeleport?.DrawPortalViewport( - _window!.Size.X, - _window.Size.Y, - _cameraController!.Active.Projection); - - // Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the - // viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window - // is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle). - // The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state. - _paperdollFramePresenter?.Render(); - - // Phase D.2b — retail-look UI tree (render-only; input integration deferred). - // Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own - // blend/depth state and restores. Drawn before ImGui so the devtools - // overlay composites on top during development. - if (_options.RetailUi && _retailUiRuntime is not null) - { - _retailUiRuntime.Tick(deltaSeconds); - if (_input is not null) - _retailUiRuntime.UpdateCursor(_input.Mice); - _retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); - } - - // Devtools remain above retained gameplay UI. The focused presenter - // owns menu policy, panels, and ImGui draw-data upload. - _devToolsFramePresenter?.Render( - deltaSeconds, - _window!.Size.X, - _window.Size.Y); - - // Explicit diagnostic captures are requested by the retained-UI - // automation tick above and executed only after every presentation - // layer has drawn into the back buffer on this render thread. - bool screenshotCaptured = _frameScreenshots?.CapturePending() == true; - - _renderFrameDiagnostics?.Publish( - new AcDream.App.Rendering.RenderFrameInput( + var frameInput = new AcDream.App.Rendering.RenderFrameInput( deltaSeconds, _window!.Size.X, - _window.Size.Y), - new AcDream.App.Rendering.RenderFrameOutcome( - new AcDream.App.Rendering.WorldRenderFrameOutcome( - visibleLandblocks, - totalLandblocks, - normalWorldDrawn), - new AcDream.App.Rendering.PrivatePresentationFrameOutcome( - portalViewportVisible, - screenshotCaptured))); + _window.Size.Y); + _renderFrameResources!.Prepare(frameInput); + AcDream.App.Rendering.RenderFrameFoundation foundation = + _renderFrameResources.Foundation; + + // gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the + // normal SmartBox viewport and UIElement_Viewport's portal + // CreatureMode; it does not redraw the world behind portal space. + bool portalViewportVisible = foundation.PortalViewportVisible; + + // Phase D.2a — begin ImGui frame. Paired with the Render() call + // after the scene draws (below). ImGuiController.Update() + // consumes buffered Silk.NET input events and calls ImGui.NewFrame. + _devToolsFramePresenter?.BeginFrame((float)deltaSeconds); + + // Phase G.1: weather state machine — deterministic per-day roll + // + transitions + lightning flash. + _renderWeatherFrame!.Tick(deltaSeconds); + + // (Pre-Bug-A code spawned camera-attached rain/snow particle + // emitters here as a workaround for missing weather-mesh + // rendering. Deleted 2026-04-26 once the retail-faithful world- + // space mesh path landed in SkyRenderer.RenderWeather. Retail + // rain is GfxObj 0x01004C42/0x01004C44 — a hollow octagonal + // cylinder anchored at player_pos + (0, 0, -120m) per + // GameSky::UpdatePosition at 0x00506dd0 — drawn after the + // landblock pass per LScape::draw at 0x00506330. There is no + // server-driven weather event and no camera-attached emitter + // in retail. Snow renders identically when a Snowy DayGroup is + // active in some other Region; the partition by Properties&0x04 + // and the SkyRenderer.RenderWeather pass both pick up snow + // weather meshes for free.) + + AcDream.App.Rendering.WorldRenderFrameOutcome worldOutcome = + _worldSceneRenderer!.Render(frameInput); + int visibleLandblocks = worldOutcome.VisibleLandblocks; + int totalLandblocks = worldOutcome.TotalLandblocks; + bool normalWorldDrawn = worldOutcome.NormalWorldDrawn; + + // Retail gmSmartBoxUI swaps the world SmartBox viewport for a + // CreatureMode portal-space viewport. The world block above is skipped + // while this replacement scene is visible. This scene and + // its projection transition draws BEFORE retained UI, so chat, windows, + // cursor, and toolbar remain visible and interactive in transit. + _localPlayerTeleport?.DrawPortalViewport( + _window!.Size.X, + _window.Size.Y, + _cameraController!.Active.Projection); + + // Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the + // viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window + // is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle). + // The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state. + _paperdollFramePresenter?.Render(); + + // Phase D.2b — retail-look UI tree (render-only; input integration deferred). + // Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own + // blend/depth state and restores. Drawn before ImGui so the devtools + // overlay composites on top during development. + if (_options.RetailUi && _retailUiRuntime is not null) + { + _retailUiRuntime.Tick(deltaSeconds); + if (_input is not null) + _retailUiRuntime.UpdateCursor(_input.Mice); + _retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); + } + + // Devtools remain above retained gameplay UI. The focused presenter + // owns menu policy, panels, and ImGui draw-data upload. + _devToolsFramePresenter?.Render( + deltaSeconds, + _window!.Size.X, + _window.Size.Y); + + // Explicit diagnostic captures are requested by the retained-UI + // automation tick above and executed only after every presentation + // layer has drawn into the back buffer on this render thread. + bool screenshotCaptured = _frameScreenshots?.CapturePending() == true; + + _renderFrameDiagnostics?.Publish( + new AcDream.App.Rendering.RenderFrameInput( + deltaSeconds, + _window!.Size.X, + _window.Size.Y), + new AcDream.App.Rendering.RenderFrameOutcome( + new AcDream.App.Rendering.WorldRenderFrameOutcome( + visibleLandblocks, + totalLandblocks, + normalWorldDrawn), + new AcDream.App.Rendering.PrivatePresentationFrameOutcome( + portalViewportVisible, + screenshotCaptured))); } catch (Exception ex) @@ -4139,7 +3606,8 @@ public sealed class GameWindow : IDisposable /// within the same server-day. Swaps both the /// feeding /// (for lighting interp) and the cached - /// (for the sky-object render loop). + /// (for the sky-object + /// render loop). /// /// /// Honors ACDREAM_DAY_GROUP=N — when set, every call picks @@ -4186,12 +3654,16 @@ public sealed class GameWindow : IDisposable : null; bool dayChanged = dayIndex != _loadedSkyDayIndex; - bool groupChanged = !ReferenceEquals(grp, _activeDayGroup); + bool groupChanged = !ReferenceEquals( + grp, + _worldSceneSkyState?.ActiveDayGroup); if (!dayChanged && !groupChanged) return; _loadedSkyDayIndex = dayIndex; - _activeDayGroup = grp; + (_worldSceneSkyState + ??= new AcDream.App.Rendering.WorldSceneSkyState(WorldTime)) + .ActiveDayGroup = grp; if (grp is not null && grp.SkyTimes.Count > 0) { @@ -4214,18 +3686,6 @@ public sealed class GameWindow : IDisposable } } - private void EnableClipDistances() - { - for (int i = 0; i < ClipFrame.MaxPlanes; i++) - _gl!.Enable(EnableCap.ClipDistance0 + i); - } - - private void DisableClipDistances() - { - for (int i = 0; i < ClipFrame.MaxPlanes; i++) - _gl!.Disable(EnableCap.ClipDistance0 + i); - } - // ── Phase I.2 — DebugPanel helpers ──────────────────────────────── // // The ImGui DebugPanel reads through DebugVM closures that ask @@ -4343,8 +3803,8 @@ public sealed class GameWindow : IDisposable /// private void ToggleCollisionWires() { - _debugCollisionVisible = !_debugCollisionVisible; - _debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}"); + bool visible = _worldSceneDebugState.ToggleCollisionWireframes(); + _debugVm?.AddToast($"Collision wireframes {(visible ? "ON" : "OFF")}"); } // Phase K.3 settings state remains runtime-owned; the presenter owns the @@ -4486,10 +3946,10 @@ public sealed class GameWindow : IDisposable { _settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - _persistedDisplay = _settingsStore.LoadDisplay(); - _persistedAudio = _settingsStore.LoadAudio(); - _persistedGameplay = _settingsStore.LoadGameplay(); - _persistedChat = _settingsStore.LoadChat(); + _persistedDisplay = _settingsStore.LoadDisplay(); + _persistedAudio = _settingsStore.LoadAudio(); + _persistedGameplay = _settingsStore.LoadGameplay(); + _persistedChat = _settingsStore.LoadChat(); // _activeToonKey is "default" pre-EnterWorld; the post-login // ApplyLiveSessionEnteredWorld swaps to the chosen toon's // name and re-loads via SettingsVM.LoadCharacterContext. @@ -4511,9 +3971,9 @@ public sealed class GameWindow : IDisposable // harmless — the engine just doesn't read them yet). if (_audioEngine is not null && _audioEngine.IsAvailable) { - _audioEngine.MasterVolume = _persistedAudio.Master; - _audioEngine.MusicVolume = _persistedAudio.Music; - _audioEngine.SfxVolume = _persistedAudio.Sfx; + _audioEngine.MasterVolume = _persistedAudio.Master; + _audioEngine.MusicVolume = _persistedAudio.Music; + _audioEngine.SfxVolume = _persistedAudio.Sfx; _audioEngine.AmbientVolume = _persistedAudio.Ambient; } } @@ -4548,7 +4008,7 @@ public sealed class GameWindow : IDisposable /// public void ReapplyQualityPreset(AcDream.UI.Abstractions.Settings.QualityPreset newPreset) { - var newBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(newPreset); + var newBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(newPreset); var newResolved = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(newBase); Console.WriteLine($"[QUALITY] ReapplyQualityPreset: {newPreset} → {newResolved}"); @@ -4576,7 +4036,7 @@ public sealed class GameWindow : IDisposable if (_streamingController is not null) { _nearRadius = newResolved.NearRadius; - _farRadius = newResolved.FarRadius; + _farRadius = newResolved.FarRadius; _streamingController.ReconfigureRadii(_nearRadius, _farRadius); _streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame; @@ -4952,7 +4412,7 @@ public sealed class GameWindow : IDisposable if (_playerMode && _cameraController?.IsChaseMode == true) { modeLabel = "Chase"; current = _sensChase; } else if (_cameraController?.IsFlyMode == true) - { modeLabel = "Fly"; current = _sensFly; } + { modeLabel = "Fly"; current = _sensFly; } else { modeLabel = "Orbit"; current = _sensOrbit; } @@ -4983,7 +4443,7 @@ public sealed class GameWindow : IDisposable Console.WriteLine( $"=== F3 DEBUG DUMP ===\n" + $" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" + - $" landblock=0x{(uint)((lbX<<24)|(lbY<<16)|0xFFFF):X8} local=({pos.X - (lbX-_liveCenterX)*192f:F2},{pos.Y - (lbY-_liveCenterY)*192f:F2})\n" + + $" landblock=0x{(uint)((lbX << 24) | (lbY << 16) | 0xFFFF):X8} local=({pos.X - (lbX - _liveCenterX) * 192f:F2},{pos.Y - (lbY - _liveCenterY) * 192f:F2})\n" + $" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}"); var visibleNearby = new List(); @@ -5103,7 +4563,9 @@ public sealed class GameWindow : IDisposable [ new("world frame composition", () => { + _worldSceneRenderer = null; _worldRenderFrameBuilder = null; + _worldSceneSkyState = null; _skyPesFrame = null; _retailPViewPassExecutor = null; _retailPViewCells = null; diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs index 00c4426c..fa93120e 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -377,7 +377,9 @@ void main() { } // depth-only: color writes are masked off by the caller state _gl.ColorMask(true, true, true, true); _gl.DepthMask(true); _gl.DepthFunc(DepthFunction.Less); - _gl.Enable(EnableCap.CullFace); + // Renderers opt into culling locally; the shared frame convention is + // cull-off. Keep this success exit identical to frame-abort recovery. + _gl.Disable(EnableCap.CullFace); _gl.CullFace(TriangleFace.Back); _gl.FrontFace(FrontFaceDirection.CW); _gl.UseProgram(0); diff --git a/src/AcDream.App/Rendering/RenderFrameGlStateController.cs b/src/AcDream.App/Rendering/RenderFrameGlStateController.cs new file mode 100644 index 00000000..b8a68c4e --- /dev/null +++ b/src/AcDream.App/Rendering/RenderFrameGlStateController.cs @@ -0,0 +1,113 @@ +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +internal interface IRenderFrameGlState +{ + void RestoreFrameDefaults(); +} + +internal interface IRenderFrameGlStateApi +{ + void SetCapability(EnableCap capability, bool enabled); + + void ColorMask(bool red, bool green, bool blue, bool alpha); + + void StencilMask(uint mask); + + void DepthMask(bool enabled); + + void DepthFunc(DepthFunction function); + + void CullFace(TriangleFace face); + + void FrontFace(FrontFaceDirection direction); + + void UseProgram(uint program); + + void BindVertexArray(uint vertexArray); + + void BindBuffer(BufferTargetARB target, uint buffer); +} + +internal sealed class SilkRenderFrameGlStateApi : IRenderFrameGlStateApi +{ + private readonly GL _gl; + + public SilkRenderFrameGlStateApi(GL gl) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + } + + public void SetCapability(EnableCap capability, bool enabled) + { + if (enabled) + _gl.Enable(capability); + else + _gl.Disable(capability); + } + + public void ColorMask(bool red, bool green, bool blue, bool alpha) => + _gl.ColorMask(red, green, blue, alpha); + + public void StencilMask(uint mask) => _gl.StencilMask(mask); + + public void DepthMask(bool enabled) => _gl.DepthMask(enabled); + + public void DepthFunc(DepthFunction function) => _gl.DepthFunc(function); + + public void CullFace(TriangleFace face) => _gl.CullFace(face); + + public void FrontFace(FrontFaceDirection direction) => _gl.FrontFace(direction); + + public void UseProgram(uint program) => _gl.UseProgram(program); + + public void BindVertexArray(uint vertexArray) => _gl.BindVertexArray(vertexArray); + + public void BindBuffer(BufferTargetARB target, uint buffer) => + _gl.BindBuffer(target, buffer); +} + +/// +/// Restores the frame-global OpenGL convention shared by frame clear and +/// exceptional world-pass rollback. This prevents a failed doorway scissor or +/// portal depth mask from clipping/color-masking the next frame's clear. +/// +internal sealed class RenderFrameGlStateController : IRenderFrameGlState +{ + private readonly IRenderFrameGlStateApi _gl; + + public RenderFrameGlStateController(IRenderFrameGlStateApi gl) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + } + + public void RestoreFrameDefaults() + { + _gl.SetCapability(EnableCap.ScissorTest, enabled: false); + _gl.SetCapability(EnableCap.StencilTest, enabled: false); + _gl.SetCapability(EnableCap.Blend, enabled: false); + _gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false); + for (int index = 0; index < ClipFrame.MaxPlanes; index++) + { + _gl.SetCapability( + EnableCap.ClipDistance0 + index, + enabled: false); + } + + _gl.ColorMask(true, true, true, true); + _gl.StencilMask(0xFF); + _gl.DepthMask(true); + _gl.DepthFunc(DepthFunction.Less); + _gl.SetCapability(EnableCap.DepthTest, enabled: true); + // Renderers that need face culling establish it locally. The shared + // frame convention is culling off; SkyRenderer restores the state it + // observed, so enabling it here would leak culling into later passes. + _gl.SetCapability(EnableCap.CullFace, enabled: false); + _gl.CullFace(TriangleFace.Back); + _gl.FrontFace(FrontFaceDirection.CW); + _gl.UseProgram(0); + _gl.BindVertexArray(0); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + } +} diff --git a/src/AcDream.App/Rendering/RenderFrameResourceController.cs b/src/AcDream.App/Rendering/RenderFrameResourceController.cs index 7d98733f..49ed2bf6 100644 --- a/src/AcDream.App/Rendering/RenderFrameResourceController.cs +++ b/src/AcDream.App/Rendering/RenderFrameResourceController.cs @@ -33,6 +33,11 @@ internal interface IRenderFrameClearPhase RenderFrameFoundation Clear(); } +internal interface IRenderFrameFoundationSource +{ + RenderFrameFoundation Foundation { get; } +} + internal interface IRenderFrameLivePreparation { void Prepare(int gpuSlot); @@ -43,7 +48,9 @@ internal interface IRenderFrameLivePreparation /// typed phases preserve resource begin, clear/state, then live publication /// order without exposing a renderer service bag. /// -internal sealed class RenderFrameResourceController : IRenderFrameResourcePhase +internal sealed class RenderFrameResourceController : + IRenderFrameResourcePhase, + IRenderFrameFoundationSource { private readonly IRenderFrameSlotSource _frameSlots; private readonly IRenderFrameBeginResources _resources; @@ -162,6 +169,7 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase private readonly IRenderFramePortalStateSource _portal; private readonly ParticleVisibilityController _particleVisibility; private readonly WorldRenderDiagnostics _diagnostics; + private readonly IRenderFrameGlState _frameGlState; public RuntimeRenderFrameClearPhase( GL gl, @@ -169,7 +177,8 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase WeatherSystem weather, IRenderFramePortalStateSource portal, ParticleVisibilityController particleVisibility, - WorldRenderDiagnostics diagnostics) + WorldRenderDiagnostics diagnostics, + IRenderFrameGlState frameGlState) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); @@ -178,6 +187,8 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase _particleVisibility = particleVisibility ?? throw new ArgumentNullException(nameof(particleVisibility)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _frameGlState = frameGlState + ?? throw new ArgumentNullException(nameof(frameGlState)); } public RenderFrameFoundation Clear() @@ -203,13 +214,11 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase 1f); } - _gl.DepthMask(true); + _frameGlState.RestoreFrameDefaults(); _gl.Clear( ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit); - _gl.CullFace(TriangleFace.Back); - _gl.FrontFace(FrontFaceDirection.CW); _diagnostics.EmitGlStateTripwireIfChanged( AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled); diff --git a/src/AcDream.App/Rendering/RetailAlphaQueue.cs b/src/AcDream.App/Rendering/RetailAlphaQueue.cs index 672982d5..b4ff1e9e 100644 --- a/src/AcDream.App/Rendering/RetailAlphaQueue.cs +++ b/src/AcDream.App/Rendering/RetailAlphaQueue.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using System.Runtime.ExceptionServices; using System.Runtime.InteropServices; namespace AcDream.App.Rendering; @@ -56,7 +57,16 @@ internal readonly record struct RetailAlphaSubmission( /// material/renderer sort. /// /// -internal sealed class RetailAlphaQueue +internal interface IWorldSceneAlphaFrame +{ + void BeginFrame(); + + void EndFrame(); + + void AbortFrame(); +} + +internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame { private readonly List _submissions = new(256); private readonly List _sources = new(4); @@ -108,58 +118,93 @@ internal sealed class RetailAlphaQueue if (!IsCollecting) throw new InvalidOperationException("Retail alpha flush requires an active frame."); + Exception? drawFailure = null; + List? resetFailures = null; try { - if (_submissions.Count == 0) - return; - - SortRetailOrder(); - EnsureTokenCapacity(_submissions.Count); - EnsureSourceCapacity(_sources.Count); - Array.Clear(_sourceDrawOffsets, 0, _sources.Count); - - // Prepare each renderer once for this alpha scope. The filtered - // token sequence preserves final retail order for that source, so - // every later adjacent source-run maps to one contiguous prepared - // range without another GPU upload. - for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++) + if (_submissions.Count > 0) { - IRetailAlphaDrawSource source = _sources[sourceIndex]; - int sourceCount = 0; - for (int i = 0; i < _submissions.Count; i++) + SortRetailOrder(); + EnsureTokenCapacity(_submissions.Count); + EnsureSourceCapacity(_sources.Count); + Array.Clear(_sourceDrawOffsets, 0, _sources.Count); + + // Prepare each renderer once for this alpha scope. The filtered + // token sequence preserves final retail order for that source, so + // every later adjacent source-run maps to one contiguous prepared + // range without another GPU upload. + for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++) { - RetailAlphaSubmission submission = _submissions[i]; - if (ReferenceEquals(submission.Source, source)) - _tokenScratch[sourceCount++] = submission.Token; + IRetailAlphaDrawSource source = _sources[sourceIndex]; + int sourceCount = 0; + for (int i = 0; i < _submissions.Count; i++) + { + RetailAlphaSubmission submission = _submissions[i]; + if (ReferenceEquals(submission.Source, source)) + _tokenScratch[sourceCount++] = submission.Token; + } + + source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount)); } - source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount)); - } + int start = 0; + while (start < _submissions.Count) + { + IRetailAlphaDrawSource source = _submissions[start].Source; + int end = start + 1; + while (end < _submissions.Count + && ReferenceEquals(_submissions[end].Source, source)) + end++; - int start = 0; - while (start < _submissions.Count) - { - IRetailAlphaDrawSource source = _submissions[start].Source; - int end = start + 1; - while (end < _submissions.Count - && ReferenceEquals(_submissions[end].Source, source)) - end++; - - int count = end - start; - int sourceIndex = FindSourceIndex(source); - int firstPreparedDraw = _sourceDrawOffsets[sourceIndex]; - source.DrawPreparedAlphaBatch(firstPreparedDraw, count); - _sourceDrawOffsets[sourceIndex] += count; - start = end; + int count = end - start; + int sourceIndex = FindSourceIndex(source); + int firstPreparedDraw = _sourceDrawOffsets[sourceIndex]; + source.DrawPreparedAlphaBatch(firstPreparedDraw, count); + _sourceDrawOffsets[sourceIndex] += count; + start = end; + } } } + catch (Exception error) + { + drawFailure = error; + } finally { for (int i = 0; i < _sources.Count; i++) - _sources[i].ResetAlphaSubmissions(); + { + try + { + _sources[i].ResetAlphaSubmissions(); + } + catch (Exception error) + { + (resetFailures ??= []).Add(error); + } + } _sources.Clear(); _submissions.Clear(); } + + if (drawFailure is not null) + { + if (resetFailures is { Count: > 0 }) + { + resetFailures.Insert(0, drawFailure); + throw new AggregateException( + "Retail alpha drawing failed and its submissions could not be fully reset.", + resetFailures); + } + + ExceptionDispatchInfo.Capture(drawFailure).Throw(); + } + + if (resetFailures is { Count: > 0 }) + { + throw new AggregateException( + "Retail alpha submissions could not be fully reset.", + resetFailures); + } } public void EndFrame() @@ -177,6 +222,39 @@ internal sealed class RetailAlphaQueue } } + /// + /// Discards an incomplete frame without drawing it. Every source is still + /// told to release its retained submission payload so the next frame begins + /// from the same empty invariant as a successful flush. + /// + public void AbortFrame() + { + List? failures = null; + try + { + for (int i = 0; i < _sources.Count; i++) + { + try + { + _sources[i].ResetAlphaSubmissions(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + } + finally + { + _sources.Clear(); + _submissions.Clear(); + IsCollecting = false; + } + + if (failures is { Count: > 0 }) + throw new AggregateException("Retail alpha frame abort failed.", failures); + } + private void SortRetailOrder() { int count = _submissions.Count; diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index 632fcd74..0f6c73f9 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -74,9 +74,17 @@ internal sealed class RetailPViewParticleClassifications /// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather /// placement follows LScape::draw @ 0x00506330. /// -internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor +internal interface IOutdoorSceneParticleOwnerSource +{ + IReadOnlySet OutdoorSceneParticleEntityIds { get; } +} + +internal sealed class RetailPViewPassExecutor : + IRetailPViewPassExecutor, + IOutdoorSceneParticleOwnerSource { private readonly GL _gl; + private readonly IRenderFrameGlState _frameGlState; private readonly IRetailPViewFramebufferSource _framebuffer; private readonly ClipFrame _clipFrame; private readonly TerrainModernRenderer? _terrain; @@ -101,6 +109,7 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor public RetailPViewPassExecutor( GL gl, + IRenderFrameGlState frameGlState, IRetailPViewFramebufferSource framebuffer, ClipFrame clipFrame, TerrainModernRenderer? terrain, @@ -115,6 +124,8 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor TerrainDrawDiagnosticsController terrainDiagnostics) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _frameGlState = frameGlState + ?? throw new ArgumentNullException(nameof(frameGlState)); _framebuffer = framebuffer ?? throw new ArgumentNullException(nameof(framebuffer)); _clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame)); @@ -136,6 +147,28 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor _particleClassifications.BeginFrame(); } + public void AbortFrame() + { + List? failures = null; + TryAbort(_frameGlState.RestoreFrameDefaults); + TryAbort(_particleClassifications.BeginFrame); + TryAbort(_noSceneParticleEntityIds.Clear); + if (failures is { Count: > 0 }) + throw new AggregateException("Retail PView pass abort failed.", failures); + + void TryAbort(Action operation) + { + try + { + operation(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + } + public ClipFrameAssembly AssembleClipFrame( PortalVisibilityFrame portalFrame, ClipFrameAssembly reuseAssembly) => diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 9f79896a..70832eef 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -889,6 +889,7 @@ public interface IRetailPViewCellSource /// public interface IRetailPViewPassExecutor { + void AbortFrame(); void BeginFrame(); ClipFrameAssembly AssembleClipFrame( PortalVisibilityFrame portalFrame, @@ -1118,7 +1119,8 @@ public sealed class RetailPViewFrameInput public required int RenderRadius { get; init; } public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, - IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } + IReadOnlyDictionary? AnimatedById)> LandblockEntries + { get; init; } // Pass-presentation and diagnostic values consumed synchronously by the // typed executor. This input is data-only and is never retained. diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs index 6a029d55..b0e22c02 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs @@ -9,7 +9,19 @@ namespace AcDream.App.Rendering.Selection; /// The renderer builds one frame while input queries the previously completed /// frame, avoiding partial visibility state during multi-slice portal drawing. /// -internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource +internal interface IWorldSceneSelectionFrame +{ + void BeginFrame(); + + void CompleteFrame(); + + void AbortFrame(); +} + +internal sealed class RetailSelectionScene : + IRetailSelectionRenderSink, + IRetailSelectionLightingSource, + IWorldSceneSelectionFrame { private readonly IRetailSelectionGeometrySource _geometry; private readonly RetailSelectionLightingPulse _lightingPulse; @@ -100,6 +112,15 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail (_published, _building) = (_building, _published); } + /// Discards the incomplete building frame and preserves the last + /// completely published selection product. + public void AbortFrame() + { + _building.Clear(); + _buildingKeys.Clear(); + _viewFrustum = null; + } + public RetailSelectionHit? Pick( float mouseX, float mouseY, diff --git a/src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs b/src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs index a0494cdb..f922dd29 100644 --- a/src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs +++ b/src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs @@ -3,13 +3,22 @@ using AcDream.Core.Vfx; namespace AcDream.App.Rendering.Vfx; +internal interface IWorldSceneParticleVisibility +{ + void MarkVisibleCells(HashSet cellIds); + + void CompleteFrame(); + + void AbortFrame(); +} + /// /// Bridges the retained retail PView result into the next physics update's /// CObjCell::IsInView particle gate. The controller owns only immutable /// frame meaning: one completed viewer position plus the AC cells admitted by /// that completed view. It neither creates emitters nor performs rendering. /// -public sealed class ParticleVisibilityController +public sealed class ParticleVisibilityController : IWorldSceneParticleVisibility { public const float ExtendedRangeMultiplier = 2f; @@ -70,6 +79,15 @@ public sealed class ParticleVisibilityController _hasCompletedWorldView = true; } + /// Discards the in-progress visibility product while preserving + /// the last completed view consumed by the update thread. + public void AbortFrame() + { + _buildingCellIds.Clear(); + _frameUsesWorldView = false; + _frameOpen = false; + } + public void Apply(ParticleSystem particles, float rangeMultiplier) { ArgumentNullException.ThrowIfNull(particles); diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index 9e575b07..06e1632b 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -61,6 +61,18 @@ internal readonly record struct WorldRenderFrame( public LoadedCell? ClipRoot => Roots.ViewerRoot ?? Buildings.OutdoorNode; } +internal interface IWorldRenderFrameBuilder +{ + WorldRenderFrame Build( + in RenderFrameFoundation foundation, + bool waitingForLogin, + DayGroupData? activeDayGroup); + + void ObserveDrawableCells(IReadOnlySet drawableCells); + + void ClearDrawableCells(); +} + internal interface IWorldFrameCameraSource { WorldCameraFrame Resolve(); @@ -113,7 +125,7 @@ internal interface IWorldFrameBuildingSource /// Orders typed world-frame fact sources without owning their borrowed render /// resources. It makes no draw decision and retains no result from a prior call. /// -internal sealed class WorldRenderFrameBuilder +internal sealed class WorldRenderFrameBuilder : IWorldRenderFrameBuilder { private readonly IWorldFrameCameraSource _camera; private readonly IWorldFrameVisibilityPreparation _visibility; diff --git a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs new file mode 100644 index 00000000..a2e23b92 --- /dev/null +++ b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs @@ -0,0 +1,274 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; + +namespace AcDream.App.Rendering; + +internal readonly record struct WorldSceneDiagnosticOutcome( + int VisibleLandblocks, + int TotalLandblocks); + +internal interface IWorldSceneDiagnostics +{ + CameraCellResolution CameraCellResolution { get; } + + void EmitPViewInput( + PortalVisibilityFrame portalFrame, + Matrix4x4 viewProjection, + LoadedCell clipRoot, + Vector3 cameraPosition, + Vector3 playerPosition); + + void EmitRenderSignature( + string branch, + LoadedCell? clipRoot, + LoadedCell? viewerRoot, + LoadedCell? playerRoot, + uint viewerCellId, + uint playerCellId, + bool playerIndoorGate, + bool cameraInsideCell, + bool renderSky, + bool drawSkyThisFrame, + bool terrainDrawn, + TerrainClipMode terrainClipMode, + bool skyDrawn, + bool depthClear, + bool outdoorSceneryDrawn, + int liveDynamicDrawnCount, + string sceneParticles, + RetailPViewFrameResult? pviewResult, + Vector3 cameraPosition, + Vector3 playerPosition); + + WorldSceneDiagnosticOutcome DrawAndPublish( + in WorldCameraFrame camera, + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds); +} + +/// +/// Owns world-pass probes, collision wireframes, and the DebugVM facts derived +/// from one completed normal-world draw. None of these diagnostics influence +/// visibility or pass ordering. +/// +internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics +{ + private readonly WorldRenderDiagnostics _diagnostics; + private readonly IWorldScenePViewDiagnosticSource _pview; + private readonly IWorldSceneDebugStateSource _state; + private readonly DebugLineRenderer? _lines; + private readonly PhysicsEngine _physics; + private readonly ILocalPlayerModeSource _mode; + private readonly ILocalPlayerControllerSource _player; + private readonly DebugVmRenderFactsPublisher _debugVm; + private readonly bool _debugVmConsumerActive; + private int _debugDrawLogCount; + + public WorldSceneDiagnosticsController( + WorldRenderDiagnostics diagnostics, + IWorldScenePViewDiagnosticSource pview, + IWorldSceneDebugStateSource state, + DebugLineRenderer? lines, + PhysicsEngine physics, + ILocalPlayerModeSource mode, + ILocalPlayerControllerSource player, + DebugVmRenderFactsPublisher debugVm, + bool debugVmConsumerActive) + { + _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _pview = pview ?? throw new ArgumentNullException(nameof(pview)); + _state = state ?? throw new ArgumentNullException(nameof(state)); + _lines = lines; + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _mode = mode ?? throw new ArgumentNullException(nameof(mode)); + _player = player ?? throw new ArgumentNullException(nameof(player)); + _debugVm = debugVm ?? throw new ArgumentNullException(nameof(debugVm)); + _debugVmConsumerActive = debugVmConsumerActive; + } + + public CameraCellResolution CameraCellResolution => _pview.CameraCellResolution; + + public void EmitPViewInput( + PortalVisibilityFrame portalFrame, + Matrix4x4 viewProjection, + LoadedCell clipRoot, + Vector3 cameraPosition, + Vector3 playerPosition) + { + if (!RenderingDiagnostics.ProbePvInputEnabled) + return; + + _diagnostics.EmitPViewInput( + enabled: true, + portalFrame, + viewProjection, + clipRoot.IsOutdoorNode, + cameraPosition, + playerPosition, + _pview.RawPlayerPositionOr(playerPosition), + _pview.PlayerYaw, + _pview.SampleTerrainZ(cameraPosition.X, cameraPosition.Y)); + } + + public void EmitRenderSignature( + string branch, + LoadedCell? clipRoot, + LoadedCell? viewerRoot, + LoadedCell? playerRoot, + uint viewerCellId, + uint playerCellId, + bool playerIndoorGate, + bool cameraInsideCell, + bool renderSky, + bool drawSkyThisFrame, + bool terrainDrawn, + TerrainClipMode terrainClipMode, + bool skyDrawn, + bool depthClear, + bool outdoorSceneryDrawn, + int liveDynamicDrawnCount, + string sceneParticles, + RetailPViewFrameResult? pviewResult, + Vector3 cameraPosition, + Vector3 playerPosition) + { + _diagnostics.EmitRenderSignatureIfChanged( + RenderingDiagnostics.ProbeFlapEnabled, + branch, + clipRoot, + viewerRoot, + playerRoot, + viewerCellId, + playerCellId, + playerIndoorGate, + cameraInsideCell, + renderSky, + drawSkyThisFrame, + terrainDrawn, + terrainClipMode, + skyDrawn, + depthClear, + outdoorSceneryDrawn, + outdoorPortalDrawn: false, + outdoorRootObjectCount: 0, + liveDynamicDrawnCount, + sceneParticles, + pviewResult?.PortalFrame, + pviewResult?.ClipAssembly, + pviewResult?.DrawableCells, + pviewResult?.Partition, + exteriorPortalFrame: null, + exteriorClipAssembly: null, + exteriorDrawableCells: null, + exteriorPartition: null, + cameraPosition, + playerPosition); + } + + public WorldSceneDiagnosticOutcome DrawAndPublish( + in WorldCameraFrame camera, + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds) + { + ArgumentNullException.ThrowIfNull(bounds); + DrawCollisionWireframes(in camera); + + int visible = 0; + int total = 0; + foreach (var entry in bounds) + { + total++; + if (FrustumCuller.IsAabbVisible( + camera.Frustum, + entry.AabbMin, + entry.AabbMax)) + { + visible++; + } + } + + Vector3 nearestOrigin = + _mode.IsPlayerMode && _player.Controller is { } controller + ? controller.Position + : camera.Position; + _debugVm.PublishDebugVmFacts( + _debugVmConsumerActive, + visible, + total, + nearestOrigin, + _physics.ShadowObjects.AllEntriesForDebug()); + return new WorldSceneDiagnosticOutcome(visible, total); + } + + private void DrawCollisionWireframes(in WorldCameraFrame camera) + { + if (!_state.CollisionWireframesVisible || _lines is null) + return; + + _lines.Begin(); + int drawn = 0; + foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug()) + { + if (shadow.CollisionType == ShadowCollisionType.Cylinder) + { + float height = shadow.CylHeight > 0f + ? shadow.CylHeight + : shadow.Radius * 2f; + _lines.AddCylinder( + shadow.Position, + shadow.Radius, + height, + new Vector3(0f, 1f, 0f)); + } + else + { + _lines.AddCylinder( + shadow.Position - new Vector3(0f, 0f, shadow.Radius), + shadow.Radius, + shadow.Radius * 2f, + new Vector3(1f, 0.5f, 0f)); + } + drawn++; + } + + if (_mode.IsPlayerMode && _player.Controller is { } localPlayer) + { + Vector3 position = localPlayer.Position; + _lines.AddCylinder( + new Vector3(position.X, position.Y, position.Z), + DebugVmRenderFactsPublisher.PlayerCollisionRadius, + 1.8f, + new Vector3(1f, 0f, 0f)); + LogNearbyCollisionObjects(position, drawn); + } + + _lines.Flush(camera.Camera.View, camera.Projection); + } + + private void LogNearbyCollisionObjects(Vector3 playerPosition, int drawn) + { + if (_debugDrawLogCount >= 5) + return; + + Console.WriteLine( + $"debug frame {_debugDrawLogCount}: player=({playerPosition.X:F1},{playerPosition.Y:F1},{playerPosition.Z:F1}) drew={drawn} " + + $"totalReg={_physics.ShadowObjects.TotalRegistered}"); + int logged = 0; + foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug()) + { + float dx = shadow.Position.X - playerPosition.X; + float dy = shadow.Position.Y - playerPosition.Y; + float horizontalDistance = MathF.Sqrt(dx * dx + dy * dy); + if (horizontalDistance >= 10f) + continue; + + Console.WriteLine( + $" near id=0x{shadow.EntityId:X8} type={shadow.CollisionType} " + + $"pos=({shadow.Position.X:F1},{shadow.Position.Y:F1},{shadow.Position.Z:F1}) " + + $"r={shadow.Radius:F2} h={shadow.CylHeight:F2} dh={horizontalDistance:F2}"); + if (++logged >= 5) + break; + } + _debugDrawLogCount++; + } +} diff --git a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs new file mode 100644 index 00000000..cb5e9a8a --- /dev/null +++ b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs @@ -0,0 +1,328 @@ +using AcDream.App.Rendering.Sky; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Rendering; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +internal interface IWorldScenePassExecutor +{ + HashSet? TerrainVisibleCellIds { get; } + + void BeginFrame(); + + void PrepareFlatWorldClip(); + + void DrawFlatSky( + in WorldCameraFrame camera, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup, + float dayFraction); + + void DrawFlatTerrain(in WorldCameraFrame camera, uint? playerLandblockId); + + void DrawFlatEntities( + in WorldCameraFrame camera, + IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin, + System.Numerics.Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> entries, + uint? playerLandblockId, + HashSet animatedEntityIds); + + string DrawPostWorldParticles( + LoadedCell? clipRoot, + ClipFrameAssembly? clipAssembly, + in WorldCameraFrame camera, + IReadOnlySet outdoorOwnerIds, + string currentSignature); + + void DrawFlatWeather( + in WorldCameraFrame camera, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup, + float dayFraction); + + void DisableClipDistances(); + + void AbortFrame(); +} + +/// +/// Concrete GL leaf for the flat-world safety path and the post-world particle +/// and weather passes. Retail PView frames remain owned by +/// and . +/// +internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor +{ + private readonly GL _gl; + private readonly IRenderFrameGlState _frameGlState; + private readonly ClipFrame _clipFrame; + private readonly WbDrawDispatcher _entities; + private readonly EnvCellRenderer _environmentCells; + private readonly TerrainModernRenderer? _terrain; + private readonly TerrainDrawDiagnosticsController _terrainDiagnostics; + private readonly SkyRenderer? _sky; + private readonly ParticleSystem? _particles; + private readonly ParticleRenderer? _particleRenderer; + private readonly HashSet _visibleParticleOwners = []; + private readonly HashSet _noExcludedParticleOwners = []; + + public WorldScenePassExecutor( + GL gl, + IRenderFrameGlState frameGlState, + ClipFrame clipFrame, + WbDrawDispatcher entities, + EnvCellRenderer environmentCells, + TerrainModernRenderer? terrain, + TerrainDrawDiagnosticsController terrainDiagnostics, + SkyRenderer? sky, + ParticleSystem? particles, + ParticleRenderer? particleRenderer) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _frameGlState = frameGlState + ?? throw new ArgumentNullException(nameof(frameGlState)); + _clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame)); + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + _environmentCells = environmentCells + ?? throw new ArgumentNullException(nameof(environmentCells)); + _terrain = terrain; + _terrainDiagnostics = terrainDiagnostics + ?? throw new ArgumentNullException(nameof(terrainDiagnostics)); + _sky = sky; + _particles = particles; + _particleRenderer = particleRenderer; + } + + public HashSet? TerrainVisibleCellIds => _terrain?.VisibleCellIds; + + public void BeginFrame() + { + _visibleParticleOwners.Clear(); + _clipFrame.Reset(); + _entities.ClearClipRouting(); + _environmentCells.SetClipRouting(null); + } + + public void PrepareFlatWorldClip() + { + _clipFrame.ReserveTerrainUploads(_gl, 1); + _clipFrame.UploadRegions(_gl); + TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(_gl); + _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); + _environmentCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); + _terrain?.SetClipUbo(terrainBinding); + } + + public void DrawFlatSky( + in WorldCameraFrame camera, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup, + float dayFraction) + { + _clipFrame.BindTerrainClip(_gl); + EnableClipDistances(); + Exception? drawFailure = null; + try + { + _sky?.RenderSky( + camera.Camera, + camera.Position, + dayFraction, + activeDayGroup, + foundation.Sky, + foundation.EnvironOverrideActive); + } + catch (Exception error) + { + drawFailure = error; + throw; + } + finally + { + try + { + DisableClipDistances(); + } + catch (Exception closeFailure) when (drawFailure is not null) + { + throw new AggregateException( + "Sky drawing failed and its clip-distance bracket could not be closed.", + drawFailure, + closeFailure); + } + } + + if (_particles is not null && _particleRenderer is not null) + { + _particleRenderer.Draw( + camera.Camera, + camera.Position, + ParticleRenderPass.SkyPreScene); + } + } + + public void DrawFlatTerrain( + in WorldCameraFrame camera, + uint? playerLandblockId) + { + EnableClipDistances(); + _terrainDiagnostics.Begin(); + _terrain?.Draw( + camera.Camera, + camera.Frustum, + neverCullLandblockId: playerLandblockId); + _terrainDiagnostics.Complete(); + } + + public void DrawFlatEntities( + in WorldCameraFrame camera, + IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin, + System.Numerics.Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> entries, + uint? playerLandblockId, + HashSet animatedEntityIds) => + _entities.Draw( + camera.Camera, + entries, + camera.Frustum, + neverCullLandblockId: playerLandblockId, + visibleCellIds: null, + animatedEntityIds: animatedEntityIds); + + public string DrawPostWorldParticles( + LoadedCell? clipRoot, + ClipFrameAssembly? clipAssembly, + in WorldCameraFrame camera, + IReadOnlySet outdoorOwnerIds, + string currentSignature) + { + if (_particles is null || _particleRenderer is null) + return currentSignature; + + if (clipRoot is null) + { + if (clipAssembly is not null) + { + _particleRenderer.DrawForOwners( + camera.Camera, + camera.Position, + ParticleRenderPass.Scene, + _visibleParticleOwners, + includeUnattached: true, + excludedAttachedOwnerIds: _noExcludedParticleOwners); + return AppendSignature(currentSignature, "filtered"); + } + + _particleRenderer.Draw( + camera.Camera, + camera.Position, + ParticleRenderPass.Scene); + return AppendSignature(currentSignature, "global"); + } + + if (clipRoot.IsOutdoorNode) + { + _particleRenderer.DrawForOwners( + camera.Camera, + camera.Position, + ParticleRenderPass.Scene, + outdoorOwnerIds, + includeUnattached: true); + return AppendSignature(currentSignature, "unattached"); + } + + return currentSignature; + } + + public void DrawFlatWeather( + in WorldCameraFrame camera, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup, + float dayFraction) + { + _clipFrame.BindTerrainClip(_gl); + EnableClipDistances(); + Exception? drawFailure = null; + try + { + _sky?.RenderWeather( + camera.Camera, + camera.Position, + dayFraction, + activeDayGroup, + foundation.Sky, + foundation.EnvironOverrideActive); + } + catch (Exception error) + { + drawFailure = error; + throw; + } + finally + { + try + { + DisableClipDistances(); + } + catch (Exception closeFailure) when (drawFailure is not null) + { + throw new AggregateException( + "Weather drawing failed and its clip-distance bracket could not be closed.", + drawFailure, + closeFailure); + } + } + + if (_particles is not null && _particleRenderer is not null) + { + _particleRenderer.Draw( + camera.Camera, + camera.Position, + ParticleRenderPass.SkyPostScene); + } + } + + public void DisableClipDistances() + { + for (int index = 0; index < ClipFrame.MaxPlanes; index++) + _gl.Disable(EnableCap.ClipDistance0 + index); + } + + public void AbortFrame() + { + List? failures = null; + TryAbort(_frameGlState.RestoreFrameDefaults); + TryAbort(_clipFrame.Reset); + TryAbort(_entities.ClearClipRouting); + TryAbort(() => _environmentCells.SetClipRouting(null)); + _visibleParticleOwners.Clear(); + if (failures is { Count: > 0 }) + throw new AggregateException("World scene pass abort failed.", failures); + + void TryAbort(Action operation) + { + try + { + operation(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + } + + private void EnableClipDistances() + { + for (int index = 0; index < ClipFrame.MaxPlanes; index++) + _gl.Enable(EnableCap.ClipDistance0 + index); + } + + private static string AppendSignature(string current, string value) => + current == "none" ? value : current + "+" + value; +} diff --git a/src/AcDream.App/Rendering/WorldSceneRenderer.cs b/src/AcDream.App/Rendering/WorldSceneRenderer.cs new file mode 100644 index 00000000..0da63a0e --- /dev/null +++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs @@ -0,0 +1,345 @@ +using AcDream.App.Rendering.Selection; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Rendering; + +namespace AcDream.App.Rendering; + +internal interface IWorldScenePViewRenderer +{ + IReadOnlySet OutdoorSceneParticleEntityIds { get; } + + RetailPViewFrameResult DrawInside(RetailPViewFrameInput input); + + void AbortFrame(); +} + +internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer +{ + private readonly RetailPViewRenderer _renderer; + private readonly IRetailPViewPassExecutor _passes; + private readonly IOutdoorSceneParticleOwnerSource _particles; + + public WorldScenePViewRenderer( + RetailPViewRenderer renderer, + IRetailPViewPassExecutor passes, + IOutdoorSceneParticleOwnerSource particles) + { + _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); + _passes = passes ?? throw new ArgumentNullException(nameof(passes)); + _particles = particles ?? throw new ArgumentNullException(nameof(particles)); + } + + public IReadOnlySet OutdoorSceneParticleEntityIds => + _particles.OutdoorSceneParticleEntityIds; + + public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input) => + _renderer.DrawInside(input, _passes); + + public void AbortFrame() => _passes.AbortFrame(); +} + +/// +/// Owns the normal-world render transaction between shared frame-resource +/// preparation and private presentation. It preserves retail's one PView +/// decision: a resolved viewer root uses DrawInside; only an unresolved root +/// uses the flat safety path. +/// +internal sealed class WorldSceneRenderer : IWorldSceneFramePhase +{ + private readonly IRenderFrameFoundationSource _foundation; + private readonly IRenderLoginStateSource _login; + private readonly IWorldSceneSkyStateSource _sky; + private readonly IWorldRenderFrameBuilder _frames; + private readonly IWorldSceneEntitySource _entities; + private readonly IWorldSceneSelectionFrame? _selection; + private readonly IWorldSceneAlphaFrame _alpha; + private readonly IWorldSceneParticleVisibility _particleVisibility; + private readonly IWorldScenePViewRenderer _pview; + private readonly IRetailPViewCellSource _pviewCells; + private readonly IWorldScenePassExecutor _passes; + private readonly IWorldRenderRangeSource _renderRange; + private readonly IWorldSceneDiagnostics _diagnostics; + + public WorldSceneRenderer( + IRenderFrameFoundationSource foundation, + IRenderLoginStateSource login, + IWorldSceneSkyStateSource sky, + IWorldRenderFrameBuilder frames, + IWorldSceneEntitySource entities, + IWorldSceneSelectionFrame? selection, + IWorldSceneAlphaFrame alpha, + IWorldSceneParticleVisibility particleVisibility, + IWorldScenePViewRenderer pview, + IRetailPViewCellSource pviewCells, + IWorldScenePassExecutor passes, + IWorldRenderRangeSource renderRange, + IWorldSceneDiagnostics diagnostics) + { + _foundation = foundation ?? throw new ArgumentNullException(nameof(foundation)); + _login = login ?? throw new ArgumentNullException(nameof(login)); + _sky = sky ?? throw new ArgumentNullException(nameof(sky)); + _frames = frames ?? throw new ArgumentNullException(nameof(frames)); + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + _selection = selection; + _alpha = alpha ?? throw new ArgumentNullException(nameof(alpha)); + _particleVisibility = particleVisibility + ?? throw new ArgumentNullException(nameof(particleVisibility)); + _pview = pview ?? throw new ArgumentNullException(nameof(pview)); + _pviewCells = pviewCells ?? throw new ArgumentNullException(nameof(pviewCells)); + _passes = passes ?? throw new ArgumentNullException(nameof(passes)); + _renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange)); + _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + } + + public WorldRenderFrameOutcome Render(RenderFrameInput input) + { + _ = input; + bool selectionFrameStarted = _selection is not null; + bool worldFrameStarted = false; + bool pviewFrameStarted = false; + try + { + _selection?.BeginFrame(); + RenderFrameFoundation foundation = _foundation.Foundation; + if (foundation.PortalViewportVisible) + { + _selection?.CompleteFrame(); + selectionFrameStarted = false; + return default; + } + + // Set before BeginFrame so an already-poisoned queue is recovered by + // the same abort path instead of making every later frame fail. + worldFrameStarted = true; + _alpha.BeginFrame(); + WorldRenderFrame world = _frames.Build( + in foundation, + _login.IsWaitingForLogin, + _sky.ActiveDayGroup); + _passes.BeginFrame(); + + WorldCameraFrame camera = world.Camera; + WorldRootFrame roots = world.Roots; + LoadedCell? clipRoot = world.ClipRoot; + bool renderSky = roots.RenderSky; + bool drawSkyThisFrame = false; + bool terrainDrawn = false; + bool skyDrawn = false; + bool depthClear = false; + bool outdoorSceneryDrawn = false; + int liveDynamicDrawnCount = 0; + string sceneParticles = "none"; + TerrainClipMode terrainClipMode = TerrainClipMode.Planes; + RetailPViewFrameResult? pviewResult = null; + + if (clipRoot is null) + { + _passes.PrepareFlatWorldClip(); + drawSkyThisFrame = renderSky; + skyDrawn = drawSkyThisFrame; + if (drawSkyThisFrame) + { + _passes.DrawFlatSky( + in camera, + in foundation, + _sky.ActiveDayGroup, + _sky.DayFraction); + } + + // Retail keeps the live sky during EnterWorld while suppressing + // terrain and object geometry until chase mode has engaged. + if (_login.IsWaitingForLogin) + return CompleteSkippedWorld(); + + _passes.DrawFlatTerrain(in camera, roots.PlayerLandblockId); + terrainDrawn = true; + } + else + { + terrainClipMode = TerrainClipMode.Skip; + } + + if (clipRoot is not null) + { + pviewFrameStarted = true; + pviewResult = _pview.DrawInside( + new RetailPViewFrameInput + { + RootCell = clipRoot, + NearbyBuildingCells = world.Buildings.NearbyBuildingCells, + ViewerEyePos = roots.ViewerEyePosition, + ViewProjection = camera.ViewProjection, + Cells = _pviewCells, + Camera = camera.Camera, + CameraWorldPosition = camera.Position, + Frustum = camera.Frustum, + PlayerLandblockId = roots.PlayerLandblockId, + AnimatedEntityIds = world.AnimatedEntityIds, + RenderCenterLbX = roots.RenderCenterLandblockX, + RenderCenterLbY = roots.RenderCenterLandblockY, + RenderRadius = _renderRange.NearRadius, + LandblockEntries = _entities.LandblockEntries, + RenderSky = renderSky, + RenderWeather = roots.PlayerSeenOutside, + DayFraction = _sky.DayFraction, + ActiveDayGroup = _sky.ActiveDayGroup, + SkyKeyframe = foundation.Sky, + EnvironOverrideActive = foundation.EnvironOverrideActive, + ViewerCellId = roots.ViewerCellId, + PlayerCellId = roots.PlayerCellId, + PlayerViewPosition = roots.PlayerViewPosition, + CameraView = camera.Camera.View, + CameraCellResolution = _diagnostics.CameraCellResolution, + }); + + _particleVisibility.MarkVisibleCells(pviewResult.DrawableCells); + _frames.ObserveDrawableCells(pviewResult.DrawableCells); + _diagnostics.EmitPViewInput( + pviewResult.PortalFrame, + camera.ViewProjection, + clipRoot, + camera.Position, + roots.PlayerViewPosition); + + bool hasOutsideSlice = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; + terrainDrawn = hasOutsideSlice; + skyDrawn = renderSky && hasOutsideSlice; + // This mirrors the established diagnostic meaning, including + // outdoor roots whose PView executor does not issue an interior + // depth clear. + depthClear = hasOutsideSlice; + if (pviewResult.Partition.ByCell.Count > 0 || hasOutsideSlice) + sceneParticles = "pviewScoped"; + outdoorSceneryDrawn = pviewResult.Partition.OutdoorStatic.Count > 0 + && hasOutsideSlice; + liveDynamicDrawnCount = pviewResult.Partition.Dynamics.Count; + } + else + { + _passes.DrawFlatEntities( + in camera, + _entities.LandblockEntries, + roots.PlayerLandblockId, + world.AnimatedEntityIds); + _frames.ClearDrawableCells(); + } + + _passes.DisableClipDistances(); + sceneParticles = _passes.DrawPostWorldParticles( + clipRoot, + pviewResult?.ClipAssembly, + in camera, + _pview.OutdoorSceneParticleEntityIds, + sceneParticles); + + if (clipRoot is null && drawSkyThisFrame) + { + skyDrawn = true; + _passes.DrawFlatWeather( + in camera, + in foundation, + _sky.ActiveDayGroup, + _sky.DayFraction); + } + + _diagnostics.EmitRenderSignature( + clipRoot is null ? "OutdoorRoot" : "RetailPViewInside", + clipRoot, + roots.ViewerRoot, + roots.PlayerRoot, + roots.ViewerCellId, + roots.PlayerCellId, + roots.PlayerIndoorGate, + roots.CameraInsideCell, + renderSky, + drawSkyThisFrame, + terrainDrawn, + terrainClipMode, + skyDrawn, + depthClear, + outdoorSceneryDrawn, + liveDynamicDrawnCount, + sceneParticles, + pviewResult, + camera.Position, + roots.PlayerViewPosition); + + WorldSceneDiagnosticOutcome diagnostic = _diagnostics.DrawAndPublish( + in camera, + _entities.LandblockBounds); + CompleteWorldFrame(); + worldFrameStarted = false; + pviewFrameStarted = false; + _selection?.CompleteFrame(); + selectionFrameStarted = false; + return new WorldRenderFrameOutcome( + diagnostic.VisibleLandblocks, + diagnostic.TotalLandblocks, + NormalWorldDrawn: true); + + WorldRenderFrameOutcome CompleteSkippedWorld() + { + CompleteWorldFrame(); + worldFrameStarted = false; + pviewFrameStarted = false; + _selection?.CompleteFrame(); + selectionFrameStarted = false; + return default; + } + } + catch (Exception renderFailure) + { + List? abortFailures = AbortFrame( + worldFrameStarted, + pviewFrameStarted, + selectionFrameStarted); + if (abortFailures is { Count: > 0 }) + { + abortFailures.Insert(0, renderFailure); + throw new AggregateException( + "World rendering failed and the incomplete frame could not be fully aborted.", + abortFailures); + } + throw; + } + } + + private void CompleteWorldFrame() + { + _alpha.EndFrame(); + if (_passes.TerrainVisibleCellIds is { } visibleTerrainCells) + _particleVisibility.MarkVisibleCells(visibleTerrainCells); + _particleVisibility.CompleteFrame(); + } + + private List? AbortFrame( + bool worldFrameStarted, + bool pviewFrameStarted, + bool selectionFrameStarted) + { + List? failures = null; + if (worldFrameStarted) + { + if (pviewFrameStarted) + TryAbort(_pview.AbortFrame); + TryAbort(_passes.AbortFrame); + TryAbort(_particleVisibility.AbortFrame); + TryAbort(_alpha.AbortFrame); + } + if (selectionFrameStarted && _selection is not null) + TryAbort(_selection.AbortFrame); + return failures; + + void TryAbort(Action abort) + { + try + { + abort(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + } +} diff --git a/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs new file mode 100644 index 00000000..bd728e53 --- /dev/null +++ b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs @@ -0,0 +1,117 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Streaming; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +internal interface IWorldSceneSkyStateSource +{ + DayGroupData? ActiveDayGroup { get; } + + float DayFraction { get; } +} + +/// +/// Holds the selected retail day group beside the world clock that provides +/// its current interpolation point. Day-group selection remains an update-time +/// concern; render owners receive this read-only seam. +/// +internal sealed class WorldSceneSkyState : IWorldSceneSkyStateSource +{ + private readonly WorldTimeService _worldTime; + + public WorldSceneSkyState(WorldTimeService worldTime) + { + _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); + } + + public DayGroupData? ActiveDayGroup { get; set; } + + public float DayFraction => (float)_worldTime.DayFraction; +} + +internal interface IWorldSceneEntitySource +{ + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> LandblockEntries + { get; } + + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds { get; } +} + +internal sealed class RuntimeWorldSceneEntitySource : IWorldSceneEntitySource +{ + private readonly GpuWorldState _world; + + public RuntimeWorldSceneEntitySource(GpuWorldState world) + { + _world = world ?? throw new ArgumentNullException(nameof(world)); + } + + public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> LandblockEntries => + _world.LandblockEntries; + + public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds => + _world.LandblockBounds; +} + +internal interface IWorldScenePViewDiagnosticSource +{ + Vector3 RawPlayerPositionOr(Vector3 fallback); + + float PlayerYaw { get; } + + float? SampleTerrainZ(float worldX, float worldY); + + CameraCellResolution CameraCellResolution { get; } +} + +internal sealed class RuntimeWorldScenePViewDiagnosticSource : + IWorldScenePViewDiagnosticSource +{ + private readonly ILocalPlayerControllerSource _player; + private readonly PhysicsEngine _physics; + private readonly CellVisibility _visibility; + + public RuntimeWorldScenePViewDiagnosticSource( + ILocalPlayerControllerSource player, + PhysicsEngine physics, + CellVisibility visibility) + { + _player = player ?? throw new ArgumentNullException(nameof(player)); + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _visibility = visibility ?? throw new ArgumentNullException(nameof(visibility)); + } + + public Vector3 RawPlayerPositionOr(Vector3 fallback) => + _player.Controller?.Position ?? fallback; + + public float PlayerYaw => _player.Controller?.Yaw ?? 0f; + + public float? SampleTerrainZ(float worldX, float worldY) => + _physics.SampleTerrainZ(worldX, worldY); + + public CameraCellResolution CameraCellResolution => + _visibility.LastCameraCellResolution; +} + +internal interface IWorldSceneDebugStateSource +{ + bool CollisionWireframesVisible { get; } +} + +internal sealed class WorldSceneDebugState : IWorldSceneDebugStateSource +{ + public bool CollisionWireframesVisible { get; private set; } + + public bool ToggleCollisionWireframes() + { + CollisionWireframesVisible = !CollisionWireframesVisible; + return CollisionWireframesVisible; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index 6b1c9628..f8068059 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -29,7 +29,7 @@ public sealed class GameWindowRenderLeafCompositionTests "_renderFrameResources!.Prepare(frameInput);", "_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);", "_renderWeatherFrame!.Tick(deltaSeconds);", - "_retailSelectionScene?.BeginFrame();"); + "_worldSceneRenderer!.Render(frameInput);"); Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source); Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source); Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source); @@ -126,12 +126,10 @@ public sealed class GameWindowRenderLeafCompositionTests { string source = GameWindowSource(); - Assert.Contains("bool normalWorldDrawn = false;", source); AssertAppearsInOrder( source, - "if (IsLiveModeWaitingForLogin)", - "goto SkipWorldGeometry;", - "normalWorldDrawn = true;", + "_worldSceneRenderer!.Render(frameInput);", + "worldOutcome.NormalWorldDrawn;", "bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;", "normalWorldDrawn),", "screenshotCaptured)));"); @@ -158,8 +156,9 @@ public sealed class GameWindowRenderLeafCompositionTests Assert.Contains( "new AcDream.App.Rendering.TerrainDrawDiagnosticsController(", source); - Assert.Contains("_terrainDrawDiagnostics!.Begin();", source); - Assert.Contains("_terrainDrawDiagnostics.Complete();", source); + Assert.Contains("new AcDream.App.Rendering.WorldScenePassExecutor(", source); + Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source); + Assert.DoesNotContain("_terrainDrawDiagnostics.Complete();", source); } private static string GameWindowSource() => File.ReadAllText(Path.Combine( diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameGlStateControllerTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameGlStateControllerTests.cs new file mode 100644 index 00000000..1f31ef3f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameGlStateControllerTests.cs @@ -0,0 +1,106 @@ +using AcDream.App.Rendering; +using Silk.NET.OpenGL; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RenderFrameGlStateControllerTests +{ + [Fact] + public void RestoreFrameDefaults_ReestablishesTheCompleteFrameGlobalContract() + { + var api = new RecordingApi(); + var state = new RenderFrameGlStateController(api); + + state.RestoreFrameDefaults(); + + Assert.Equal( + [ + "cap:ScissorTest:False", + "cap:StencilTest:False", + "cap:Blend:False", + "cap:SampleAlphaToMaskSgis:False", + "cap:ClipDistance0:False", + "cap:ClipDistance1:False", + "cap:ClipDistance2:False", + "cap:ClipDistance3:False", + "cap:ClipDistance4:False", + "cap:ClipDistance5:False", + "cap:ClipDistance6:False", + "cap:ClipDistance7:False", + "color-mask:True:True:True:True", + "stencil-mask:255", + "depth-mask:True", + "depth-func:Less", + "cap:DepthTest:True", + "cap:CullFace:False", + "cull:Back", + "front:CW", + "program:0", + "vao:0", + "buffer:ArrayBuffer:0", + ], + api.Calls); + } + + [Fact] + public void PortalDepthMask_SuccessExitRestoresTheSameCullOffConvention() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "PortalDepthMaskRenderer.cs")); + int restore = source.IndexOf( + "// ---- restore the frame-global convention ----", + StringComparison.Ordinal); + + Assert.True(restore >= 0, "Missing portal-depth state restore block."); + string restoreBlock = source[restore..]; + Assert.Contains("_gl.Disable(EnableCap.CullFace);", restoreBlock); + Assert.DoesNotContain("_gl.Enable(EnableCap.CullFace);", restoreBlock); + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } + + private sealed class RecordingApi : IRenderFrameGlStateApi + { + public List Calls { get; } = []; + + public void SetCapability(EnableCap capability, bool enabled) => + Calls.Add($"cap:{capability}:{enabled}"); + + public void ColorMask(bool red, bool green, bool blue, bool alpha) => + Calls.Add($"color-mask:{red}:{green}:{blue}:{alpha}"); + + public void StencilMask(uint mask) => Calls.Add($"stencil-mask:{mask}"); + + public void DepthMask(bool enabled) => Calls.Add($"depth-mask:{enabled}"); + + public void DepthFunc(DepthFunction function) => + Calls.Add($"depth-func:{function}"); + + public void CullFace(TriangleFace face) => Calls.Add($"cull:{face}"); + + public void FrontFace(FrontFaceDirection direction) => + Calls.Add($"front:{direction}"); + + public void UseProgram(uint program) => Calls.Add($"program:{program}"); + + public void BindVertexArray(uint vertexArray) => Calls.Add($"vao:{vertexArray}"); + + public void BindBuffer(BufferTargetARB target, uint buffer) => + Calls.Add($"buffer:{target}:{buffer}"); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs index f08673d0..208ff93b 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs @@ -87,10 +87,8 @@ public sealed class RenderFrameResourceControllerTests "_particleVisibility.Reset();", "SkyKeyframe sky = _worldTime.CurrentSky;", "AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky);", - "_gl.DepthMask(true);", + "_frameGlState.RestoreFrameDefaults();", "_gl.Clear(", - "_gl.CullFace(TriangleFace.Back);", - "_gl.FrontFace(FrontFaceDirection.CW);", "_diagnostics.EmitGlStateTripwireIfChanged("); } diff --git a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs index bad2df97..a6338406 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs @@ -179,6 +179,76 @@ public sealed class RetailAlphaQueueTests log); } + [Fact] + public void AbortFrame_DiscardsPayloadAndAllowsTheNextFrameToRender() + { + var log = new List(); + var source = new RecordingSource("alpha", log); + var queue = new RetailAlphaQueue(); + + queue.BeginFrame(); + queue.Submit(source, 1, 12f); + queue.AbortFrame(); + + Assert.False(queue.IsCollecting); + Assert.Equal(0, queue.PendingCount); + Assert.Empty(log); + Assert.Equal(1, source.ResetCount); + + queue.BeginFrame(); + queue.Submit(source, 2, 12f); + queue.EndFrame(); + + Assert.Equal(["alpha:2"], log); + Assert.Equal(2, source.ResetCount); + } + + [Fact] + public void EndFrame_DrawAndResetFailuresPreserveThePrimaryFailureAndClearTheFrame() + { + var drawSource = new FailureSource("draw failed", "first reset failed"); + var secondSource = new FailureSource(null, null); + var queue = new RetailAlphaQueue(); + + queue.BeginFrame(); + queue.Submit(drawSource, 1, 20f); + queue.Submit(secondSource, 2, 10f); + + AggregateException failure = Assert.Throws(queue.EndFrame); + + Assert.Collection( + failure.InnerExceptions, + error => Assert.Equal("draw failed", error.Message), + error => Assert.Equal("first reset failed", error.Message)); + Assert.Equal(1, drawSource.ResetCount); + Assert.Equal(1, secondSource.ResetCount); + Assert.Equal(0, queue.PendingCount); + Assert.False(queue.IsCollecting); + } + + [Fact] + public void EndFrame_MultipleResetFailuresAttemptEverySourceAndClearTheFrame() + { + var first = new FailureSource(null, "first reset failed"); + var second = new FailureSource(null, "second reset failed"); + var queue = new RetailAlphaQueue(); + + queue.BeginFrame(); + queue.Submit(first, 1, 20f); + queue.Submit(second, 2, 10f); + + AggregateException failure = Assert.Throws(queue.EndFrame); + + Assert.Collection( + failure.InnerExceptions, + error => Assert.Equal("first reset failed", error.Message), + error => Assert.Equal("second reset failed", error.Message)); + Assert.Equal(1, first.ResetCount); + Assert.Equal(1, second.ResetCount); + Assert.Equal(0, queue.PendingCount); + Assert.False(queue.IsCollecting); + } + [Fact] public void ComputeViewerDistance_UsesTransformedGfxSortCenter() { @@ -224,4 +294,28 @@ public sealed class RetailAlphaQueueTests public void ResetAlphaSubmissions() => ResetCount++; } + + private sealed class FailureSource( + string? drawFailure, + string? resetFailure) : IRetailAlphaDrawSource + { + public int ResetCount { get; private set; } + + public void PrepareAlphaDraws(ReadOnlySpan tokens) + { + } + + public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) + { + if (drawFailure is not null) + throw new InvalidOperationException(drawFailure); + } + + public void ResetAlphaSubmissions() + { + ResetCount++; + if (resetFailure is not null) + throw new InvalidOperationException(resetFailure); + } + } } diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index 3d1a21d7..f771b229 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -229,7 +229,8 @@ public sealed class RetailPViewPassExecutorTests "GameWindow.cs")); Assert.Contains("new AcDream.App.Rendering.RetailPViewPassExecutor(", source); - Assert.Contains("new AcDream.App.Rendering.RetailPViewFrameInput", source); + Assert.Contains("new AcDream.App.Rendering.WorldScenePViewRenderer(", source); + Assert.DoesNotContain("new AcDream.App.Rendering.RetailPViewFrameInput", source); Assert.DoesNotContain("RetailPViewDrawContext", source); Assert.DoesNotContain("DrawLandscapeSlice =", source); Assert.DoesNotContain("DrawExitPortalMasks =", source); @@ -399,15 +400,15 @@ public sealed class RetailPViewPassExecutorTests uint id, uint serverGuid = 0, uint? parentCellId = null) => new() - { - Id = id, - ServerGuid = serverGuid, - SourceGfxObjOrSetupId = 0, - Position = Vector3.Zero, - Rotation = Quaternion.Identity, - MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)], - ParentCellId = parentCellId, - }; + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)], + ParentCellId = parentCellId, + }; private static string MethodBody(string source, string signature) { @@ -475,6 +476,8 @@ public sealed class RetailPViewPassExecutorTests private sealed class RecordingExecutor : IRetailPViewPassExecutor, IDisposable { + public void AbortFrame() => Operations.Add("abort"); + private readonly ClipFrame _clipFrame = ClipFrame.NoClip(); public List Operations { get; } = []; diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs index 9fb24950..5b02b0f3 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs @@ -45,6 +45,29 @@ public sealed class RetailSelectionSceneTests Assert.False(scene.TryGetLighting(entity.ServerGuid, entity.Id, out _)); } + [Fact] + public void AbortFrame_DiscardsBuildingSelectionAndPreservesPublishedFrame() + { + var scene = CreateScene(); + var published = Entity(localId: 46u, serverGuid: 0x5000_0046u); + Publish(scene, published); + var rejected = Entity(localId: 47u, serverGuid: 0x5000_0047u); + + scene.BeginFrame(); + (Matrix4x4 view, Matrix4x4 projection) = Camera(); + scene.SetViewFrustum(FrustumPlanes.FromViewProjection(view * projection)); + scene.AddVisiblePart( + rejected, + partIndex: 0, + gfxObjId: 0x0100_0001u, + Matrix4x4.CreateTranslation(0f, 0f, -5f)); + scene.AbortFrame(); + + RetailSelectionHit? hit = PickCenter(scene); + Assert.NotNull(hit); + Assert.Equal(published.Id, hit.Value.LocalEntityId); + } + private static RetailSelectionScene CreateScene( RetailSelectionLightingPulse? pulse = null) { diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/ParticleVisibilityControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/ParticleVisibilityControllerTests.cs index 48b8799f..8d17b725 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/ParticleVisibilityControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/ParticleVisibilityControllerTests.cs @@ -64,4 +64,35 @@ public sealed class ParticleVisibilityControllerTests controller.Apply(particles, 1f); Assert.False(Assert.Single(particles.EnumerateEmitters()).ViewEligible); } + + [Fact] + public void AbortedFrame_PreservesTheLastCompletedWorldView() + { + var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(1)); + int handle = particles.SpawnEmitter( + new EmitterDesc + { + DatId = 0x32000003u, + Type = ParticleType.Still, + MaxDegradeDistance = 100f, + MaxParticles = 1, + }, + Vector3.Zero, + attachedObjectId: 42u); + particles.UpdateEmitterOwnerCell(handle, 0x01010001u); + var controller = new ParticleVisibilityController(); + + controller.BeginFrame(Vector3.Zero); + controller.UseWorldView(); + controller.MarkVisibleCells([0x01010001u]); + controller.CompleteFrame(); + + controller.BeginFrame(new Vector3(1000f, 1000f, 0f)); + controller.UseWorldView(); + controller.MarkVisibleCells([0x02020001u]); + controller.AbortFrame(); + controller.Apply(particles, 1f); + + Assert.True(Assert.Single(particles.EnumerateEmitters()).ViewEligible); + } } diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs index 498422ff..2af6301d 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -325,25 +325,32 @@ public sealed class WorldRenderFrameBuilderTests } [Fact] - public void Game_window_uses_the_typed_builder_and_releases_it_before_render_owners() + public void World_scene_uses_the_typed_builder_and_game_window_releases_it_before_render_owners() { - string source = GameWindowSource(); + string gameWindow = GameWindowSource(); + string worldScene = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "WorldSceneRenderer.cs")); Assert.Equal( 1, - CountOccurrences(source, "_worldRenderFrameBuilder!.Build(")); - Assert.DoesNotContain("private void UpdateSunFromSky(", source, StringComparison.Ordinal); - Assert.DoesNotContain("private void UpdateSkyPes(", source, StringComparison.Ordinal); - Assert.DoesNotContain("private static float ParseEnvFloat(", source, StringComparison.Ordinal); + CountOccurrences(worldScene, "_frames.Build(")); + Assert.DoesNotContain("private void UpdateSunFromSky(", gameWindow, StringComparison.Ordinal); + Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal); + Assert.DoesNotContain("private static float ParseEnvFloat(", gameWindow, StringComparison.Ordinal); AssertAppearsInOrder( - source, - "if (_cameraController is not null && !portalViewportVisible)", - "_retailAlphaQueue.BeginFrame();", - "_worldRenderFrameBuilder!.Build(", - "if (IsLiveModeWaitingForLogin)", - "normalWorldDrawn = true;"); + worldScene, + "_alpha.BeginFrame();", + "_frames.Build(", + "if (_login.IsWaitingForLogin)", + "_passes.DrawFlatTerrain(", + "NormalWorldDrawn: true"); AssertAppearsInOrder( - source, + gameWindow, + "_worldSceneRenderer = null;", "_worldRenderFrameBuilder = null;", "_skyPesFrame = null;", "new(\"equipped children\"", diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs new file mode 100644 index 00000000..b99d7c1f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs @@ -0,0 +1,744 @@ +using System.Numerics; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Selection; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Rendering; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class WorldSceneRendererTests +{ + [Fact] + public void PortalViewport_PublishesEmptySelectionFrameAndSkipsWorldOwners() + { + var rig = new Rig(portalVisible: true, waitingForLogin: false, clipRoot: null); + + WorldRenderFrameOutcome result = rig.Renderer.Render(default); + + Assert.Equal(default, result); + Assert.Equal(["selection:begin", "selection:complete"], rig.Calls); + } + + [Fact] + public void LoginWait_DrawsFlatSkyThenCompletesFrameWithoutWorldGeometry() + { + var rig = new Rig(portalVisible: false, waitingForLogin: true, clipRoot: null); + + WorldRenderFrameOutcome result = rig.Renderer.Render(default); + + Assert.False(result.NormalWorldDrawn); + Assert.Equal(0, result.VisibleLandblocks); + Assert.Equal(0, result.TotalLandblocks); + Assert.Equal( + [ + "selection:begin", + "alpha:begin", + "frame:build", + "passes:begin", + "flat:clip", + "flat:sky", + "alpha:end", + "visibility:mark", + "visibility:complete", + "selection:complete", + ], + rig.Calls); + } + + [Fact] + public void FlatWorld_PreservesTerrainEntityParticleWeatherAndCompletionOrder() + { + var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null); + + WorldRenderFrameOutcome result = rig.Renderer.Render(default); + + Assert.Equal(new WorldRenderFrameOutcome(3, 8, true), result); + Assert.Equal( + [ + "selection:begin", + "alpha:begin", + "frame:build", + "passes:begin", + "flat:clip", + "flat:sky", + "flat:terrain", + "flat:entities", + "frame:clear-cells", + "passes:disable-clip", + "particles:global", + "flat:weather", + "diagnostics:signature:OutdoorRoot", + "diagnostics:draw", + "alpha:end", + "visibility:mark", + "visibility:complete", + "selection:complete", + ], + rig.Calls); + + Assert.Equal(rig.Foundation, rig.Frames.Foundation); + Assert.False(rig.Frames.WaitingForLogin); + Assert.Same(rig.DayGroup, rig.Frames.ActiveDayGroup); + Assert.Equal(rig.Foundation, rig.Passes.SkyFoundation); + Assert.Equal(rig.Foundation, rig.Passes.WeatherFoundation); + Assert.Same(rig.DayGroup, rig.Passes.SkyDayGroup); + Assert.Same(rig.DayGroup, rig.Passes.WeatherDayGroup); + Assert.Equal(rig.DayFraction, rig.Passes.SkyDayFraction); + Assert.Equal(rig.DayFraction, rig.Passes.WeatherDayFraction); + } + + [Fact] + public void PViewWorld_UsesOnePViewProductAndPublishesItsDrawableCells() + { + var root = new LoadedCell + { + CellId = 0x01010001u, + IsOutdoorNode = false, + }; + var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root); + + WorldRenderFrameOutcome result = rig.Renderer.Render(default); + + Assert.True(result.NormalWorldDrawn); + Assert.Equal( + [ + "selection:begin", + "alpha:begin", + "frame:build", + "passes:begin", + "pview:draw", + "visibility:mark", + "frame:observe-cells", + "diagnostics:pview", + "passes:disable-clip", + "particles:none", + "diagnostics:signature:RetailPViewInside", + "diagnostics:draw", + "alpha:end", + "visibility:mark", + "visibility:complete", + "selection:complete", + ], + rig.Calls); + Assert.DoesNotContain("flat:clip", rig.Calls); + Assert.DoesNotContain("flat:terrain", rig.Calls); + Assert.DoesNotContain("flat:entities", rig.Calls); + Assert.NotNull(rig.PView.LastInput); + Assert.Same(rig.DayGroup, rig.PView.LastInput!.ActiveDayGroup); + Assert.Equal(rig.DayFraction, rig.PView.LastInput.DayFraction); + Assert.Equal(rig.Foundation.Sky, rig.PView.LastInput.SkyKeyframe); + Assert.True(rig.PView.LastInput.RenderWeather); + Assert.Equal(root.CellId, rig.PView.LastInput.ViewerCellId); + Assert.Equal(0x01010002u, rig.PView.LastInput.PlayerCellId); + Assert.Equal(4, rig.PView.LastInput.RenderRadius); + } + + [Fact] + public void OutdoorPView_UsesPViewOwnersForPostWorldParticlesWithoutFlatWeather() + { + var root = new LoadedCell + { + CellId = 0x01010001u, + IsOutdoorNode = true, + }; + var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root); + + WorldRenderFrameOutcome result = rig.Renderer.Render(default); + + Assert.True(result.NormalWorldDrawn); + Assert.Contains("particles:pviewScoped+unattached", rig.Calls); + Assert.Same(rig.PView.OutdoorSceneParticleEntityIds, rig.Passes.ParticleOwners); + Assert.Equal([0xCAFEu], rig.Passes.ParticleOwners); + Assert.DoesNotContain("flat:weather", rig.Calls); + } + + [Fact] + public void SealedInteriorPView_DisablesWeatherAndPropagatesEnvironmentOverride() + { + var root = new LoadedCell + { + CellId = 0x01010001u, + IsOutdoorNode = false, + }; + var rig = new Rig( + portalVisible: false, + waitingForLogin: false, + clipRoot: root, + playerSeenOutside: false, + environOverride: EnvironOverride.BlueFog); + + rig.Renderer.Render(default); + + Assert.NotNull(rig.PView.LastInput); + Assert.False(rig.PView.LastInput!.RenderWeather); + Assert.True(rig.PView.LastInput.EnvironOverrideActive); + } + + [Fact] + public void PViewFailure_AbortsPortalStateAndRecoversOnTheNextFrame() + { + var root = new LoadedCell + { + CellId = 0x01010001u, + IsOutdoorNode = false, + }; + var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root); + rig.PView.ThrowOnDraw = true; + + Assert.Throws(() => rig.Renderer.Render(default)); + + Assert.Equal( + [ + "selection:begin", + "alpha:begin", + "frame:build", + "passes:begin", + "pview:draw", + "pview:abort", + "passes:abort", + "visibility:abort", + "alpha:abort", + "selection:abort", + ], + rig.Calls); + + rig.Calls.Clear(); + rig.PView.ThrowOnDraw = false; + + Assert.True(rig.Renderer.Render(default).NormalWorldDrawn); + Assert.Contains("selection:complete", rig.Calls); + } + + [Fact] + public void DrawFailure_DoesNotPublishPartialAlphaSelectionOrParticleFrames() + { + var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null); + rig.Passes.ThrowOnTerrain = true; + + Assert.Throws(() => rig.Renderer.Render(default)); + + Assert.Equal( + [ + "selection:begin", + "alpha:begin", + "frame:build", + "passes:begin", + "flat:clip", + "flat:sky", + "flat:terrain", + "passes:abort", + "visibility:abort", + "alpha:abort", + "selection:abort", + ], + rig.Calls); + + rig.Calls.Clear(); + rig.Passes.ThrowOnTerrain = false; + WorldRenderFrameOutcome recovered = rig.Renderer.Render(default); + + Assert.True(recovered.NormalWorldDrawn); + Assert.Contains("alpha:end", rig.Calls); + Assert.Contains("selection:complete", rig.Calls); + } + + [Fact] + public void AbortFailure_IsAggregatedAfterEveryOtherFrameOwnerIsAborted() + { + var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null); + rig.Passes.ThrowOnTerrain = true; + rig.Passes.ThrowOnAbort = true; + + AggregateException failure = Assert.Throws( + () => rig.Renderer.Render(default)); + + Assert.Contains( + failure.InnerExceptions, + error => error is InvalidOperationException + && error.Message == "terrain failed"); + Assert.Contains( + failure.InnerExceptions, + error => error is InvalidOperationException + && error.Message == "pass abort failed"); + Assert.Contains("visibility:abort", rig.Calls); + Assert.Contains("alpha:abort", rig.Calls); + Assert.Contains("selection:abort", rig.Calls); + + rig.Calls.Clear(); + rig.Passes.ThrowOnTerrain = false; + rig.Passes.ThrowOnAbort = false; + Assert.True(rig.Renderer.Render(default).NormalWorldDrawn); + } + + [Fact] + public void GameWindow_DelegatesWorldRenderingWithoutOwningDrawBranches() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.Contains("new AcDream.App.Rendering.WorldSceneRenderer(", source); + Assert.Equal(1, CountOccurrences(source, "_worldSceneRenderer!.Render(frameInput);")); + Assert.DoesNotContain("_retailPViewRenderer.DrawInside(", source); + Assert.DoesNotContain("_retailAlphaQueue.BeginFrame();", source); + Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source); + Assert.DoesNotContain("SkipWorldGeometry:", source); + } + + [Fact] + public void ExtractedWorldOwners_RetainNoWindowDelegateOrBorrowedPViewProduct() + { + Type[] ownerTypes = + [ + typeof(WorldSceneRenderer), + typeof(WorldScenePassExecutor), + typeof(WorldSceneDiagnosticsController), + ]; + Type[] borrowedTypes = + [ + typeof(RetailPViewFrameResult), + typeof(PortalVisibilityFrame), + typeof(ClipFrameAssembly), + ]; + + foreach (Type owner in ownerTypes) + { + foreach (FieldInfo field in owner.GetFields( + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic)) + { + Assert.False( + typeof(GameWindow).IsAssignableFrom(field.FieldType), + $"{owner.Name}.{field.Name} retains GameWindow."); + Assert.False( + typeof(Delegate).IsAssignableFrom(field.FieldType), + $"{owner.Name}.{field.Name} retains a delegate callback."); + Assert.DoesNotContain(field.FieldType, borrowedTypes); + } + } + } + + private sealed class Rig + { + public Rig( + bool portalVisible, + bool waitingForLogin, + LoadedCell? clipRoot, + bool? playerSeenOutside = null, + EnvironOverride environOverride = EnvironOverride.None) + { + Calls = []; + DayGroup = new DayGroupData { Name = "sentinel-day-group" }; + DayFraction = 0.375f; + Foundation = new RenderFrameFoundation( + portalVisible, + new SkyKeyframe( + 0.25f, + 35f, + 45f, + new Vector3(0.1f, 0.2f, 0.3f), + 0.8f, + new Vector3(0.4f, 0.5f, 0.6f), + 0.7f, + new Vector3(0.7f, 0.8f, 0.9f), + 0.2f), + new AtmosphereSnapshot( + WeatherKind.Clear, + 1f, + new Vector3(0.1f, 0.2f, 0.3f), + 1f, + 100f, + FogMode.Linear, + 0f, + environOverride)); + var foundation = new FoundationSource(Foundation); + var login = new LoginSource(waitingForLogin); + var sky = new SkySource(DayGroup, DayFraction); + var frame = CreateFrame( + clipRoot, + playerSeenOutside ?? clipRoot is not null); + Frames = new FrameBuilder(Calls, frame); + var selection = new SelectionFrame(Calls); + var alpha = new AlphaFrame(Calls); + var visibility = new ParticleVisibility(Calls); + PView = new PViewRenderer(Calls); + Passes = new PassExecutor(Calls); + var diagnostics = new Diagnostics(Calls); + + Renderer = new WorldSceneRenderer( + foundation, + login, + sky, + Frames, + new EntitySource(), + selection, + alpha, + visibility, + PView, + new PViewCells(), + Passes, + new WorldRenderRangeState(4, 12), + diagnostics); + } + + public List Calls { get; } + + public RenderFrameFoundation Foundation { get; } + + public DayGroupData DayGroup { get; } + + public float DayFraction { get; } + + public FrameBuilder Frames { get; } + + public PViewRenderer PView { get; } + + public PassExecutor Passes { get; } + + public WorldSceneRenderer Renderer { get; } + } + + private sealed class FoundationSource(RenderFrameFoundation foundation) : + IRenderFrameFoundationSource + { + public RenderFrameFoundation Foundation { get; } = foundation; + } + + private sealed class LoginSource(bool waiting) : IRenderLoginStateSource + { + public bool IsWaitingForLogin { get; } = waiting; + } + + private sealed class SkySource( + DayGroupData activeDayGroup, + float dayFraction) : IWorldSceneSkyStateSource + { + public DayGroupData? ActiveDayGroup { get; } = activeDayGroup; + + public float DayFraction { get; } = dayFraction; + } + + private sealed class FrameBuilder(List calls, WorldRenderFrame frame) : + IWorldRenderFrameBuilder + { + public RenderFrameFoundation Foundation { get; private set; } + + public bool WaitingForLogin { get; private set; } + + public DayGroupData? ActiveDayGroup { get; private set; } + + public WorldRenderFrame Build( + in RenderFrameFoundation foundation, + bool waitingForLogin, + DayGroupData? activeDayGroup) + { + calls.Add("frame:build"); + Foundation = foundation; + WaitingForLogin = waitingForLogin; + ActiveDayGroup = activeDayGroup; + return frame; + } + + public void ObserveDrawableCells(IReadOnlySet drawableCells) => + calls.Add("frame:observe-cells"); + + public void ClearDrawableCells() => calls.Add("frame:clear-cells"); + } + + private sealed class EntitySource : IWorldSceneEntitySource + { + public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> LandblockEntries => + Array.Empty<(uint, Vector3, Vector3, IReadOnlyList, + IReadOnlyDictionary?)>(); + + public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds => + Array.Empty<(uint, Vector3, Vector3)>(); + } + + private sealed class SelectionFrame(List calls) : IWorldSceneSelectionFrame + { + public void BeginFrame() => calls.Add("selection:begin"); + + public void CompleteFrame() => calls.Add("selection:complete"); + + public void AbortFrame() => calls.Add("selection:abort"); + } + + private sealed class AlphaFrame(List calls) : IWorldSceneAlphaFrame + { + public void BeginFrame() => calls.Add("alpha:begin"); + + public void EndFrame() => calls.Add("alpha:end"); + + public void AbortFrame() => calls.Add("alpha:abort"); + } + + private sealed class ParticleVisibility(List calls) : + IWorldSceneParticleVisibility + { + public void MarkVisibleCells(HashSet cellIds) => + calls.Add("visibility:mark"); + + public void CompleteFrame() => calls.Add("visibility:complete"); + + public void AbortFrame() => calls.Add("visibility:abort"); + } + + private sealed class PViewRenderer : IWorldScenePViewRenderer + { + private readonly List _calls; + private readonly RetailPViewFrameResult _interiorResult; + private readonly RetailPViewFrameResult _outdoorResult; + + public PViewRenderer(List calls) + { + _calls = calls; + _interiorResult = new RetailPViewFrameResult().Reset( + new PortalVisibilityFrame(), + new ClipFrameAssembly(), + [], + new InteriorEntityPartition.Result()); + var outdoorPortalFrame = new PortalVisibilityFrame(); + outdoorPortalFrame.OutsideView.Add(new ViewPolygon( + [ + new Vector2(-1f, -1f), + new Vector2(1f, -1f), + new Vector2(1f, 1f), + new Vector2(-1f, 1f), + ])); + _outdoorResult = new RetailPViewFrameResult().Reset( + outdoorPortalFrame, + ClipFrameAssembler.Assemble(ClipFrame.NoClip(), outdoorPortalFrame), + [], + new InteriorEntityPartition.Result()); + } + + public IReadOnlySet OutdoorSceneParticleEntityIds { get; } = + new HashSet { 0xCAFEu }; + + public RetailPViewFrameInput? LastInput { get; private set; } + + public bool ThrowOnDraw { get; set; } + + public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input) + { + _calls.Add("pview:draw"); + LastInput = input; + if (ThrowOnDraw) + throw new InvalidOperationException("pview failed"); + return input.RootCell.IsOutdoorNode ? _outdoorResult : _interiorResult; + } + + public void AbortFrame() => _calls.Add("pview:abort"); + } + + private sealed class PViewCells : IRetailPViewCellSource + { + public LoadedCell? Find(uint cellId) => null; + } + + private sealed class PassExecutor(List calls) : IWorldScenePassExecutor + { + public bool ThrowOnTerrain { get; set; } + + public bool ThrowOnAbort { get; set; } + + public HashSet? TerrainVisibleCellIds { get; } = [0x01010001u]; + + public RenderFrameFoundation? SkyFoundation { get; private set; } + + public RenderFrameFoundation? WeatherFoundation { get; private set; } + + public DayGroupData? SkyDayGroup { get; private set; } + + public DayGroupData? WeatherDayGroup { get; private set; } + + public float SkyDayFraction { get; private set; } + + public float WeatherDayFraction { get; private set; } + + public IReadOnlySet? ParticleOwners { get; private set; } + + public void BeginFrame() => calls.Add("passes:begin"); + + public void PrepareFlatWorldClip() => calls.Add("flat:clip"); + + public void DrawFlatSky( + in WorldCameraFrame camera, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup, + float dayFraction) + { + calls.Add("flat:sky"); + SkyFoundation = foundation; + SkyDayGroup = activeDayGroup; + SkyDayFraction = dayFraction; + } + + public void DrawFlatTerrain(in WorldCameraFrame camera, uint? playerLandblockId) + { + calls.Add("flat:terrain"); + if (ThrowOnTerrain) + throw new InvalidOperationException("terrain failed"); + } + + public void DrawFlatEntities( + in WorldCameraFrame camera, + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> entries, + uint? playerLandblockId, + HashSet animatedEntityIds) => calls.Add("flat:entities"); + + public string DrawPostWorldParticles( + LoadedCell? clipRoot, + ClipFrameAssembly? clipAssembly, + in WorldCameraFrame camera, + IReadOnlySet outdoorOwnerIds, + string currentSignature) + { + ParticleOwners = outdoorOwnerIds; + string kind = clipRoot switch + { + null => "global", + { IsOutdoorNode: true } => currentSignature == "none" + ? "unattached" + : currentSignature + "+unattached", + _ => currentSignature, + }; + calls.Add($"particles:{kind}"); + return kind; + } + + public void DrawFlatWeather( + in WorldCameraFrame camera, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup, + float dayFraction) + { + calls.Add("flat:weather"); + WeatherFoundation = foundation; + WeatherDayGroup = activeDayGroup; + WeatherDayFraction = dayFraction; + } + + public void DisableClipDistances() => calls.Add("passes:disable-clip"); + + public void AbortFrame() + { + calls.Add("passes:abort"); + if (ThrowOnAbort) + throw new InvalidOperationException("pass abort failed"); + } + } + + private sealed class Diagnostics(List calls) : IWorldSceneDiagnostics + { + public CameraCellResolution CameraCellResolution => CameraCellResolution.None; + + public void EmitPViewInput( + PortalVisibilityFrame portalFrame, + Matrix4x4 viewProjection, + LoadedCell clipRoot, + Vector3 cameraPosition, + Vector3 playerPosition) => calls.Add("diagnostics:pview"); + + public void EmitRenderSignature( + string branch, + LoadedCell? clipRoot, + LoadedCell? viewerRoot, + LoadedCell? playerRoot, + uint viewerCellId, + uint playerCellId, + bool playerIndoorGate, + bool cameraInsideCell, + bool renderSky, + bool drawSkyThisFrame, + bool terrainDrawn, + TerrainClipMode terrainClipMode, + bool skyDrawn, + bool depthClear, + bool outdoorSceneryDrawn, + int liveDynamicDrawnCount, + string sceneParticles, + RetailPViewFrameResult? pviewResult, + Vector3 cameraPosition, + Vector3 playerPosition) => + calls.Add($"diagnostics:signature:{branch}"); + + public WorldSceneDiagnosticOutcome DrawAndPublish( + in WorldCameraFrame camera, + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds) + { + calls.Add("diagnostics:draw"); + return new WorldSceneDiagnosticOutcome(3, 8); + } + } + + private static WorldRenderFrame CreateFrame( + LoadedCell? clipRoot, + bool playerSeenOutside) + { + var camera = new FlyCamera { Position = new Vector3(1f, 2f, 3f) }; + var cameraFrame = new WorldCameraFrame( + camera, + camera.Projection, + camera.View * camera.Projection, + default, + Matrix4x4.Identity, + camera.Position); + var roots = new WorldRootFrame( + PlayerRoot: null, + PlayerSeenOutside: playerSeenOutside, + ViewerCellId: clipRoot?.CellId ?? 0u, + ViewerEyePosition: camera.Position, + PlayerViewPosition: camera.Position, + ViewerRoot: clipRoot, + CameraInsideCell: clipRoot is not null, + RootSeenOutside: true, + PlayerInsideCell: false, + PlayerLandblockId: null, + RenderCenterLandblockX: 0, + RenderCenterLandblockY: 0, + PlayerCellId: 0x01010002u, + PlayerIndoorGate: false); + return new WorldRenderFrame( + cameraFrame, + roots, + new WorldBuildingFrame(null, Array.Empty()), + []); + } + + private static int CountOccurrences(string source, string value) + { + int count = 0; + int cursor = 0; + while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0) + { + count++; + cursor += value.Length; + } + return count; + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} From 9d7df1bfc5ee6721113930dfe8e3ee09f253b0d7 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 08:03:49 +0200 Subject: [PATCH 88/98] refactor(render): compose render frame orchestrator Move the accepted draw transaction and failure recovery behind typed frame-phase owners so GameWindow only supplies immutable frame input. Preserve retail draw order, make ImGui/bootstrap shutdown ownership explicit, and restore exact text-render GL state on failures.\n\nCo-authored-by: Codex --- docs/architecture/code-structure.md | 56 +++- .../retail-divergence-register.md | 2 +- ...ndow-slice-7-render-frame-orchestration.md | 43 ++- .../Diagnostics/FrameScreenshotController.cs | 9 +- .../Rendering/DevToolsFramePresenter.cs | 60 ++++- src/AcDream.App/Rendering/GameWindow.cs | 253 ++++++----------- .../Rendering/PaperdollFramePresenter.cs | 2 +- .../Rendering/PrivatePresentationRenderer.cs | 148 ++++++++++ .../Rendering/RenderFrameOrchestrator.cs | 61 ++++- .../RenderFramePreparationController.cs | 35 +++ .../RenderFrameResourceController.cs | 2 +- .../Rendering/TextRenderGlStateScope.cs | 156 +++++++++++ src/AcDream.App/Rendering/TextRenderer.cs | 25 +- src/AcDream.UI.ImGui/ImGuiBootstrapper.cs | 10 + ...WorldLifecycleAutomationControllerTests.cs | 9 +- .../Rendering/DevToolsFramePresenterTests.cs | 101 ++++++- .../GameWindowRenderLeafCompositionTests.cs | 127 ++++++--- .../Rendering/PaperdollFramePresenterTests.cs | 2 +- .../PrivatePresentationRendererTests.cs | 255 ++++++++++++++++++ .../Rendering/RenderFrameOrchestratorTests.cs | 91 ++++++- .../RenderFramePreparationControllerTests.cs | 92 +++++++ .../RenderFrameRecoveryIntegrationTests.cs | 178 ++++++++++++ .../TextRendererFailureSafetyTests.cs | 217 +++++++++++++++ .../Rendering/WorldRenderFrameBuilderTests.cs | 17 +- .../Rendering/WorldSceneRendererTests.cs | 3 +- 25 files changed, 1675 insertions(+), 279 deletions(-) create mode 100644 src/AcDream.App/Rendering/PrivatePresentationRenderer.cs create mode 100644 src/AcDream.App/Rendering/RenderFramePreparationController.cs create mode 100644 src/AcDream.App/Rendering/TextRenderGlStateScope.cs create mode 100644 tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RenderFramePreparationControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 07d89b24..db4b89b7 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -1,9 +1,9 @@ # acdream — code structure & extraction sequence **Status:** Living document. Created 2026-05-16; implementation reconciliation -completed 2026-07-21; Slices 1–6 landed by 2026-07-22. Slice 7 draw-frame -orchestration is the active structural program before new M4 subsystems enter -the App layer. +completed 2026-07-21; Slices 1–6 and the Slice 7 draw-frame cutover landed by +2026-07-22. Slice 7 connected closeout is active before the final Slice 8 +composition/shutdown cleanup and new M4 subsystems enter the App layer. **Purpose:** Describe the desired structural state of the App layer, explain the rules we've adopted, and lay out the safe extraction sequence from today's reality (one 15,723-line `GameWindow.cs` at the @@ -30,6 +30,7 @@ after Slice 3 14,310 lines / 274 fields / 190 method after Slice 4 10,301 lines / 267 fields / 163 methods after Slice 5 closeout 8,811 lines / 247 fields / 153 methods after Slice 6 closeout 7,026 lines / 241 fields / 108 methods +after Slice 7 draw-frame cutover 4,666 lines / 196 fields / 70 methods ``` `GameWindow` is the single object that: @@ -168,6 +169,45 @@ App currently wires it. --- +### Render-frame ownership versus recursive reachability + +The typed `RenderFrameOrchestrator` graph owns frame sequencing and borrows +long-lived runtime/presentation owners. The enforceable ownership invariant is +that the orchestrator and its immediate phase owners retain neither +`GameWindow`, an anonymous callback bag, nor direct window delegates. It is +**not** an invariant that recursively walking every borrowed canonical owner +can never reach an event subscriber or callback that was composed by +`GameWindow`; that claim would be false and would confuse object-graph +reachability with frame ownership. + +Known recursive callback-bearing paths include (this is evidence and design +documentation, not an exhaustive allowlist): + +- `RetainedGameplayUiFrame -> RetailUiRuntime -> RetailUiRuntimeBindings` for + retained gameplay UI state and commands. +- `PrivatePresentationRenderer -> PaperdollFramePresenter -> + RetailPaperdollFrameView/PaperdollInventoryVisibility -> UiElement tree` for + private paperdoll visibility and texture publication. This is a second, + independent retained-UI entry because `UiElement.Parent` links back to the + shared root and sibling controls. +- `DevToolsFramePresenter -> DevToolsPanelSet -> panel/ViewModel bindings` for + the optional developer UI. +- `WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview -> SettingsVM` + for the live settings draft preview applied before world drawing. +- `LocalPlayerPortalViewport -> LocalPlayerTeleportController -> + GameplayInputFrameController -> InputDispatcher.Fired -> GameWindow` for the + canonical portal/input lifetime and the host's input-action subscription. + +These are presentation state/command seams, not alternative owners of the +window or frame transaction. The screenshot path is deliberately not on this +list: viewport width/height now travel in `RenderFrameInput`, so +`FrameScreenshotController` no longer stores a window-size callback. New +immediate phase owners must still satisfy the direct ownership invariant; +recursive shared-owner paths are reviewed against their canonical subsystem +lifetime rather than an impossible global no-callback assertion. + +--- + ## 3. Target structure of the App layer The end state — not what we're shipping in one pass, but the shape @@ -608,7 +648,7 @@ registered; this behavior-preserving extraction introduced no new divergence. Detailed execution ledger: [`docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md`](../plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md) -(active). +(connected closeout active). Move the complete draw graph and its reusable frame-local scratch state into a GL-owning App collaborator. Preserve the exact modern pipeline order, clip @@ -622,6 +662,14 @@ Automated acceptance uses framebuffer artifacts and render/resource checkpoints. Visual acceptance compares outdoor, building, dungeon, portal exit, translucent lifestone/particles, and UI at the same camera positions. +The production draw cutover is complete. `GameWindow.OnRender` now performs one +value-only handoff to `RenderFrameOrchestrator`; focused resource, world, +private-presentation, and diagnostic owners contain the former frame body and +failure recovery. The Release suite passes 7,341 tests with five intentional +skips. `GameWindow` is 4,666 raw lines / 196 fields / 70 methods: 11,057 lines +(70.3%) smaller than the campaign baseline. The connected lifecycle and +resource-soak closeout remains before Slice 7 is final. + #### Slice 8 — composition and shutdown cleanup Keep GL/window construction in `GameWindow.OnLoad`, but group creation into diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index bf61148e..3949a045 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -260,7 +260,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; shared frame drain in `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveEffectFrameController`) | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order | | TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveObjectFrameController` + `LiveEffectFrameController` shared `_particles.Tick` / `_scripts.Tick` tail); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order | | TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 | -| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slice 6 preserves the accepted host order as an ownership-only extraction. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/GameWindow.cs` (`_retailUiRuntime.Tick` in `OnRender`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | +| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | --- diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md index d10a9126..a327e38d 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md +++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md @@ -28,7 +28,7 @@ port, or resource-lifetime redesign. `RetailPViewPassExecutor` and data-only inputs. - [x] F — extract `WorldSceneRenderer`, including both shared-alpha scopes, PView/fallback world branches, particles, debug world draw, and completion. -- [ ] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one +- [x] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one handoff, and delete obsolete frame bodies, fields, helpers, and callbacks. - [ ] H — corrected-diff reviews, full Release suite, connected lifecycle and soak gates, framebuffer comparison, documentation, memory, and metrics. @@ -231,12 +231,15 @@ contract requires separate approval and evidence. ### 4.2 Borrowed resources and shutdown -Slice 7 collaborators borrow the already-created GL/render owners. They do not -dispose them. The existing retryable shutdown transaction keeps its proven -dependency order: wait for submitted work; withdraw UI/render publications; -release textures and mesh owners; then dispose GL backing stores, with -`GpuFrameFlightController` last among render resources. Slice 8 may group -construction/disposal after the render cutover is stable. +The frame orchestrator and phase owners borrow the already-created GL/render +owners and do not dispose them. The developer-tools backend is itself the +canonical owner of its ImGui bootstrap and disposes that bootstrap when the +existing retryable shutdown transaction retires the backend; the frame +presenter only borrows it. Shutdown keeps its proven dependency order: wait for +submitted work; withdraw UI/render publications; release textures and mesh +owners; then dispose GL backing stores, with `GpuFrameFlightController` last +among render resources. Slice 8 may group construction/disposal after the +render cutover is stable. `DisplayFramePacingController` is the focused shared owner for requested VSync, live display preview, monitor/window callbacks, and the separate @@ -354,7 +357,8 @@ Completed 2026-07-22. Paperdoll clone/re-dress/pose/FBO work now belongs to `PaperdollFramePresenter`; retained surfaces are narrow and optional, while live identity and DAT pose application resolve through focused adapters. `DevToolsFramePresenter` owns the ImGui frame, menus, panels, layout, and input -actions without taking shutdown ownership of the borrowed bootstrap. +actions while borrowing the bootstrap; `ImGuiDevToolsFrameBackend` explicitly +owns and disposes that bootstrap at shutdown. `WorldRenderDiagnostics`, `DebugVmRenderFactsPublisher`, and `RenderFrameDiagnosticsController` own the former render probes, debug facts, title/resource cadence, and public snapshot. The production handoff now reports @@ -471,12 +475,29 @@ corrected-diff reviews are clean. - compose concrete resource, world, private-presentation, and diagnostic owners; - replace `OnRender` with one `RenderFrameOrchestrator.Render` handoff; -- remove obsolete fields/helpers/callbacks and prove the transitive render graph - has no substantial path back to `GameWindow`; -- preserve the existing shutdown resource list without transferring disposal; +- remove obsolete fields/helpers/callbacks; prove immediate frame owners retain + no direct `GameWindow`/anonymous-delegate back-reference, and document rather + than deny recursive paths through borrowed canonical UI/input owners; +- preserve the existing shutdown dependency order while making the + developer-tools backend's pre-existing bootstrap ownership explicit; - measure line/field/method counts. Ownership decides completion; the expected signal is roughly 5,000–5,500 lines before Slice 8 composition cleanup. +Completed 2026-07-22. `GameWindow.OnRender` now creates one value-only +`RenderFrameInput` and hands the complete frame to `RenderFrameOrchestrator`. +Focused preparation, world-scene, private-presentation, and diagnostic owners +preserve the accepted resource → ImGui begin → weather → world → portal → +paperdoll → retained UI → ImGui submit → screenshot → diagnostics → GPU close +order. Recovery now closes an active ImGui frame through the owning Silk +controller and restores the exact text-render GL state after success or +failure. Screenshot dimensions travel in the frame input rather than through a +window callback, and immediate frame owners have no direct `GameWindow` or +anonymous-delegate back-reference. `GameWindow.cs` is 4,666 raw lines / 196 +fields / 70 methods: 11,057 lines (70.3%) below the 15,723-line campaign +baseline. The Release build and complete suite pass (7,341 passed / 5 skipped), +and retail-conformance, architecture, and adversarial corrected-diff reviews +are clean. + ### H — release and connected gates After three clean corrected-diff reviews: diff --git a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs index 8a050504..0170a5e3 100644 --- a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs +++ b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs @@ -20,7 +20,6 @@ internal sealed class FrameScreenshotController private sealed record CaptureStatus(CaptureState State, string? Error = null); - private readonly Func<(int Width, int Height)> _getSize; private readonly Func _readRgba; private readonly string _directory; private readonly Action _log; @@ -30,21 +29,18 @@ internal sealed class FrameScreenshotController public FrameScreenshotController( GL gl, - Func<(int Width, int Height)> getSize, string directory, Action? log = null) - : this(getSize, (width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log) + : this((width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log) { ArgumentNullException.ThrowIfNull(gl); } internal FrameScreenshotController( - Func<(int Width, int Height)> getSize, Func readRgba, string directory, Action? log = null) { - _getSize = getSize ?? throw new ArgumentNullException(nameof(getSize)); _readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba)); _directory = string.IsNullOrWhiteSpace(directory) ? throw new ArgumentException("A screenshot directory is required.", nameof(directory)) @@ -76,7 +72,7 @@ internal sealed class FrameScreenshotController && status.State == CaptureState.Complete; /// Captures at most one queued image on the current GL thread. - public bool CapturePending() + public bool CapturePending(int width, int height) { if (_pending.Count == 0) return false; @@ -84,7 +80,6 @@ internal sealed class FrameScreenshotController string name = _pending.Dequeue(); try { - (int width, int height) = _getSize(); if (width <= 0 || height <= 0) throw new InvalidOperationException($"invalid framebuffer size {width}x{height}"); diff --git a/src/AcDream.App/Rendering/DevToolsFramePresenter.cs b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs index d1bb4143..c4586550 100644 --- a/src/AcDream.App/Rendering/DevToolsFramePresenter.cs +++ b/src/AcDream.App/Rendering/DevToolsFramePresenter.cs @@ -25,10 +25,19 @@ internal enum DevToolsPanelKind Settings, } +internal interface IDevToolsFrameLifecycle : IRenderFrameFailureRecovery +{ + void BeginFrame(float deltaSeconds); + + void Render(double deltaSeconds, int viewportWidth, int viewportHeight); +} + internal interface IDevToolsFrameBackend { void BeginFrame(float deltaSeconds); + void AbortFrame(); + bool BeginMainMenuBar(); void EndMainMenuBar(); @@ -82,10 +91,11 @@ internal interface IDevToolsPanelSet } /// Concrete ImGui backend for the developer presentation owner. -internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend +internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDisposable { private readonly ImGuiBootstrapper _bootstrap; private readonly ImGuiPanelHost _panels; + private bool _disposed; public ImGuiDevToolsFrameBackend( ImGuiBootstrapper bootstrap, @@ -97,6 +107,8 @@ internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend public void BeginFrame(float deltaSeconds) => _bootstrap.BeginFrame(deltaSeconds); + public void AbortFrame() => _bootstrap.AbortFrame(); + public bool BeginMainMenuBar() => ImGuiNET.ImGui.BeginMainMenuBar(); public void EndMainMenuBar() => ImGuiNET.ImGui.EndMainMenuBar(); @@ -130,6 +142,15 @@ internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend ImGuiNET.ImGui.SetWindowSize(title, size, imguiCondition); } + public void Dispose() + { + if (_disposed) + return; + + _bootstrap.Dispose(); + _disposed = true; + } + } /// Mutable late-composition seam for player-mode menu operations. @@ -229,13 +250,14 @@ internal sealed class DevToolsPanelSet : IDevToolsPanelSet /// reusable default layout. Retained gameplay UI remains a separate earlier /// presentation phase. /// -internal sealed class DevToolsFramePresenter +internal sealed class DevToolsFramePresenter : IDevToolsFrameLifecycle { private readonly IDevToolsFrameBackend _backend; private readonly IDevToolsCameraMenuOperations _camera; private readonly IDevToolsCommandBusSource _commands; private readonly IDevToolsPanelSet _panels; private readonly AcDream.App.Diagnostics.FrameProfiler _profiler; + private bool _frameOpen; public DevToolsFramePresenter( IDevToolsFrameBackend backend, @@ -251,15 +273,47 @@ internal sealed class DevToolsFramePresenter _panels = panels ?? throw new ArgumentNullException(nameof(panels)); } - public void BeginFrame(float deltaSeconds) => _backend.BeginFrame(deltaSeconds); + public void BeginFrame(float deltaSeconds) + { + if (_frameOpen) + { + throw new InvalidOperationException( + "The previous developer-tools frame must render or abort before a new frame begins."); + } + + _frameOpen = true; + _backend.BeginFrame(deltaSeconds); + } public void Render(double deltaSeconds, int viewportWidth, int viewportHeight) { + if (!_frameOpen) + { + throw new InvalidOperationException( + "BeginFrame must open the developer-tools frame before Render."); + } + var context = new PanelContext((float)deltaSeconds, _commands.Current); DrawMenu(viewportWidth, viewportHeight); _backend.RenderPanels(context); using var stage = _profiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui); _backend.RenderDrawData(); + _frameOpen = false; + } + + public void AbortFrame() + { + if (!_frameOpen) + return; + + try + { + _backend.AbortFrame(); + } + finally + { + _frameOpen = false; + } } public void ToggleDebugPanel() diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 57f1e7a9..8161ebe6 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -93,23 +93,14 @@ public sealed class GameWindow : IDisposable new AcDream.App.Rendering.ConsoleRenderFrameDiagnosticLog(); private readonly AcDream.App.Rendering.DebugVmRenderFactsPublisher _debugVmRenderFacts = new(); - private AcDream.App.Rendering.WorldRenderDiagnostics? _worldRenderDiagnostics; private AcDream.App.Rendering.RenderFrameDiagnosticsController? _renderFrameDiagnostics; private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; - private AcDream.App.Rendering.RenderFrameResourceController? - _renderFrameResources; - private AcDream.App.Rendering.RuntimeRenderFrameLivePreparation? - _renderFrameLivePreparation; - private AcDream.App.Rendering.RenderWeatherFrameController? - _renderWeatherFrame; - private AcDream.App.Rendering.WorldRenderFrameBuilder? - _worldRenderFrameBuilder; + private AcDream.App.Rendering.RenderFrameOrchestrator? + _renderFrameOrchestrator; private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState; - private AcDream.App.Rendering.WorldSceneRenderer? _worldSceneRenderer; - private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame; private ResourceShutdownTransaction? _shutdown; private readonly DisplayFramePacingController _displayFramePacing; @@ -221,13 +212,8 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer; private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum; - // R1 (render redesign): the per-cell DrawInside flood and concrete pass - // executor are borrowed by WorldSceneRenderer for each normal-world frame. - private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; - private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor; - private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells; - private AcDream.App.Rendering.TerrainDrawDiagnosticsController? - _terrainDrawDiagnostics; + // R1 (render redesign): portal-view draw owners are retained transitively + // by the frame orchestrator rather than duplicated as GameWindow roots. private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel; @@ -746,7 +732,7 @@ public sealed class GameWindow : IDisposable _gl = GL.GetApi(_window!); _gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl); - _worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics( + var worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics( new AcDream.App.Rendering.SilkRenderGlStateReader(_gl), _renderDiagnosticLog); _input = _window!.CreateInput(); @@ -1682,7 +1668,6 @@ public sealed class GameWindow : IDisposable { _frameScreenshots = new AcDream.App.Diagnostics.FrameScreenshotController( _gl!, - () => (_window!.Size.X, _window.Size.Y), System.IO.Path.Combine(artifactDirectory, "screenshots"), UiProbeLog); _worldLifecycleAutomation = @@ -2285,8 +2270,6 @@ public sealed class GameWindow : IDisposable landblockRenderPublisher.PrepareAfterRenderPins); _clipFrame ??= ClipFrame.NoClip(); - _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(); - // T1: invisible portal depth writes (seal/punch) — retail // DrawPortalPolyInternal (Ghidra 0x0059bc90). _portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl); @@ -2782,7 +2765,7 @@ public sealed class GameWindow : IDisposable _localPlayerMode); var renderFrameGlState = new AcDream.App.Rendering.RenderFrameGlStateController( new AcDream.App.Rendering.SilkRenderFrameGlStateApi(_gl!)); - _renderFrameLivePreparation = + var renderFrameLivePreparation = new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation( _textureCache, _wbMeshAdapter, @@ -2795,7 +2778,7 @@ public sealed class GameWindow : IDisposable _particleRenderer, _frameProfiler, _frameDiag); - _renderFrameResources = + var renderFrameResources = new AcDream.App.Rendering.RenderFrameResourceController( _gpuFrameFlights!, new AcDream.App.Rendering.RuntimeRenderFrameBeginResources( @@ -2816,14 +2799,14 @@ public sealed class GameWindow : IDisposable Weather, teleportRenderState, _particleVisibility, - _worldRenderDiagnostics!, + worldRenderDiagnostics, renderFrameGlState), - _renderFrameLivePreparation); - _renderWeatherFrame = + renderFrameLivePreparation); + var renderWeatherFrame = new AcDream.App.Rendering.RenderWeatherFrameController( WorldTime, Weather); - _skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController( + var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController( _scriptRunner!, _particleSink!, _effectPoses, @@ -2837,8 +2820,8 @@ public sealed class GameWindow : IDisposable _envCellRenderer, _sceneLightingUbo, _renderRange, - _skyPesFrame); - _worldRenderFrameBuilder = + skyPesFrame); + var worldRenderFrameBuilder = new AcDream.App.Rendering.WorldRenderFrameBuilder( new AcDream.App.Rendering.RuntimeWorldFrameCameraSource( _cameraController!, @@ -2869,22 +2852,22 @@ public sealed class GameWindow : IDisposable new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource( _landblockPresentationPipeline!, _cellVisibility)); - _terrainDrawDiagnostics = + var terrainDrawDiagnostics = new AcDream.App.Rendering.TerrainDrawDiagnosticsController( _frameDiag, - _worldRenderDiagnostics!, + worldRenderDiagnostics, new AcDream.App.Rendering.RuntimeFramePipelineDiagnosticFactsSource( _terrain, _landblockPresentationPipeline, - _renderFrameLivePreparation, + renderFrameLivePreparation, _wbDrawDispatcher, _streamingController, _liveEntities!, _worldState), _renderDiagnosticLog); - _retailPViewCells = new AcDream.App.Rendering.RetailPViewCellSource( + var retailPViewCells = new AcDream.App.Rendering.RetailPViewCellSource( _cellVisibility); - _retailPViewPassExecutor = + var retailPViewPassExecutor = new AcDream.App.Rendering.RetailPViewPassExecutor( _gl!, renderFrameGlState, @@ -2899,11 +2882,11 @@ public sealed class GameWindow : IDisposable _particleRenderer, _portalDepthMask, _retailAlphaQueue, - _worldRenderDiagnostics!, - _terrainDrawDiagnostics); + worldRenderDiagnostics, + terrainDrawDiagnostics); var worldSceneDiagnostics = new AcDream.App.Rendering.WorldSceneDiagnosticsController( - _worldRenderDiagnostics!, + worldRenderDiagnostics, new AcDream.App.Rendering.RuntimeWorldScenePViewDiagnosticSource( _playerControllerSlot, _physicsEngine, @@ -2922,27 +2905,63 @@ public sealed class GameWindow : IDisposable _wbDrawDispatcher!, _envCellRenderer!, _terrain, - _terrainDrawDiagnostics, + terrainDrawDiagnostics, _skyRenderer, _particleSystem, _particleRenderer); - _worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer( - _renderFrameResources, + var worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer( + renderFrameResources, renderLoginState, _worldSceneSkyState!, - _worldRenderFrameBuilder, + worldRenderFrameBuilder, new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState), _retailSelectionScene, _retailAlphaQueue, _particleVisibility, new AcDream.App.Rendering.WorldScenePViewRenderer( - _retailPViewRenderer!, - _retailPViewPassExecutor, - _retailPViewPassExecutor), - _retailPViewCells, + new AcDream.App.Rendering.RetailPViewRenderer(), + retailPViewPassExecutor, + retailPViewPassExecutor), + retailPViewCells, worldScenePasses, _renderRange, worldSceneDiagnostics); + AcDream.App.Rendering.IRetainedGameplayUiFrame? retainedGameplayUi = + _options.RetailUi && _retailUiRuntime is not null + ? new AcDream.App.Rendering.RetainedGameplayUiFrame( + _retailUiRuntime, + _input) + : null; + AcDream.App.Rendering.IPrivateFrameScreenshot? privateScreenshot = + _frameScreenshots is not null + ? new AcDream.App.Rendering.PrivateFrameScreenshot( + _frameScreenshots) + : null; + var privatePresentation = + new AcDream.App.Rendering.PrivatePresentationRenderer( + new AcDream.App.Rendering.LocalPlayerPortalViewport( + _localPlayerTeleport!, + _cameraController!), + renderFrameResources, + _paperdollFramePresenter, + retainedGameplayUi, + _devToolsFramePresenter, + privateScreenshot); + var framePreparation = + new AcDream.App.Rendering.RenderFramePreparationController( + renderFrameResources, + _devToolsFramePresenter, + renderWeatherFrame); + _renderFrameOrchestrator = + new AcDream.App.Rendering.RenderFrameOrchestrator( + _gpuFrameFlights!, + framePreparation, + worldSceneRenderer, + privatePresentation, + _renderFrameDiagnostics!, + (AcDream.App.Rendering.IRenderFrameFailureRecovery?) + _devToolsFramePresenter + ?? AcDream.App.Rendering.NullRenderFrameFailureRecovery.Instance); var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( liveObjectFrame, _worldState, @@ -3415,126 +3434,12 @@ public sealed class GameWindow : IDisposable // window title so there's zero rendering cost (no font/overlay needed). private void OnRender(double deltaSeconds) { - _gpuFrameFlights!.BeginFrame(); - Exception? renderFailure = null; - try - { - var frameInput = new AcDream.App.Rendering.RenderFrameInput( + Vector2D size = _window!.Size; + _renderFrameOrchestrator!.Render( + new AcDream.App.Rendering.RenderFrameInput( deltaSeconds, - _window!.Size.X, - _window.Size.Y); - _renderFrameResources!.Prepare(frameInput); - AcDream.App.Rendering.RenderFrameFoundation foundation = - _renderFrameResources.Foundation; - - // gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the - // normal SmartBox viewport and UIElement_Viewport's portal - // CreatureMode; it does not redraw the world behind portal space. - bool portalViewportVisible = foundation.PortalViewportVisible; - - // Phase D.2a — begin ImGui frame. Paired with the Render() call - // after the scene draws (below). ImGuiController.Update() - // consumes buffered Silk.NET input events and calls ImGui.NewFrame. - _devToolsFramePresenter?.BeginFrame((float)deltaSeconds); - - // Phase G.1: weather state machine — deterministic per-day roll - // + transitions + lightning flash. - _renderWeatherFrame!.Tick(deltaSeconds); - - // (Pre-Bug-A code spawned camera-attached rain/snow particle - // emitters here as a workaround for missing weather-mesh - // rendering. Deleted 2026-04-26 once the retail-faithful world- - // space mesh path landed in SkyRenderer.RenderWeather. Retail - // rain is GfxObj 0x01004C42/0x01004C44 — a hollow octagonal - // cylinder anchored at player_pos + (0, 0, -120m) per - // GameSky::UpdatePosition at 0x00506dd0 — drawn after the - // landblock pass per LScape::draw at 0x00506330. There is no - // server-driven weather event and no camera-attached emitter - // in retail. Snow renders identically when a Snowy DayGroup is - // active in some other Region; the partition by Properties&0x04 - // and the SkyRenderer.RenderWeather pass both pick up snow - // weather meshes for free.) - - AcDream.App.Rendering.WorldRenderFrameOutcome worldOutcome = - _worldSceneRenderer!.Render(frameInput); - int visibleLandblocks = worldOutcome.VisibleLandblocks; - int totalLandblocks = worldOutcome.TotalLandblocks; - bool normalWorldDrawn = worldOutcome.NormalWorldDrawn; - - // Retail gmSmartBoxUI swaps the world SmartBox viewport for a - // CreatureMode portal-space viewport. The world block above is skipped - // while this replacement scene is visible. This scene and - // its projection transition draws BEFORE retained UI, so chat, windows, - // cursor, and toolbar remain visible and interactive in transit. - _localPlayerTeleport?.DrawPortalViewport( - _window!.Size.X, - _window.Size.Y, - _cameraController!.Active.Projection); - - // Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the - // viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window - // is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle). - // The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state. - _paperdollFramePresenter?.Render(); - - // Phase D.2b — retail-look UI tree (render-only; input integration deferred). - // Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own - // blend/depth state and restores. Drawn before ImGui so the devtools - // overlay composites on top during development. - if (_options.RetailUi && _retailUiRuntime is not null) - { - _retailUiRuntime.Tick(deltaSeconds); - if (_input is not null) - _retailUiRuntime.UpdateCursor(_input.Mice); - _retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); - } - - // Devtools remain above retained gameplay UI. The focused presenter - // owns menu policy, panels, and ImGui draw-data upload. - _devToolsFramePresenter?.Render( - deltaSeconds, - _window!.Size.X, - _window.Size.Y); - - // Explicit diagnostic captures are requested by the retained-UI - // automation tick above and executed only after every presentation - // layer has drawn into the back buffer on this render thread. - bool screenshotCaptured = _frameScreenshots?.CapturePending() == true; - - _renderFrameDiagnostics?.Publish( - new AcDream.App.Rendering.RenderFrameInput( - deltaSeconds, - _window!.Size.X, - _window.Size.Y), - new AcDream.App.Rendering.RenderFrameOutcome( - new AcDream.App.Rendering.WorldRenderFrameOutcome( - visibleLandblocks, - totalLandblocks, - normalWorldDrawn), - new AcDream.App.Rendering.PrivatePresentationFrameOutcome( - portalViewportVisible, - screenshotCaptured))); - - } - catch (Exception ex) - { - renderFailure = ex; - throw; - } - finally - { - try - { - _gpuFrameFlights.EndFrame(); - } - catch (Exception closeFailure) when (renderFailure is not null) - { - throw new AggregateException( - "Rendering failed and the in-flight GPU frame could not be closed.", - renderFailure, - closeFailure); - } - } + size.X, + size.Y)); } private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot @@ -4563,14 +4468,8 @@ public sealed class GameWindow : IDisposable [ new("world frame composition", () => { - _worldSceneRenderer = null; - _worldRenderFrameBuilder = null; + _renderFrameOrchestrator = null; _worldSceneSkyState = null; - _skyPesFrame = null; - _retailPViewPassExecutor = null; - _retailPViewCells = null; - _terrainDrawDiagnostics = null; - _retailPViewRenderer = null; }), ]), new ResourceShutdownStage("session dependents", @@ -4643,11 +4542,13 @@ public sealed class GameWindow : IDisposable ]), new ResourceShutdownStage("render frontends", [ - new("frame composition", () => + new("developer tools", () => { - _renderFrameResources = null; - _renderFrameLivePreparation = null; - _renderWeatherFrame = null; + _devToolsFramePresenter = null; + _devToolsCameraMenu = null; + _devToolsCommandBus = null; + _devToolsBackend?.Dispose(); + _devToolsBackend = null; }), new("portal tunnel", () => { diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs index 1f8dc509..a3149e6f 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -48,7 +48,7 @@ internal interface IPaperdollPoseApplicator /// The GL renderer remains a borrowed resource disposed by the existing window /// shutdown transaction. /// -internal sealed class PaperdollFramePresenter +internal sealed class PaperdollFramePresenter : IPrivatePaperdollFrame { private readonly IPaperdollDollRenderer _renderer; private readonly IPaperdollFrameView _view; diff --git a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs new file mode 100644 index 00000000..b82aa5c3 --- /dev/null +++ b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs @@ -0,0 +1,148 @@ +using System.Numerics; +using AcDream.App.Diagnostics; +using AcDream.App.Streaming; +using AcDream.App.UI; +using Silk.NET.Input; + +namespace AcDream.App.Rendering; + +internal interface IPrivatePortalViewport +{ + void Draw(int width, int height); +} + +internal interface IPrivatePaperdollFrame +{ + void Render(); +} + +internal interface IRetainedGameplayUiFrame +{ + void Render(double deltaSeconds, int width, int height); +} + +internal interface IPrivateFrameScreenshot +{ + bool CapturePending(int width, int height); +} + +/// +/// Draws the private and 2-D presentation layers after the world alpha scope +/// has closed: portal CreatureMode, paperdoll FBO, retained gameplay UI, +/// developer UI, then the complete-frame screenshot. +/// +internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePhase +{ + private readonly IPrivatePortalViewport _portal; + private readonly IRenderFrameFoundationSource _foundation; + private readonly IPrivatePaperdollFrame? _paperdoll; + private readonly IRetainedGameplayUiFrame? _gameplayUi; + private readonly IDevToolsFrameLifecycle? _devTools; + private readonly IPrivateFrameScreenshot? _screenshots; + + public PrivatePresentationRenderer( + IPrivatePortalViewport portal, + IRenderFrameFoundationSource foundation, + IPrivatePaperdollFrame? paperdoll, + IRetainedGameplayUiFrame? gameplayUi, + IDevToolsFrameLifecycle? devTools, + IPrivateFrameScreenshot? screenshots) + { + _portal = portal ?? throw new ArgumentNullException(nameof(portal)); + _foundation = foundation + ?? throw new ArgumentNullException(nameof(foundation)); + _paperdoll = paperdoll; + _gameplayUi = gameplayUi; + _devTools = devTools; + _screenshots = screenshots; + } + + public PrivatePresentationFrameOutcome Render( + RenderFrameInput input, + WorldRenderFrameOutcome world) + { + _ = world; + // Foundation is the immutable result of this frame's clear phase. + // Snapshot it before drawing so a portal transition raised by a + // private layer cannot relabel the viewport that was just presented. + bool portalViewportVisible = + _foundation.Foundation.PortalViewportVisible; + _portal.Draw(input.ViewportWidth, input.ViewportHeight); + _paperdoll?.Render(); + _gameplayUi?.Render( + input.DeltaSeconds, + input.ViewportWidth, + input.ViewportHeight); + _devTools?.Render( + input.DeltaSeconds, + input.ViewportWidth, + input.ViewportHeight); + bool screenshotCaptured = _screenshots?.CapturePending( + input.ViewportWidth, + input.ViewportHeight) == true; + return new PrivatePresentationFrameOutcome( + portalViewportVisible, + screenshotCaptured); + } +} + +/// Typed portal viewport adapter over the canonical teleport owner. +internal sealed class LocalPlayerPortalViewport : IPrivatePortalViewport +{ + private readonly LocalPlayerTeleportController _teleport; + private readonly CameraController _camera; + + public LocalPlayerPortalViewport( + LocalPlayerTeleportController teleport, + CameraController camera) + { + _teleport = teleport ?? throw new ArgumentNullException(nameof(teleport)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + } + + public void Draw(int width, int height) => + _teleport.DrawPortalViewport(width, height, _camera.Active.Projection); +} + +/// +/// One retained-gameplay-UI tick/cursor/draw transaction. This is the explicit +/// boundary where the render graph reaches the retained UI composition owner; +/// intentionally retains its documented state and +/// command bindings. The distinction between direct frame ownership and +/// recursive shared-owner reachability is recorded in +/// docs/architecture/code-structure.md. +/// +internal sealed class RetainedGameplayUiFrame : IRetainedGameplayUiFrame +{ + private readonly RetailUiRuntime _runtime; + private readonly IInputContext? _input; + + public RetainedGameplayUiFrame( + RetailUiRuntime runtime, + IInputContext? input) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _input = input; + } + + public void Render(double deltaSeconds, int width, int height) + { + _runtime.Tick(deltaSeconds); + if (_input is not null) + _runtime.UpdateCursor(_input.Mice); + _runtime.Draw(new Vector2(width, height)); + } +} + +/// Optional complete-frame capture adapter. +internal sealed class PrivateFrameScreenshot : IPrivateFrameScreenshot +{ + private readonly FrameScreenshotController _screenshots; + + public PrivateFrameScreenshot(FrameScreenshotController screenshots) => + _screenshots = screenshots + ?? throw new ArgumentNullException(nameof(screenshots)); + + public bool CapturePending(int width, int height) => + _screenshots.CapturePending(width, height); +} diff --git a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs index 6aa79fb9..9f688d99 100644 --- a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs +++ b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs @@ -60,6 +60,29 @@ internal interface IRenderFrameDiagnosticsPhase void Publish(RenderFrameInput input, RenderFrameOutcome outcome); } +/// +/// Closes presentation transactions which may have opened before a later +/// render phase failed. Recovery is idempotent so failures before the +/// transaction opens and failures after it closes are both safe. +/// +internal interface IRenderFrameFailureRecovery +{ + void AbortFrame(); +} + +internal sealed class NullRenderFrameFailureRecovery : IRenderFrameFailureRecovery +{ + public static NullRenderFrameFailureRecovery Instance { get; } = new(); + + private NullRenderFrameFailureRecovery() + { + } + + public void AbortFrame() + { + } +} + /// /// Owns the accepted outer render transaction. Focused phase owners retain the /// detailed PView, private-viewport, UI, and diagnostic order inside their @@ -79,19 +102,22 @@ internal sealed class RenderFrameOrchestrator private readonly IWorldSceneFramePhase _world; private readonly IPrivatePresentationFramePhase _presentation; private readonly IRenderFrameDiagnosticsPhase _diagnostics; + private readonly IRenderFrameFailureRecovery _recovery; public RenderFrameOrchestrator( IRenderFrameLifetime lifetime, IRenderFrameResourcePhase resources, IWorldSceneFramePhase world, IPrivatePresentationFramePhase presentation, - IRenderFrameDiagnosticsPhase diagnostics) + IRenderFrameDiagnosticsPhase diagnostics, + IRenderFrameFailureRecovery recovery) { _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); _resources = resources ?? throw new ArgumentNullException(nameof(resources)); _world = world ?? throw new ArgumentNullException(nameof(world)); _presentation = presentation ?? throw new ArgumentNullException(nameof(presentation)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _recovery = recovery ?? throw new ArgumentNullException(nameof(recovery)); } public RenderFrameOutcome Render(RenderFrameInput input) @@ -115,17 +141,50 @@ internal sealed class RenderFrameOrchestrator } finally { + Exception? recoveryFailure = null; + if (renderFailure is not null) + { + try + { + // ImGui NewFrame is opened during preparation but normally + // closed during private presentation. Abort it before the + // GPU flight closes when any intervening phase fails. + _recovery.AbortFrame(); + } + catch (Exception error) + { + recoveryFailure = error; + } + } + try { _lifetime.EndFrame(); } catch (Exception closeFailure) when (renderFailure is not null) { + if (recoveryFailure is not null) + { + throw new AggregateException( + "Rendering failed and neither presentation recovery nor the in-flight GPU frame could be closed.", + renderFailure, + recoveryFailure, + closeFailure); + } + throw new AggregateException( "Rendering failed and the in-flight GPU frame could not be closed.", renderFailure, closeFailure); } + + if (renderFailure is not null && recoveryFailure is not null) + { + throw new AggregateException( + "Rendering failed and the presentation frame could not be aborted.", + renderFailure, + recoveryFailure); + } } } } diff --git a/src/AcDream.App/Rendering/RenderFramePreparationController.cs b/src/AcDream.App/Rendering/RenderFramePreparationController.cs new file mode 100644 index 00000000..bb49b62f --- /dev/null +++ b/src/AcDream.App/Rendering/RenderFramePreparationController.cs @@ -0,0 +1,35 @@ +namespace AcDream.App.Rendering; + +internal interface IRenderWeatherFramePhase +{ + void Tick(double deltaSeconds); +} + +/// +/// Preserves the accepted pre-world order after the GPU/resource transaction: +/// begin optional developer UI, advance render-time weather, then hand the +/// completed preparation to the world phase. +/// +internal sealed class RenderFramePreparationController : IRenderFrameResourcePhase +{ + private readonly IRenderFrameResourcePhase _resources; + private readonly IDevToolsFrameLifecycle? _devTools; + private readonly IRenderWeatherFramePhase _weather; + + public RenderFramePreparationController( + IRenderFrameResourcePhase resources, + IDevToolsFrameLifecycle? devTools, + IRenderWeatherFramePhase weather) + { + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + _devTools = devTools; + _weather = weather ?? throw new ArgumentNullException(nameof(weather)); + } + + public void Prepare(RenderFrameInput input) + { + _resources.Prepare(input); + _devTools?.BeginFrame((float)input.DeltaSeconds); + _weather.Tick(input.DeltaSeconds); + } +} diff --git a/src/AcDream.App/Rendering/RenderFrameResourceController.cs b/src/AcDream.App/Rendering/RenderFrameResourceController.cs index 49ed2bf6..def8f00d 100644 --- a/src/AcDream.App/Rendering/RenderFrameResourceController.cs +++ b/src/AcDream.App/Rendering/RenderFrameResourceController.cs @@ -350,7 +350,7 @@ internal sealed class RuntimeRenderFrameLivePreparation } /// Owns the render-time weather clock and deterministic day index. -internal sealed class RenderWeatherFrameController +internal sealed class RenderWeatherFrameController : IRenderWeatherFramePhase { private readonly WorldTimeService _worldTime; private readonly WeatherSystem _weather; diff --git a/src/AcDream.App/Rendering/TextRenderGlStateScope.cs b/src/AcDream.App/Rendering/TextRenderGlStateScope.cs new file mode 100644 index 00000000..b65123b7 --- /dev/null +++ b/src/AcDream.App/Rendering/TextRenderGlStateScope.cs @@ -0,0 +1,156 @@ +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +internal interface ITextRenderGlStateApi +{ + bool IsEnabled(EnableCap capability); + + int GetInteger(GetPName parameter); + + bool GetBoolean(GetPName parameter); + + void SetCapability(EnableCap capability, bool enabled); + + void DepthMask(bool enabled); + + void BlendFuncSeparate( + BlendingFactor sourceRgb, + BlendingFactor destinationRgb, + BlendingFactor sourceAlpha, + BlendingFactor destinationAlpha); + + void UseProgram(uint program); + + void BindVertexArray(uint vertexArray); + + void BindBuffer(BufferTargetARB target, uint buffer); + + void ActiveTexture(TextureUnit unit); + + void BindTexture(TextureTarget target, uint texture); +} + +internal sealed class SilkTextRenderGlStateApi : ITextRenderGlStateApi +{ + private readonly GL _gl; + + public SilkTextRenderGlStateApi(GL gl) => + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + + public bool IsEnabled(EnableCap capability) => _gl.IsEnabled(capability); + + public int GetInteger(GetPName parameter) => _gl.GetInteger(parameter); + + public bool GetBoolean(GetPName parameter) => _gl.GetBoolean(parameter); + + public void SetCapability(EnableCap capability, bool enabled) + { + if (enabled) + _gl.Enable(capability); + else + _gl.Disable(capability); + } + + public void DepthMask(bool enabled) => _gl.DepthMask(enabled); + + public void BlendFuncSeparate( + BlendingFactor sourceRgb, + BlendingFactor destinationRgb, + BlendingFactor sourceAlpha, + BlendingFactor destinationAlpha) => + _gl.BlendFuncSeparate( + sourceRgb, + destinationRgb, + sourceAlpha, + destinationAlpha); + + public void UseProgram(uint program) => _gl.UseProgram(program); + + public void BindVertexArray(uint vertexArray) => _gl.BindVertexArray(vertexArray); + + public void BindBuffer(BufferTargetARB target, uint buffer) => + _gl.BindBuffer(target, buffer); + + public void ActiveTexture(TextureUnit unit) => _gl.ActiveTexture(unit); + + public void BindTexture(TextureTarget target, uint texture) => + _gl.BindTexture(target, texture); +} + +/// +/// Exact, focused state transaction for . It +/// captures every GL value that Flush or DrawLayer mutates, while avoiding the +/// dozens of unrelated synchronous reads made by the broad diagnostic scope. +/// +internal readonly struct TextRenderGlStateScope : IDisposable +{ + private readonly ITextRenderGlStateApi _gl; + private readonly bool _depthTest; + private readonly bool _blend; + private readonly bool _cullFace; + private readonly bool _alphaToCoverage; + private readonly bool _multisample; + private readonly bool _depthWrite; + private readonly int _blendSourceRgb; + private readonly int _blendDestinationRgb; + private readonly int _blendSourceAlpha; + private readonly int _blendDestinationAlpha; + private readonly int _program; + private readonly int _vertexArray; + private readonly int _arrayBuffer; + private readonly int _activeTexture; + private readonly int _texture0Binding2D; + + public TextRenderGlStateScope(ITextRenderGlStateApi gl) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _depthTest = gl.IsEnabled(EnableCap.DepthTest); + _blend = gl.IsEnabled(EnableCap.Blend); + _cullFace = gl.IsEnabled(EnableCap.CullFace); + _alphaToCoverage = gl.IsEnabled(EnableCap.SampleAlphaToCoverage); + _multisample = gl.IsEnabled(EnableCap.Multisample); + _depthWrite = gl.GetBoolean(GetPName.DepthWritemask); + _blendSourceRgb = gl.GetInteger(GetPName.BlendSrcRgb); + _blendDestinationRgb = gl.GetInteger(GetPName.BlendDstRgb); + _blendSourceAlpha = gl.GetInteger(GetPName.BlendSrcAlpha); + _blendDestinationAlpha = gl.GetInteger(GetPName.BlendDstAlpha); + _program = gl.GetInteger(GetPName.CurrentProgram); + _vertexArray = gl.GetInteger(GetPName.VertexArrayBinding); + _arrayBuffer = gl.GetInteger(GetPName.ArrayBufferBinding); + _activeTexture = gl.GetInteger(GetPName.ActiveTexture); + + try + { + gl.ActiveTexture(TextureUnit.Texture0); + _texture0Binding2D = gl.GetInteger(GetPName.TextureBinding2D); + } + finally + { + gl.ActiveTexture((TextureUnit)_activeTexture); + } + } + + public void Dispose() + { + _gl.UseProgram((uint)_program); + _gl.BindVertexArray((uint)_vertexArray); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, (uint)_arrayBuffer); + + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2D, (uint)_texture0Binding2D); + _gl.ActiveTexture((TextureUnit)_activeTexture); + + _gl.DepthMask(_depthWrite); + _gl.BlendFuncSeparate( + (BlendingFactor)_blendSourceRgb, + (BlendingFactor)_blendDestinationRgb, + (BlendingFactor)_blendSourceAlpha, + (BlendingFactor)_blendDestinationAlpha); + _gl.SetCapability(EnableCap.DepthTest, _depthTest); + _gl.SetCapability(EnableCap.Blend, _blend); + _gl.SetCapability(EnableCap.CullFace, _cullFace); + _gl.SetCapability(EnableCap.SampleAlphaToCoverage, _alphaToCoverage); + _gl.SetCapability(EnableCap.Multisample, _multisample); + } +} diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 88e1f69d..096dd929 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -24,6 +24,7 @@ public sealed unsafe class TextRenderer : IDisposable private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4) private readonly GL _gl; + private readonly ITextRenderGlStateApi _glState; private readonly Shader _shader; private uint _vao; private uint _vbo; @@ -53,6 +54,7 @@ public sealed unsafe class TextRenderer : IDisposable // at its FIRST-insertion point, so later bar sprites covered glyphs emitted // earlier via the shared dat-font atlas — the stamina/mana numbers vanished.) private sealed class SpriteSeg { public uint Texture; public readonly List Verts = new(256); } + private readonly List _spriteSegs = new(); private int _segUsed; private int _textVerts; @@ -77,6 +79,7 @@ public sealed unsafe class TextRenderer : IDisposable public TextRenderer(GL gl, string shaderDir) { _gl = gl; + _glState = new SilkTextRenderGlStateApi(gl); _shader = new Shader(gl, Path.Combine(shaderDir, "ui_text.vert"), Path.Combine(shaderDir, "ui_text.frag")); @@ -350,23 +353,24 @@ public sealed unsafe class TextRenderer : IDisposable bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0; if (!anyNormal && !anyOverlay) return; + // Retained UI is a private render pass: an upload or draw failure must + // not leak its depth/cull/blend/MSAA state into a later recoverable + // frame. The focused scope restores from Flush's generated finally, + // including when either DrawLayer call throws. + using var stateScope = new TextRenderGlStateScope(_glState); + _shader.Use(); _shader.SetVec2("uScreenSize", _screenSize); _gl.BindVertexArray(_vao); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - // Save GL state. - bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); - bool wasBlend = _gl.IsEnabled(EnableCap.Blend); - bool wasCull = _gl.IsEnabled(EnableCap.CullFace); + // Establish the self-contained UI pass state. // The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher, // QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha // EDGE is converted to dithered MSAA coverage instead of a clean alpha blend — // the "text not sharp / fuzzy" artifact. The UI composites with straight alpha // blending and must own this state (feedback_render_self_contained_gl_state). - bool wasA2C = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage); - bool wasMsaa = _gl.IsEnabled(EnableCap.Multisample); _gl.Disable(EnableCap.SampleAlphaToCoverage); _gl.Disable(EnableCap.Multisample); _gl.Disable(EnableCap.DepthTest); @@ -386,15 +390,6 @@ public sealed unsafe class TextRenderer : IDisposable DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font); DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font); - // Restore GL state. - _gl.DepthMask(true); - if (!wasBlend) _gl.Disable(EnableCap.Blend); - if (wasCull) _gl.Enable(EnableCap.CullFace); - if (wasDepth) _gl.Enable(EnableCap.DepthTest); - if (wasA2C) _gl.Enable(EnableCap.SampleAlphaToCoverage); - if (wasMsaa) _gl.Enable(EnableCap.Multisample); - - _gl.BindVertexArray(0); } /// Draw one compositing layer: sprites (submission order, one call per diff --git a/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs index bbcc3a81..04daaab8 100644 --- a/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs +++ b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs @@ -60,5 +60,15 @@ public sealed class ImGuiBootstrapper : IDisposable /// public void Render() => _controller.Render(); + /// + /// Close an unfinished frame through the owning Silk controller. Silk's + /// controller clears its private frame-begun flag before uploading draw + /// data, so this is both the correct abort path for pre-render failures and + /// an idempotent no-op when a render upload failed after logical close. + /// Calling raw ImGui.EndFrame here would leave the controller's state + /// open and poison its next call. + /// + public void AbortFrame() => _controller.Render(); + public void Dispose() => _controller.Dispose(); } diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index 09f7b590..28000eda 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -19,7 +19,6 @@ public sealed class WorldLifecycleAutomationControllerTests ]; var logs = new List(); var controller = new FrameScreenshotController( - () => (2, 2), (_, _) => rgba, directory, logs.Add); @@ -29,7 +28,7 @@ public sealed class WorldLifecycleAutomationControllerTests Assert.True(controller.TryRequest("login_stable", out string error), error); Assert.False(controller.IsComplete("login_stable")); - Assert.True(controller.CapturePending()); + Assert.True(controller.CapturePending(2, 2)); Assert.True(controller.IsComplete("login_stable")); string path = Path.Combine(directory, "login_stable.png"); @@ -53,15 +52,14 @@ public sealed class WorldLifecycleAutomationControllerTests { string directory = NewDirectory(); var controller = new FrameScreenshotController( - () => (0, 0), (_, _) => [], directory); try { - Assert.False(controller.CapturePending()); + Assert.False(controller.CapturePending(0, 0)); Assert.True(controller.TryRequest("bad_frame", out string error), error); - Assert.False(controller.CapturePending()); + Assert.False(controller.CapturePending(0, 0)); Assert.False(controller.IsComplete("bad_frame")); } finally @@ -109,7 +107,6 @@ public sealed class WorldLifecycleAutomationControllerTests TrackedGpuBytes = 1234, }; var screenshots = new FrameScreenshotController( - () => (1, 1), (_, _) => [0, 0, 0, 255], Path.Combine(directory, "screenshots")); var controller = new WorldLifecycleAutomationController( diff --git a/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs index b28279a3..3b0c6139 100644 --- a/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/DevToolsFramePresenterTests.cs @@ -42,8 +42,10 @@ public sealed class DevToolsFramePresenterTests var commands = new RecordingCommandSource { Current = first }; var presenter = Create(backend, commands: commands); + presenter.BeginFrame(0.1f); presenter.Render(0.1, 800, 600); commands.Current = second; + presenter.BeginFrame(0.2f); presenter.Render(0.2, 800, 600); Assert.Same(first, backend.Contexts[0].Commands); @@ -60,6 +62,7 @@ public sealed class DevToolsFramePresenterTests var panels = new RecordingPanels(); var presenter = Create(backend, panels: panels); + presenter.BeginFrame(0.1f); presenter.Render(0.1, 1920, 1080); Assert.Equal( @@ -93,6 +96,7 @@ public sealed class DevToolsFramePresenterTests var camera = new RecordingCamera { IsFlyMode = true, CollideCamera = true }; var presenter = Create(backend, camera: camera); + presenter.BeginFrame(0.1f); presenter.Render(0.1, 1280, 720); Assert.Equal(1, camera.ToggleCount); @@ -113,6 +117,7 @@ public sealed class DevToolsFramePresenterTests presenter.ToggleSettingsPanel(); presenter.ResetLayout(1280, 720, DevToolsPanelLayoutCondition.FirstUseEver); + presenter.BeginFrame(0.1f); presenter.Render(0.1, 1280, 720); Assert.DoesNotContain(DevToolsPanelKind.Settings, panels.Toggles); @@ -156,6 +161,68 @@ public sealed class DevToolsFramePresenterTests call.WithoutCondition())); } + [Fact] + public void AbortFrame_ClosesAnOpenBackendFrameAndAllowsTheNextFrame() + { + var calls = new List(); + var backend = new RecordingBackend(calls); + var presenter = Create(backend); + + presenter.BeginFrame(0.1f); + presenter.AbortFrame(); + presenter.AbortFrame(); + presenter.BeginFrame(0.2f); + presenter.Render(0.2, 800, 600); + + Assert.Equal(1, calls.Count(call => call == "abort")); + Assert.Equal(2, calls.Count(call => call.StartsWith("begin:"))); + Assert.Equal(1, calls.Count(call => call == "draw-data")); + } + + [Fact] + public void RenderFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame() + { + var calls = new List(); + var backend = new RecordingBackend(calls) + { + DrawFailure = new InvalidOperationException("draw"), + }; + var presenter = Create(backend); + + presenter.BeginFrame(0.1f); + Assert.Throws(() => + presenter.Render(0.1, 800, 600)); + presenter.AbortFrame(); + + backend.DrawFailure = null; + presenter.BeginFrame(0.2f); + presenter.Render(0.2, 800, 600); + + Assert.Equal(0, calls.Count(call => call == "abort")); + Assert.Equal(2, calls.Count(call => call == "draw-data")); + } + + [Fact] + public void BeginFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame() + { + var calls = new List(); + var backend = new RecordingBackend(calls) + { + BeginFailure = new InvalidOperationException("begin"), + }; + var presenter = Create(backend); + + Assert.Throws(() => presenter.BeginFrame(0.1f)); + presenter.AbortFrame(); + + backend.BeginFailure = null; + presenter.BeginFrame(0.2f); + presenter.Render(0.2, 800, 600); + + Assert.Equal(1, calls.Count(call => call == "abort")); + Assert.Equal(2, calls.Count(call => call.StartsWith("begin:"))); + } + [Fact] public void InputFacingActionsDelegateToTheTypedPanelOwner() { @@ -198,7 +265,7 @@ public sealed class DevToolsFramePresenterTests field => field.FieldType == typeof(Silk.NET.Windowing.IWindow)); } - Assert.False(typeof(IDisposable).IsAssignableFrom( + Assert.True(typeof(IDisposable).IsAssignableFrom( typeof(ImGuiDevToolsFrameBackend))); } @@ -221,8 +288,29 @@ public sealed class DevToolsFramePresenterTests public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = []; public List Contexts { get; } = []; public List Layouts { get; } = []; + public Exception? BeginFailure { get; set; } + public Exception? DrawFailure { get; set; } - public void BeginFrame(float deltaSeconds) => calls.Add($"begin:{deltaSeconds}"); + public bool ControllerFrameOpen { get; private set; } + + public void BeginFrame(float deltaSeconds) + { + calls.Add($"begin:{deltaSeconds}"); + ControllerFrameOpen = true; + if (BeginFailure is not null) + throw BeginFailure; + } + + public void AbortFrame() + { + // Silk ImGuiController.Render is idempotent. It closes an active + // controller frame, but is a no-op if Render already cleared its + // private _frameBegun flag before a GL upload failure. + if (!ControllerFrameOpen) + return; + ControllerFrameOpen = false; + calls.Add("abort"); + } public bool BeginMainMenuBar() { @@ -254,7 +342,14 @@ public sealed class DevToolsFramePresenterTests calls.Add($"panels:{context.DeltaSeconds}"); } - public void RenderDrawData() => calls.Add("draw-data"); + public void RenderDrawData() + { + Assert.True(ControllerFrameOpen); + ControllerFrameOpen = false; + calls.Add("draw-data"); + if (DrawFailure is not null) + throw DrawFailure; + } public void SetWindowLayout( string title, diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index f8068059..91f0533b 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -7,32 +7,33 @@ public sealed class GameWindowRenderLeafCompositionTests [Fact] public void ProductionRender_PreservesPrivatePresentationAndCaptureOrder() { - string source = GameWindowSource(); + string presentation = Source("PrivatePresentationRenderer.cs"); + string orchestrator = Source("RenderFrameOrchestrator.cs"); AssertAppearsInOrder( - source, - "_localPlayerTeleport?.DrawPortalViewport(", - "_paperdollFramePresenter?.Render();", - "_retailUiRuntime.Tick(deltaSeconds);", - "_devToolsFramePresenter?.Render(", - "_frameScreenshots?.CapturePending() == true;", - "_renderFrameDiagnostics?.Publish("); + presentation, + "_portal.Draw(", + "_paperdoll?.Render();", + "_gameplayUi?.Render(", + "_devTools?.Render(", + "_screenshots?.CapturePending("); + AssertAppearsInOrder( + orchestrator, + "_world.Render(input);", + "_presentation.Render(input, world);", + "_diagnostics.Publish(input, outcome);"); } [Fact] public void ProductionRender_Prepares_resources_then_devtools_weather_and_world() { - string source = GameWindowSource(); + string source = Source("RenderFramePreparationController.cs"); AssertAppearsInOrder( source, - "_renderFrameResources!.Prepare(frameInput);", - "_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);", - "_renderWeatherFrame!.Tick(deltaSeconds);", - "_worldSceneRenderer!.Render(frameInput);"); - Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source); - Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source); - Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source); + "_resources.Prepare(input);", + "_devTools?.BeginFrame((float)input.DeltaSeconds);", + "_weather.Tick(input.DeltaSeconds);"); } [Fact] @@ -46,7 +47,9 @@ public sealed class GameWindowRenderLeafCompositionTests "_portalTunnel = null;", "_localPlayerTeleportSink.Bind(_localPlayerTeleport);", "new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(", - "new AcDream.App.Rendering.RenderFrameResourceController("); + "new AcDream.App.Rendering.RenderFrameResourceController(", + "new AcDream.App.Rendering.PrivatePresentationRenderer(", + "new AcDream.App.Rendering.RenderFrameOrchestrator("); } [Fact] @@ -78,6 +81,13 @@ public sealed class GameWindowRenderLeafCompositionTests "ApplyFramePacingPreference", "RefreshActiveMonitorFramePacing", "private void OnFrameRendered(", + "private AcDream.App.Rendering.WorldRenderDiagnostics? _worldRenderDiagnostics", + "private AcDream.App.Rendering.WorldRenderFrameBuilder?", + "private AcDream.App.Rendering.SkyPesFrameController?", + "private AcDream.App.Rendering.RetailPViewRenderer?", + "private AcDream.App.Rendering.RetailPViewPassExecutor?", + "private AcDream.App.Rendering.RetailPViewCellSource?", + "private AcDream.App.Rendering.TerrainDrawDiagnosticsController?", ]; foreach (string identifier in removed) Assert.DoesNotContain(identifier, source, StringComparison.Ordinal); @@ -86,6 +96,8 @@ public sealed class GameWindowRenderLeafCompositionTests Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", source); Assert.Contains("new AcDream.App.Rendering.RenderFrameResourceController(", source); Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", source); + Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source); + Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source); Assert.Contains("new DisplayFramePacingController(", source); Assert.Contains("_inputCapture.WantCaptureMouse", source); } @@ -99,40 +111,70 @@ public sealed class GameWindowRenderLeafCompositionTests source, "new ResourceShutdownStage(\"submitted GPU work\"", "new ResourceShutdownStage(\"render frontends\"", - "new(\"frame composition\"", + "new(\"developer tools\"", + "_devToolsBackend?.Dispose()", "new(\"portal tunnel\"", "new(\"paperdoll viewport\"", "new ResourceShutdownStage(\"OpenGL context\""); - Assert.Contains("_renderFrameResources = null;", source); - Assert.Contains("_renderFrameLivePreparation = null;", source); - Assert.Contains("_renderWeatherFrame = null;", source); AssertAppearsInOrder( source, "new ResourceShutdownStage(\"frame borrowers\"", - "_retailPViewPassExecutor = null;", - "_retailPViewCells = null;", - "_terrainDrawDiagnostics = null;", - "_retailPViewRenderer = null;", + "_renderFrameOrchestrator = null;", "new ResourceShutdownStage(\"session dependents\""); AssertAppearsInOrder( source, "new(\"frame pacing\", _displayFramePacing.Dispose)", "new(\"frame profiler\", _frameProfiler.Dispose)"); - Assert.DoesNotContain("_devToolsBackend?.Dispose()", source); + AssertAppearsInOrder( + source, + "_renderFrameOrchestrator = null;", + "_devToolsBackend?.Dispose()", + "new ResourceShutdownStage(\"input\"", + "_input?.Dispose();", + "new ResourceShutdownStage(\"OpenGL context\""); } [Fact] public void ProductionOutcome_UsesObservedWorldAndScreenshotFacts() { - string source = GameWindowSource(); + string presentation = Source("PrivatePresentationRenderer.cs"); + string orchestrator = Source("RenderFrameOrchestrator.cs"); AssertAppearsInOrder( - source, - "_worldSceneRenderer!.Render(frameInput);", - "worldOutcome.NormalWorldDrawn;", - "bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;", - "normalWorldDrawn),", - "screenshotCaptured)));"); + presentation, + "_foundation.Foundation.PortalViewportVisible;", + "_portal.Draw(", + "bool screenshotCaptured = _screenshots?.CapturePending(", + "portalViewportVisible,", + "screenshotCaptured);"); + AssertAppearsInOrder( + orchestrator, + "WorldRenderFrameOutcome world = _world.Render(input);", + "_presentation.Render(input, world);", + "new RenderFrameOutcome(world, presentation);", + "_diagnostics.Publish(input, outcome);"); + } + + [Fact] + public void GameWindow_OnRenderIsOneImmutableOrchestratorHandoff() + { + string source = GameWindowSource(); + int start = source.IndexOf( + "private void OnRender(double deltaSeconds)", + StringComparison.Ordinal); + int end = source.IndexOf( + "private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot", + start, + StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + string body = source[start..end]; + + Assert.Equal(1, CountOccurrences(body, "_renderFrameOrchestrator!.Render(")); + Assert.DoesNotContain("_gpuFrameFlights", body); + Assert.DoesNotContain("_worldScene", body); + Assert.DoesNotContain("_devToolsFramePresenter", body); + Assert.DoesNotContain("_retailUiRuntime", body); + Assert.DoesNotContain("_frameScreenshots", body); } [Fact] @@ -168,6 +210,25 @@ public sealed class GameWindowRenderLeafCompositionTests "Rendering", "GameWindow.cs")); + private static string Source(string fileName) => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + fileName)); + + private static int CountOccurrences(string source, string value) + { + int count = 0; + int cursor = 0; + while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0) + { + count++; + cursor += value.Length; + } + return count; + } + private static void AssertAppearsInOrder(string source, params string[] needles) { int cursor = -1; diff --git a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs index 87dc0270..80ca7706 100644 --- a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs @@ -93,7 +93,7 @@ public sealed class PaperdollFramePresenterTests } [Fact] - public void PresenterAndProductionHelpersHaveNoWindowOrDelegateBackReference() + public void PresenterAndProductionHelpersHaveNoDirectWindowOrDelegateBackReference() { Type[] owners = [ diff --git a/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs b/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs new file mode 100644 index 00000000..4d384a67 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs @@ -0,0 +1,255 @@ +using System.Reflection; +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class PrivatePresentationRendererTests +{ + private static readonly RenderFrameInput Input = new(0.125d, 1600, 900); + private static readonly WorldRenderFrameOutcome World = new(3, 12, true); + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void Render_PreservesPortalPaperdollGameplayDevToolsScreenshotOrder( + bool portalVisible, + bool screenshotCaptured) + { + List calls = []; + var portal = new Portal(calls); + var paperdoll = new Paperdoll(calls); + var gameplay = new GameplayUi(calls); + var devTools = new DevTools(calls); + var screenshots = new Screenshots(calls, screenshotCaptured); + var presentation = new PrivatePresentationRenderer( + portal, + new FoundationSource(portalVisible), + paperdoll, + gameplay, + devTools, + screenshots); + + PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World); + + Assert.Equal( + ["portal", "paperdoll", "gameplay-ui", "devtools-render", "screenshot"], + calls); + Assert.Equal(new PrivatePresentationFrameOutcome( + portalVisible, + screenshotCaptured), outcome); + Assert.Equal((Input.ViewportWidth, Input.ViewportHeight), portal.Size); + Assert.Equal((Input.ViewportWidth, Input.ViewportHeight), screenshots.Size); + Assert.Equal(Input, gameplay.Input); + Assert.Equal(Input, devTools.Input); + } + + [Fact] + public void Render_OptionalLayersMayBeAbsentButPortalOutcomeRemainsObserved() + { + List calls = []; + var presentation = new PrivatePresentationRenderer( + new Portal(calls), + new FoundationSource(portalVisible: true), + paperdoll: null, + gameplayUi: null, + devTools: null, + screenshots: null); + + PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World); + + Assert.Equal(["portal"], calls); + Assert.True(outcome.PortalViewportDrawn); + Assert.False(outcome.ScreenshotCaptured); + } + + [Fact] + public void Render_ReportsThePreparedPortalSnapshotWhenDrawMutatesTheSource() + { + List calls = []; + var foundation = new MutableFoundationSource(portalVisible: true); + var presentation = new PrivatePresentationRenderer( + new MutatingPortal(calls, () => foundation.PortalVisible = false), + foundation, + paperdoll: null, + gameplayUi: null, + devTools: null, + screenshots: null); + + PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World); + + Assert.Equal(["portal"], calls); + Assert.False(foundation.PortalVisible); + Assert.True(outcome.PortalViewportDrawn); + } + + [Fact] + public void Constructor_RequiresTheCanonicalPortalOwner() + { + Assert.Throws(() => + new PrivatePresentationRenderer(null!, new FoundationSource(false), null, null, null, null)); + Assert.Throws(() => + new PrivatePresentationRenderer(new Portal([]), null!, null, null, null, null)); + } + + [Fact] + public void ExtractedPresentationOwner_RetainsNoDirectWindowOrDelegate() + { + FieldInfo[] fields = typeof(PrivatePresentationRenderer).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + [Fact] + public void RetainedUiAdapter_UsesTheDocumentedRuntimeBindingBoundary() + { + FieldInfo[] adapterFields = typeof(RetainedGameplayUiFrame).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.Equal( + [ + typeof(AcDream.App.UI.RetailUiRuntime), + typeof(Silk.NET.Input.IInputContext), + ], + adapterFields.Select(field => field.FieldType).OrderBy(type => type.FullName)); + Assert.DoesNotContain( + adapterFields, + field => field.FieldType == typeof(GameWindow) + || typeof(Delegate).IsAssignableFrom(field.FieldType)); + + FieldInfo bindings = typeof(AcDream.App.UI.RetailUiRuntime).GetField( + "_bindings", + BindingFlags.Instance | BindingFlags.NonPublic)!; + Assert.Equal(typeof(AcDream.App.UI.RetailUiRuntimeBindings), bindings.FieldType); + Assert.Contains( + typeof(AcDream.App.UI.RetailUiRuntimeBindings).GetProperties(), + property => property.PropertyType.Name.EndsWith("Bindings", StringComparison.Ordinal)); + } + + [Fact] + public void KnownSharedOwnerPaths_ArePinnedWithoutClaimingRecursivePurity() + { + static Type[] FieldTypes(Type owner) => owner.GetFields( + BindingFlags.Instance | BindingFlags.NonPublic) + .Select(field => field.FieldType) + .ToArray(); + + Assert.Contains( + typeof(AcDream.App.UI.RetailUiRuntime), + FieldTypes(typeof(RetainedGameplayUiFrame))); + Assert.Contains( + typeof(AcDream.App.UI.UiViewport), + FieldTypes(typeof(RetailPaperdollFrameView))); + Assert.Contains( + typeof(AcDream.App.UI.UiElement), + FieldTypes(typeof(PaperdollInventoryVisibility))); + Assert.Contains( + typeof(AcDream.UI.Abstractions.Panels.Debug.DebugVM), + FieldTypes(typeof(DevToolsPanelSet))); + Assert.Contains( + typeof(AcDream.UI.Abstractions.Panels.Settings.SettingsVM), + FieldTypes(typeof(RuntimeWorldFrameSettingsPreview))); + Assert.Contains( + typeof(AcDream.App.Streaming.LocalPlayerTeleportController), + FieldTypes(typeof(LocalPlayerPortalViewport))); + Assert.Contains( + typeof(AcDream.App.Streaming.ILocalPlayerTeleportInputLifetime), + FieldTypes(typeof(AcDream.App.Streaming.LocalPlayerTeleportController))); + + Assert.DoesNotContain( + FieldTypes(typeof(PrivateFrameScreenshot)), + type => typeof(Delegate).IsAssignableFrom(type)); + Assert.Null(typeof(AcDream.App.Diagnostics.FrameScreenshotController).GetField( + "_getSize", + BindingFlags.Instance | BindingFlags.NonPublic)); + } + + private sealed class Portal(List calls) : IPrivatePortalViewport + { + public (int Width, int Height) Size { get; private set; } + + public void Draw(int width, int height) + { + Size = (width, height); + calls.Add("portal"); + } + } + + private sealed class FoundationSource(bool portalVisible) : IRenderFrameFoundationSource + { + public RenderFrameFoundation Foundation { get; } = new( + portalVisible, + default, + default); + } + + private sealed class MutableFoundationSource(bool portalVisible) : + IRenderFrameFoundationSource + { + public bool PortalVisible { get; set; } = portalVisible; + + public RenderFrameFoundation Foundation => new( + PortalVisible, + default, + default); + } + + private sealed class MutatingPortal(List calls, Action mutate) : + IPrivatePortalViewport + { + public void Draw(int width, int height) + { + calls.Add("portal"); + mutate(); + } + } + + private sealed class Paperdoll(List calls) : IPrivatePaperdollFrame + { + public void Render() => calls.Add("paperdoll"); + } + + private sealed class GameplayUi(List calls) : IRetainedGameplayUiFrame + { + public RenderFrameInput Input { get; private set; } + + public void Render(double deltaSeconds, int width, int height) + { + Input = new RenderFrameInput(deltaSeconds, width, height); + calls.Add("gameplay-ui"); + } + } + + private sealed class DevTools(List calls) : IDevToolsFrameLifecycle + { + public RenderFrameInput Input { get; private set; } + + public void BeginFrame(float deltaSeconds) => calls.Add("devtools-begin"); + + public void AbortFrame() => calls.Add("devtools-abort"); + + public void Render(double deltaSeconds, int viewportWidth, int viewportHeight) + { + Input = new RenderFrameInput(deltaSeconds, viewportWidth, viewportHeight); + calls.Add("devtools-render"); + } + } + + private sealed class Screenshots(List calls, bool result) : + IPrivateFrameScreenshot + { + public (int Width, int Height) Size { get; private set; } + + public bool CapturePending(int width, int height) + { + Size = (width, height); + calls.Add("screenshot"); + return result; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs index b132cadb..34807786 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs @@ -148,21 +148,77 @@ public sealed class RenderFrameOrchestratorTests phases.ObservedInputs); } + [Fact] + public void RenderAndRecoveryFailure_AreAggregatedBeforeGpuClose() + { + var calls = new List(); + var renderFailure = new InvalidOperationException("render"); + var recoveryFailure = new InvalidOperationException("recovery"); + var phases = new RecordingPhases(calls) + { + FailurePoint = "world", + Failure = renderFailure, + RecoveryFailure = recoveryFailure, + }; + + AggregateException actual = Assert.Throws( + () => Create(phases).Render(Input)); + + Assert.Equal(2, actual.InnerExceptions.Count); + Assert.Same(renderFailure, actual.InnerExceptions[0]); + Assert.Same(recoveryFailure, actual.InnerExceptions[1]); + Assert.Equal( + ["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"], + calls); + } + + [Fact] + public void RenderRecoveryAndCloseFailures_AllRemainObservableInCausalOrder() + { + var calls = new List(); + var renderFailure = new InvalidOperationException("render"); + var recoveryFailure = new InvalidOperationException("recovery"); + var closeFailure = new InvalidOperationException("close"); + var phases = new RecordingPhases(calls) + { + FailurePoint = "presentation", + Failure = renderFailure, + RecoveryFailure = recoveryFailure, + CloseFailure = closeFailure, + }; + + AggregateException actual = Assert.Throws( + () => Create(phases).Render(Input)); + + Assert.Equal(3, actual.InnerExceptions.Count); + Assert.Same(renderFailure, actual.InnerExceptions[0]); + Assert.Same(recoveryFailure, actual.InnerExceptions[1]); + Assert.Same(closeFailure, actual.InnerExceptions[2]); + Assert.Equal( + [ + "gpu-begin", "resources", "world", "presentation", + "recovery-abort", "gpu-end", + ], + calls); + } + [Fact] public void Constructor_RejectsEveryMissingRequiredOwner() { var phases = new RecordingPhases([]); Assert.Throws(() => new RenderFrameOrchestrator( - null!, phases, phases, phases, phases)); + null!, phases, phases, phases, phases, phases)); Assert.Throws(() => new RenderFrameOrchestrator( - phases, null!, phases, phases, phases)); + phases, null!, phases, phases, phases, phases)); Assert.Throws(() => new RenderFrameOrchestrator( - phases, phases, null!, phases, phases)); + phases, phases, null!, phases, phases, phases)); Assert.Throws(() => new RenderFrameOrchestrator( - phases, phases, phases, null!, phases)); + phases, phases, phases, null!, phases, phases)); Assert.Throws(() => new RenderFrameOrchestrator( - phases, phases, phases, phases, null!)); + phases, phases, phases, phases, null!, phases)); + Assert.Throws(() => new RenderFrameOrchestrator( + phases, phases, phases, phases, phases, null!)); } [Fact] @@ -175,6 +231,7 @@ public sealed class RenderFrameOrchestratorTests typeof(IWorldSceneFramePhase), typeof(IPrivatePresentationFramePhase), typeof(IRenderFrameDiagnosticsPhase), + typeof(IRenderFrameFailureRecovery), ]; FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields( BindingFlags.Instance | BindingFlags.NonPublic); @@ -239,14 +296,17 @@ public sealed class RenderFrameOrchestratorTests private static string[] ExpectedFailureCalls(string failurePoint) => failurePoint switch { - "resources" => ["gpu-begin", "resources", "gpu-end"], - "world" => ["gpu-begin", "resources", "world", "gpu-end"], + "resources" => ["gpu-begin", "resources", "recovery-abort", "gpu-end"], + "world" => ["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"], "presentation" => - ["gpu-begin", "resources", "world", "presentation", "gpu-end"], + [ + "gpu-begin", "resources", "world", "presentation", + "recovery-abort", "gpu-end", + ], "diagnostics" => [ "gpu-begin", "resources", "world", "presentation", "diagnostics", - "gpu-end", + "recovery-abort", "gpu-end", ], _ => throw new ArgumentOutOfRangeException(nameof(failurePoint)), }; @@ -266,14 +326,15 @@ public sealed class RenderFrameOrchestratorTests } private static RenderFrameOrchestrator Create(RecordingPhases phases) => - new(phases, phases, phases, phases, phases); + new(phases, phases, phases, phases, phases, phases); private sealed class RecordingPhases : IRenderFrameLifetime, IRenderFrameResourcePhase, IWorldSceneFramePhase, IPrivatePresentationFramePhase, - IRenderFrameDiagnosticsPhase + IRenderFrameDiagnosticsPhase, + IRenderFrameFailureRecovery { private readonly List _calls; @@ -285,6 +346,7 @@ public sealed class RenderFrameOrchestratorTests public string? FailurePoint { get; init; } public Exception? Failure { get; init; } public Exception? CloseFailure { get; init; } + public Exception? RecoveryFailure { get; init; } public WorldRenderFrameOutcome World { get; init; } = new(7, 19, true); public PrivatePresentationFrameOutcome Presentation { get; init; } = new(false, false); public List<(string Phase, RenderFrameInput Input)> ObservedInputs { get; } = []; @@ -300,6 +362,13 @@ public sealed class RenderFrameOrchestratorTests throw CloseFailure; } + public void AbortFrame() + { + _calls.Add("recovery-abort"); + if (RecoveryFailure is not null) + throw RecoveryFailure; + } + public void Prepare(RenderFrameInput input) { ObservedInputs.Add(("resources", input)); diff --git a/tests/AcDream.App.Tests/Rendering/RenderFramePreparationControllerTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFramePreparationControllerTests.cs new file mode 100644 index 00000000..d3746dbd --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderFramePreparationControllerTests.cs @@ -0,0 +1,92 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RenderFramePreparationControllerTests +{ + private static readonly RenderFrameInput Input = new(0.25d, 1280, 720); + + [Fact] + public void Prepare_PreservesResourcesThenDevToolsThenWeatherOrder() + { + List calls = []; + var resources = new Resources(calls); + var devTools = new DevTools(calls); + var weather = new Weather(calls); + var preparation = new RenderFramePreparationController( + resources, + devTools, + weather); + + preparation.Prepare(Input); + + Assert.Equal(["resources", "devtools-begin", "weather"], calls); + Assert.Equal(Input, resources.Input); + Assert.Equal((float)Input.DeltaSeconds, devTools.DeltaSeconds); + Assert.Equal(Input.DeltaSeconds, weather.DeltaSeconds); + } + + [Fact] + public void Prepare_DevToolsAreOptionalWithoutChangingRequiredOrder() + { + List calls = []; + var preparation = new RenderFramePreparationController( + new Resources(calls), + devTools: null, + new Weather(calls)); + + preparation.Prepare(Input); + + Assert.Equal(["resources", "weather"], calls); + } + + [Fact] + public void Constructor_RejectsMissingRequiredOwners() + { + var resources = new Resources([]); + var weather = new Weather([]); + + Assert.Throws(() => + new RenderFramePreparationController(null!, null, weather)); + Assert.Throws(() => + new RenderFramePreparationController(resources, null, null!)); + } + + private sealed class Resources(List calls) : IRenderFrameResourcePhase + { + public RenderFrameInput Input { get; private set; } + + public void Prepare(RenderFrameInput input) + { + Input = input; + calls.Add("resources"); + } + } + + private sealed class DevTools(List calls) : IDevToolsFrameLifecycle + { + public float DeltaSeconds { get; private set; } + + public void BeginFrame(float deltaSeconds) + { + DeltaSeconds = deltaSeconds; + calls.Add("devtools-begin"); + } + + public void AbortFrame() => calls.Add("devtools-abort"); + + public void Render(double deltaSeconds, int viewportWidth, int viewportHeight) => + calls.Add("devtools-render"); + } + + private sealed class Weather(List calls) : IRenderWeatherFramePhase + { + public double DeltaSeconds { get; private set; } + + public void Tick(double deltaSeconds) + { + DeltaSeconds = deltaSeconds; + calls.Add("weather"); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs new file mode 100644 index 00000000..ec0a10a5 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs @@ -0,0 +1,178 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RenderFrameRecoveryIntegrationTests +{ + private static readonly RenderFrameInput Input = new(1d / 60d, 1280, 720); + + [Theory] + [InlineData("devtools-begin")] + [InlineData("weather")] + [InlineData("world")] + [InlineData("portal")] + [InlineData("paperdoll")] + [InlineData("gameplay-ui")] + [InlineData("devtools-render")] + public void DownstreamFailure_AbortsDevToolsBeforeGpuClose_ThenNextFrameSucceeds( + string failurePoint) + { + var failure = new FailureSwitch { Point = failurePoint }; + var gpu = new GpuLifetime(failure); + var devTools = new DevTools(failure); + var preparation = new RenderFramePreparationController( + new Resources(), + devTools, + new Weather(failure)); + var presentation = new PrivatePresentationRenderer( + new Portal(failure), + new FoundationSource(), + new Paperdoll(failure), + new GameplayUi(failure), + devTools, + new Screenshots()); + var orchestrator = new RenderFrameOrchestrator( + gpu, + preparation, + new World(failure), + presentation, + new Diagnostics(), + devTools); + + Assert.Throws(() => orchestrator.Render(Input)); + + Assert.False(devTools.IsOpen); + Assert.Equal(1, devTools.AbortCount); + Assert.Equal(1, gpu.EndCount); + Assert.True(devTools.AbortSequence < gpu.LastEndSequence); + + failure.Point = null; + RenderFrameOutcome outcome = orchestrator.Render(Input); + + Assert.False(devTools.IsOpen); + Assert.Equal(2, devTools.BeginCount); + Assert.Equal(2, gpu.EndCount); + Assert.True(outcome.World.NormalWorldDrawn); + } + + private sealed class FailureSwitch + { + private int _sequence; + + public string? Point { get; set; } + + public int NextSequence() => ++_sequence; + + public void ThrowIf(string point) + { + if (Point == point) + throw new InvalidOperationException(point); + } + } + + private sealed class GpuLifetime(FailureSwitch failure) : IRenderFrameLifetime + { + public int EndCount { get; private set; } + public int LastEndSequence { get; private set; } + + public void BeginFrame() + { + } + + public void EndFrame() + { + EndCount++; + LastEndSequence = failure.NextSequence(); + } + } + + private sealed class DevTools(FailureSwitch failure) : IDevToolsFrameLifecycle + { + public bool IsOpen { get; private set; } + public int BeginCount { get; private set; } + public int AbortCount { get; private set; } + public int AbortSequence { get; private set; } + + public void BeginFrame(float deltaSeconds) + { + Assert.False(IsOpen); + IsOpen = true; + BeginCount++; + failure.ThrowIf("devtools-begin"); + } + + public void Render(double deltaSeconds, int viewportWidth, int viewportHeight) + { + Assert.True(IsOpen); + failure.ThrowIf("devtools-render"); + IsOpen = false; + } + + public void AbortFrame() + { + if (!IsOpen) + return; + + AbortCount++; + AbortSequence = failure.NextSequence(); + IsOpen = false; + } + } + + private sealed class Resources : IRenderFrameResourcePhase + { + public void Prepare(RenderFrameInput input) + { + } + } + + private sealed class Weather(FailureSwitch failure) : IRenderWeatherFramePhase + { + public void Tick(double deltaSeconds) => failure.ThrowIf("weather"); + } + + private sealed class World(FailureSwitch failure) : IWorldSceneFramePhase + { + public WorldRenderFrameOutcome Render(RenderFrameInput input) + { + failure.ThrowIf("world"); + return new WorldRenderFrameOutcome(1, 1, NormalWorldDrawn: true); + } + } + + private sealed class Portal(FailureSwitch failure) : IPrivatePortalViewport + { + public void Draw(int width, int height) => failure.ThrowIf("portal"); + } + + private sealed class Paperdoll(FailureSwitch failure) : IPrivatePaperdollFrame + { + public void Render() => failure.ThrowIf("paperdoll"); + } + + private sealed class GameplayUi(FailureSwitch failure) : IRetainedGameplayUiFrame + { + public void Render(double deltaSeconds, int width, int height) => + failure.ThrowIf("gameplay-ui"); + } + + private sealed class FoundationSource : IRenderFrameFoundationSource + { + public RenderFrameFoundation Foundation { get; } = new( + PortalViewportVisible: false, + default, + default); + } + + private sealed class Screenshots : IPrivateFrameScreenshot + { + public bool CapturePending(int width, int height) => false; + } + + private sealed class Diagnostics : IRenderFrameDiagnosticsPhase + { + public void Publish(RenderFrameInput input, RenderFrameOutcome outcome) + { + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs b/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs new file mode 100644 index 00000000..77154717 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/TextRendererFailureSafetyTests.cs @@ -0,0 +1,217 @@ +using System.Reflection; +using AcDream.App.Rendering; +using Silk.NET.OpenGL; + +namespace AcDream.App.Tests.Rendering; + +public sealed class TextRendererFailureSafetyTests +{ + [Fact] + public void Flush_CompilesCompleteGlStateScopeAsFinallyAroundBothDrawLayers() + { + MethodInfo flush = typeof(TextRenderer).GetMethod(nameof(TextRenderer.Flush))!; + MethodBody body = flush.GetMethodBody()!; + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "TextRenderer.cs")); + + Assert.Contains( + body.ExceptionHandlingClauses, + clause => clause.Flags == ExceptionHandlingClauseOptions.Finally); + AssertAppearsInOrder( + source, + "using var stateScope = new TextRenderGlStateScope(_glState);", + "_gl.Disable(EnableCap.Multisample);", + "DrawLayer(_spriteSegs,", + "DrawLayer(_overlaySpriteSegs,"); + } + + [Fact] + public void FailedDraw_RestoresEveryGlValueMutatedByTheTextPass() + { + var gl = new RecordingGlState + { + DepthWrite = false, + BlendSourceRgb = BlendingFactor.DstAlpha, + BlendDestinationRgb = BlendingFactor.OneMinusDstAlpha, + BlendSourceAlpha = BlendingFactor.One, + BlendDestinationAlpha = BlendingFactor.Zero, + Program = 17, + VertexArray = 23, + ArrayBuffer = 31, + ActiveUnit = TextureUnit.Texture2, + }; + gl.SetCapability(EnableCap.DepthTest, enabled: true); + gl.SetCapability(EnableCap.Blend, enabled: false); + gl.SetCapability(EnableCap.CullFace, enabled: true); + gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: true); + gl.SetCapability(EnableCap.Multisample, enabled: false); + gl.TextureBindings[TextureUnit.Texture0] = 41; + gl.TextureBindings[TextureUnit.Texture2] = 43; + StateSnapshot expected = gl.Capture(); + + Action failedDraw = () => + { + using var stateScope = new TextRenderGlStateScope(gl); + gl.SetCapability(EnableCap.DepthTest, enabled: false); + gl.SetCapability(EnableCap.Blend, enabled: true); + gl.SetCapability(EnableCap.CullFace, enabled: false); + gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false); + gl.SetCapability(EnableCap.Multisample, enabled: true); + gl.DepthMask(true); + gl.BlendFuncSeparate( + BlendingFactor.SrcAlpha, + BlendingFactor.OneMinusSrcAlpha, + BlendingFactor.SrcAlpha, + BlendingFactor.OneMinusSrcAlpha); + gl.UseProgram(101); + gl.BindVertexArray(103); + gl.BindBuffer(BufferTargetARB.ArrayBuffer, 107); + gl.ActiveTexture(TextureUnit.Texture0); + gl.BindTexture(TextureTarget.Texture2D, 109); + throw new InvalidOperationException("draw upload"); + }; + + Assert.Throws(failedDraw); + + Assert.Equal(expected, gl.Capture()); + } + + private readonly record struct StateSnapshot( + bool DepthTest, + bool Blend, + bool Cull, + bool AlphaToCoverage, + bool Multisample, + bool DepthWrite, + BlendingFactor BlendSourceRgb, + BlendingFactor BlendDestinationRgb, + BlendingFactor BlendSourceAlpha, + BlendingFactor BlendDestinationAlpha, + uint Program, + uint VertexArray, + uint ArrayBuffer, + TextureUnit ActiveUnit, + uint Texture0, + uint Texture2); + + private sealed class RecordingGlState : ITextRenderGlStateApi + { + private readonly Dictionary _capabilities = []; + + public bool DepthWrite { get; set; } + public BlendingFactor BlendSourceRgb { get; set; } + public BlendingFactor BlendDestinationRgb { get; set; } + public BlendingFactor BlendSourceAlpha { get; set; } + public BlendingFactor BlendDestinationAlpha { get; set; } + public uint Program { get; set; } + public uint VertexArray { get; set; } + public uint ArrayBuffer { get; set; } + public TextureUnit ActiveUnit { get; set; } + public Dictionary TextureBindings { get; } = []; + + public StateSnapshot Capture() => new( + IsEnabled(EnableCap.DepthTest), + IsEnabled(EnableCap.Blend), + IsEnabled(EnableCap.CullFace), + IsEnabled(EnableCap.SampleAlphaToCoverage), + IsEnabled(EnableCap.Multisample), + DepthWrite, + BlendSourceRgb, + BlendDestinationRgb, + BlendSourceAlpha, + BlendDestinationAlpha, + Program, + VertexArray, + ArrayBuffer, + ActiveUnit, + TextureBindings.GetValueOrDefault(TextureUnit.Texture0), + TextureBindings.GetValueOrDefault(TextureUnit.Texture2)); + + public bool IsEnabled(EnableCap capability) => + _capabilities.GetValueOrDefault(capability); + + public int GetInteger(GetPName parameter) => parameter switch + { + GetPName.BlendSrcRgb => (int)BlendSourceRgb, + GetPName.BlendDstRgb => (int)BlendDestinationRgb, + GetPName.BlendSrcAlpha => (int)BlendSourceAlpha, + GetPName.BlendDstAlpha => (int)BlendDestinationAlpha, + GetPName.CurrentProgram => (int)Program, + GetPName.VertexArrayBinding => (int)VertexArray, + GetPName.ArrayBufferBinding => (int)ArrayBuffer, + GetPName.ActiveTexture => (int)ActiveUnit, + GetPName.TextureBinding2D => + (int)TextureBindings.GetValueOrDefault(ActiveUnit), + _ => throw new ArgumentOutOfRangeException(nameof(parameter)), + }; + + public bool GetBoolean(GetPName parameter) => + parameter == GetPName.DepthWritemask + ? DepthWrite + : throw new ArgumentOutOfRangeException(nameof(parameter)); + + public void SetCapability(EnableCap capability, bool enabled) => + _capabilities[capability] = enabled; + + public void DepthMask(bool enabled) => DepthWrite = enabled; + + public void BlendFuncSeparate( + BlendingFactor sourceRgb, + BlendingFactor destinationRgb, + BlendingFactor sourceAlpha, + BlendingFactor destinationAlpha) + { + BlendSourceRgb = sourceRgb; + BlendDestinationRgb = destinationRgb; + BlendSourceAlpha = sourceAlpha; + BlendDestinationAlpha = destinationAlpha; + } + + public void UseProgram(uint program) => Program = program; + + public void BindVertexArray(uint vertexArray) => VertexArray = vertexArray; + + public void BindBuffer(BufferTargetARB target, uint buffer) + { + Assert.Equal(BufferTargetARB.ArrayBuffer, target); + ArrayBuffer = buffer; + } + + public void ActiveTexture(TextureUnit unit) => ActiveUnit = unit; + + public void BindTexture(TextureTarget target, uint texture) + { + Assert.Equal(TextureTarget.Texture2D, target); + TextureBindings[ActiveUnit] = texture; + } + } + + private static void AssertAppearsInOrder(string source, params string[] needles) + { + int cursor = -1; + foreach (string needle in needles) + { + int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal); + Assert.True(next >= 0, $"Missing expected source fragment: {needle}"); + Assert.True(next > cursor, $"Out-of-order source fragment: {needle}"); + cursor = next; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs index 2af6301d..d81a56f3 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -325,7 +325,7 @@ public sealed class WorldRenderFrameBuilderTests } [Fact] - public void World_scene_uses_the_typed_builder_and_game_window_releases_it_before_render_owners() + public void World_scene_uses_the_typed_builder_and_orchestrator_owns_its_local_composition() { string gameWindow = GameWindowSource(); string worldScene = File.ReadAllText(Path.Combine( @@ -341,6 +341,14 @@ public sealed class WorldRenderFrameBuilderTests Assert.DoesNotContain("private void UpdateSunFromSky(", gameWindow, StringComparison.Ordinal); Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal); Assert.DoesNotContain("private static float ParseEnvFloat(", gameWindow, StringComparison.Ordinal); + Assert.DoesNotContain( + "private AcDream.App.Rendering.WorldRenderFrameBuilder?", + gameWindow, + StringComparison.Ordinal); + Assert.DoesNotContain( + "private AcDream.App.Rendering.SkyPesFrameController?", + gameWindow, + StringComparison.Ordinal); AssertAppearsInOrder( worldScene, "_alpha.BeginFrame();", @@ -350,9 +358,10 @@ public sealed class WorldRenderFrameBuilderTests "NormalWorldDrawn: true"); AssertAppearsInOrder( gameWindow, - "_worldSceneRenderer = null;", - "_worldRenderFrameBuilder = null;", - "_skyPesFrame = null;", + "var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(", + "var worldRenderFrameBuilder =", + "new AcDream.App.Rendering.RenderFrameOrchestrator(", + "_renderFrameOrchestrator = null;", "new(\"equipped children\"", "new(\"effect network state\"", "new(\"audio\"", diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs index b99d7c1f..de2cd9fc 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs @@ -283,7 +283,8 @@ public sealed class WorldSceneRendererTests "GameWindow.cs")); Assert.Contains("new AcDream.App.Rendering.WorldSceneRenderer(", source); - Assert.Equal(1, CountOccurrences(source, "_worldSceneRenderer!.Render(frameInput);")); + Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source); + Assert.DoesNotContain("_worldSceneRenderer", source); Assert.DoesNotContain("_retailPViewRenderer.DrawInside(", source); Assert.DoesNotContain("_retailAlphaQueue.BeginFrame();", source); Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source); From 96f8bfcfa8dd36515336621c1196eea3490815d2 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 08:40:52 +0200 Subject: [PATCH 89/98] docs(architecture): close GameWindow render slice Record the clean Release, lifecycle, soak, and framebuffer gates for Slice 7, advance the structural program to Slice 8, and preserve the observed process-residency diagnostic gap as issue 232 rather than weakening its leak threshold.\n\nCo-authored-by: Codex --- AGENTS.md | 22 ++--- CLAUDE.md | 22 ++--- docs/ISSUES.md | 47 ++++++++++- docs/architecture/code-structure.md | 20 +++-- docs/plans/2026-04-11-roadmap.md | 15 +++- docs/plans/2026-05-12-milestones.md | 11 ++- ...ndow-slice-7-render-frame-orchestration.md | 20 ++++- memory/project_gamewindow_decomposition.md | 83 +++++++++++++------ 8 files changed, 176 insertions(+), 64 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 209851cb..8122d41e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,182 tests pass / 5 skip. +Release builds; 7,341 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -123,16 +123,16 @@ Selection/interaction Slice 1, live-animation-presentation Slice 2, complete live-session ownership Slice 3, and live-entity App integration Slice 4 are landed. Focused hydration, teardown, inbound-authority, network-update, and motion-runtime owners now sit over canonical `LiveEntityRuntime` without a -second GUID dictionary. Slice 5 landblock presentation and Slice 6 update-frame -orchestration are complete. Streaming, input, the object/network barrier, -liveness, teleport, player mode, and camera presentation have typed phase -owners; `GameWindow.OnUpdate` is one orchestrator handoff with no substantial -transitive callback facade. `GameWindow` is 7,026 raw lines / 241 fields / 108 -methods, down 8,697 lines (55.3%) from the 15,723-line campaign baseline. The -7,182-test Release suite, 314-second lifecycle/reconnect gate, and 394-second -synchronized nine-stop soak pass. Slice 7 draw-frame orchestration is active; -Slice 8 finishes composition and Silk callback cleanup. -See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining +second GUID dictionary. Slices 5–7 are complete: landblock presentation, +update-frame orchestration, and render-frame orchestration all have typed +owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; +the frame owners have no direct window or anonymous callback-bag ownership. +`GameWindow` is 4,666 raw lines / 196 fields / 70 methods, down 11,057 lines +(70.3%) from the 15,723-line campaign baseline. The 7,341-test Release suite, +315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop +soak pass. Final Slice 8 composition/shutdown and Silk callback cleanup is +active. See `docs/architecture/code-structure.md`. **Carried:** #232, #153, +#116, remaining R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/CLAUDE.md b/CLAUDE.md index e2cad234..4a332db0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,182 tests pass / 5 skip. +Release builds; 7,341 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -121,16 +121,16 @@ Selection/interaction Slice 1, live-animation-presentation Slice 2, complete live-session ownership Slice 3, and live-entity App integration Slice 4 are landed. Focused hydration, teardown, inbound-authority, network-update, and motion-runtime owners now sit over canonical `LiveEntityRuntime` without a -second GUID dictionary. Slice 5 landblock presentation and Slice 6 update-frame -orchestration are complete. Streaming, input, the object/network barrier, -liveness, teleport, player mode, and camera presentation have typed phase -owners; `GameWindow.OnUpdate` is one orchestrator handoff with no substantial -transitive callback facade. `GameWindow` is 7,026 raw lines / 241 fields / 108 -methods, down 8,697 lines (55.3%) from the 15,723-line campaign baseline. The -7,182-test Release suite, 314-second lifecycle/reconnect gate, and 394-second -synchronized nine-stop soak pass. Slice 7 draw-frame orchestration is active; -Slice 8 finishes composition and Silk callback cleanup. -See `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining +second GUID dictionary. Slices 5–7 are complete: landblock presentation, +update-frame orchestration, and render-frame orchestration all have typed +owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; +the frame owners have no direct window or anonymous callback-bag ownership. +`GameWindow` is 4,666 raw lines / 196 fields / 70 methods, down 11,057 lines +(70.3%) from the 15,723-line campaign baseline. The 7,341-test Release suite, +315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop +soak pass. Final Slice 8 composition/shutdown and Silk callback cleanup is +active. See `docs/architecture/code-structure.md`. **Carried:** #232, #153, +#116, remaining R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 194fcefb..c0ca02ae 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,12 +26,15 @@ What does NOT go here: ## Current queue — 2026-07-22 -- **Active structural work:** `GameWindow` decomposition. Slices 1–6 are - complete; `GameWindow.OnUpdate` is one typed orchestration handoff and the - class is 7,026 lines / 241 fields / 108 methods. Slice 7 draw-frame - orchestration is active in +- **Active structural work:** `GameWindow` decomposition. Slices 1–7 are + complete; `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed + orchestration handoffs and the class is 4,666 lines / 196 fields / 70 + methods. Final Slice 8 composition/shutdown cleanup is active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. +- **Connected-gate fidelity:** `#232` adds canonical managed/GPU/cache-owner + snapshots to the nine-stop soak so process-residency variance cannot be + mistaken for an owner leak or silently waved away. - **Separate rendering gate:** `#225`, lifestone/particle alpha ordering. Its connected performance, lifetime, and unattended portal routes pass. - **Carried behavior debt:** `#153` far-teleport unstreamed-edge arrival and @@ -70,6 +73,42 @@ Copy this block when adding a new issue: --- +## #232 — Nine-stop soak process-memory gate lacks canonical owner snapshots + +**Status:** OPEN +**Severity:** LOW +**Filed:** 2026-07-22 +**Component:** connected automation / resource diagnostics + +**Description:** Identical-binary nine-stop runs can cross the same-location +working/private-process-memory tolerance even when all deterministic lifecycle, +entity, animation, allocation-rate, update-cost, movement, and graceful-close +checks pass. At Slice 7 closeout, two runs reported Caul-plateau working-set +growth of +240.9 MiB and +497.4 MiB; a third unchanged run passed at +85.7 MiB +(private +59.3 MiB). Earlier Slice 6 development history also contains both a +coarse-memory failure and subsequent clean runs. + +**Root cause / status:** The current soak samples only process working set and +private bytes plus coarse world/title counters. Those totals combine live .NET +objects, committed-but-free GC segments, native allocations, mapped pages, and +GPU-driver residency, so a failed total cannot identify the owner that grew. +This is a diagnostic blind spot, not evidence that the threshold should be +raised. The unchanged gate produced a clean required closeout pass; no runtime +workaround or tolerance change was made. + +**Files:** `tools/run-connected-r6-soak.ps1`; +`src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs`; +`src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs`. + +**Acceptance:** Each named soak checkpoint records the existing canonical +world-lifecycle resource snapshot (managed used/committed, tracked GPU buffers +and textures, mesh/atlas caches, composite/particle texture ownership, VFX and +pending teardown counts). Same-location assertions identify which owner grew; +process residency remains a secondary guard. Repeated clean runs pass without +loosening the canonical-owner leak limits. + +--- + ## #231 — F-key pickup omits retail pending destination-slot presentation **Status:** DONE (2026-07-21, `52dbb574` + `5acc3f01`) diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index db4b89b7..08e0ff6c 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -1,9 +1,9 @@ # acdream — code structure & extraction sequence **Status:** Living document. Created 2026-05-16; implementation reconciliation -completed 2026-07-21; Slices 1–6 and the Slice 7 draw-frame cutover landed by -2026-07-22. Slice 7 connected closeout is active before the final Slice 8 -composition/shutdown cleanup and new M4 subsystems enter the App layer. +completed 2026-07-21; Slices 1–7 landed by 2026-07-22. The final Slice 8 +composition/shutdown cleanup is active before new M4 subsystems enter the App +layer. **Purpose:** Describe the desired structural state of the App layer, explain the rules we've adopted, and lay out the safe extraction sequence from today's reality (one 15,723-line `GameWindow.cs` at the @@ -644,11 +644,11 @@ counts, and a 0.8 ms update-frame p95 at the final Caul checkpoint. smaller than the campaign baseline. TS-50, TS-51, and TS-53 remain accurately registered; this behavior-preserving extraction introduced no new divergence. -#### Slice 7 — extract `RenderFrameOrchestrator` +#### Slice 7 — extract `RenderFrameOrchestrator` — COMPLETE 2026-07-22 Detailed execution ledger: [`docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md`](../plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md) -(connected closeout active). +(complete). Move the complete draw graph and its reusable frame-local scratch state into a GL-owning App collaborator. Preserve the exact modern pipeline order, clip @@ -667,10 +667,14 @@ value-only handoff to `RenderFrameOrchestrator`; focused resource, world, private-presentation, and diagnostic owners contain the former frame body and failure recovery. The Release suite passes 7,341 tests with five intentional skips. `GameWindow` is 4,666 raw lines / 196 fields / 70 methods: 11,057 lines -(70.3%) smaller than the campaign baseline. The connected lifecycle and -resource-soak closeout remains before Slice 7 is final. +(70.3%) smaller than the campaign baseline. The 315.6-second lifecycle and +395.2-second synchronized nine-stop soak pass with code-zero graceful exits; +the six stable framebuffer checkpoints preserve the Slice 6 presentation. +Issue #232 records the coarse process-residency gate's run-to-run variance so +future diagnostics can distinguish canonical owner growth from OS/driver/GC +residency without weakening leak detection. -#### Slice 8 — composition and shutdown cleanup +#### Slice 8 — composition and shutdown cleanup — ACTIVE Keep GL/window construction in `GameWindow.OnLoad`, but group creation into small composition functions and delete feature state left behind by prior diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 46c10bca..8e405354 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-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–6 are complete and Slice 7 draw-frame orchestration is active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 are complete and final Slice 8 composition/shutdown cleanup is active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. 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. --- @@ -68,7 +68,18 @@ passes 7,182 tests / 5 skips; the 314-second lifecycle/reconnect gate and 394-second synchronized nine-stop resource soak pass with graceful exits and stable owner counts. The completed ledger is [`2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`](2026-07-21-gamewindow-slice-6-update-frame-orchestration.md). -Slice 7 `RenderFrameOrchestrator` extraction is now active. +Slice 7 render-frame orchestration is complete. `GameWindow.OnRender` now +supplies one immutable frame input to focused resource, world-scene, +private-presentation, and diagnostic owners. Exact world/PView/alpha/portal/ +paperdoll/retained-UI/ImGui/screenshot/GPU-close order and failure recovery are +pinned by tests. `GameWindow` is 4,666 raw lines / 196 fields / 70 methods, +11,057 lines (70.3%) below the campaign baseline. The Release suite passes +7,341 tests / 5 skips; the 315.6-second lifecycle/reconnect gate and 395.2- +second synchronized soak pass with graceful exits, and the six stable PNG +checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse +process-residency gate's diagnostic blind spot; no tolerance was loosened. + +Final Slice 8 composition/shutdown cleanup is now active. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index c398945d..91eb7ccc 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -32,7 +32,16 @@ suite passes 7,182 tests / 5 skips; the 314-second capped/reconnect lifecycle gate and synchronized 394-second nine-destination resource soak both pass with graceful exits and stable owner counts. The completed Slice 6 ledger is [`2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`](2026-07-21-gamewindow-slice-6-update-frame-orchestration.md). -Slice 7 draw-frame orchestration is active. +Slice 7 draw-frame orchestration is complete. `GameWindow.OnRender` is one +typed frame handoff; resource, world-scene, private-presentation, and +diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is +4,666 raw lines / 196 fields / 70 methods, down 11,057 lines (70.3%) from the +campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the +315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized +nine-destination soak pass with graceful exits, and six stable PNG checkpoints +preserve Slice 6 presentation. Final Slice 8 composition/shutdown cleanup is +active. Issue #232 tracks process-residency variance in the soak without +loosening its leak threshold. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md index a327e38d..70927f40 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md +++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md @@ -1,6 +1,6 @@ # GameWindow Slice 7 — render-frame orchestration -**Status:** Active 2026-07-22. +**Status:** Complete 2026-07-22. **Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 7. **Baseline:** `9512404e`; `GameWindow.cs` is 7,026 raw lines, 241 fields, and 108 methods. The Release suite passes 7,182 tests / 5 fixture or environment @@ -30,7 +30,7 @@ port, or resource-lifetime redesign. PView/fallback world branches, particles, debug world draw, and completion. - [x] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one handoff, and delete obsolete frame bodies, fields, helpers, and callbacks. -- [ ] H — corrected-diff reviews, full Release suite, connected lifecycle and +- [x] H — corrected-diff reviews, full Release suite, connected lifecycle and soak gates, framebuffer comparison, documentation, memory, and metrics. Every checked checkpoint is one bisectable architectural commit. A checkpoint @@ -517,6 +517,22 @@ After three clean corrected-diff reviews: 8. audit architecture, divergence pointers, roadmap, milestones, issues, `AGENTS.md`, `CLAUDE.md`, and durable memory. +Completed 2026-07-22. The 315.6-second lifecycle/reconnect gate passed at +`9d7df1bf` with six valid PNG checkpoints, code-zero graceful capped exit, and +fresh-process uncapped reconnect. Side-by-side comparison with the Slice 6 +artifacts preserved world geometry, UI/paperdoll layering, private viewports, +and depth/presentation; only live weather, particle timing, authoritative +vitals, and small scripted camera-settling differences changed. The unchanged +nine-stop gate then passed in 395.2 seconds with nine materializations, all +Caul/Holtburg movement exercises, code-zero graceful shutdown, update p95 at +or below 0.8 ms, and same-location Caul plateau deltas of +85.7 MiB working set +and +59.3 MiB private memory. Two immediately preceding identical-binary runs +crossed the coarse process-residency limit while every other deterministic +lifecycle, timing, and shutdown check passed; #232 records the need to add +canonical managed/GPU/cache owner snapshots to that gate instead of weakening +its leak threshold. Release build and the complete suite remain green at 7,341 +passed / 5 skipped. Slice 7 is closed with no new retail divergence. + The accepted baseline at docs-only commit `9512404e` is behavior-equivalent to production cutover `e91f3102`: 7,182 passed / 5 skipped, 314.195-second lifecycle/reconnect, and 393.581-second soak. Exact counters are recorded in diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index 9acbe0b1..c10261c2 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -2,9 +2,9 @@ ## Current state -The behavior-preserving App ownership campaign is complete through Slice 6. +The behavior-preserving App ownership campaign is complete through Slice 7. `GameWindow.cs` moved from the 2026-07-21 baseline of 15,723 lines / 278 fields / -205 methods to 7,026 lines / 241 fields / 108 methods: 8,697 lines (55.3%) were +205 methods to 4,666 lines / 196 fields / 70 methods: 11,057 lines (70.3%) were removed without changing accepted gameplay or rendering behavior. | Slice | Ownership moved out | Closeout size | @@ -15,11 +15,14 @@ removed without changing accepted gameplay or rendering behavior. | 4 | live-entity hydration, teardown, and network presentation | 10,301 / 267 / 163 | | 5 | landblock build, publication, retirement, and shared-origin lifetime | 8,811 / 247 / 153 | | 6 | complete update-frame orchestration | 7,026 / 241 / 108 | +| 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 | Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. -The detailed ledgers and earlier commit sets live under `docs/plans/2026-07-21- -gamewindow-slice-*.md` and `docs/architecture/code-structure.md`. +Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`, +`6d6e5b5f`, `85239fb3`, `28e1cf80`, and production cutover `9d7df1bf`. +The detailed ledgers live under `docs/plans/2026-07-2*-gamewindow-slice-*.md` and +`docs/architecture/code-structure.md`. ## Frozen update graph @@ -45,40 +48,70 @@ the session generation. Extracted phase owners query these typed runtimes and must not create replacement dictionaries, service locators, or callback facades into substantial `GameWindow` methods. +## Frozen render graph + +`GameWindow.OnRender` creates one value-only `RenderFrameInput` and calls +`RenderFrameOrchestrator.Render`. The accepted healthy order is: + +1. begin the GPU-flight transaction; +2. begin/reset frame resources, clear, and publish live resources; +3. begin the optional ImGui frame; +4. advance weather display preparation; +5. draw the complete world/PView/shared-alpha/particle/debug scene; +6. draw the portal/private viewport using the pre-private foundation snapshot; +7. refresh/draw paperdoll, retained gameplay UI, then submit ImGui; +8. capture a requested screenshot; +9. publish title/resource/frame diagnostics; +10. close the GPU-flight transaction. + +Recovery is one reverse-lifetime transaction. An active ImGui frame closes +through the owning Silk controller, and text rendering restores exact borrowed +GL state on success or failure. The orchestrator and its immediate phase owners +retain no direct `GameWindow` or anonymous callback bag. Recursive reachability +through canonical UI/input owners is not frame ownership; the known paths and +review rule live in `docs/architecture/code-structure.md`. + ## Accepted verification -- Release suite: 7,182 passed / 5 fixture or environment skips. -- Connected lifecycle/reconnect: 314.195 seconds, graceful close and fresh - process reconnect passed; only 25 expected world-edge empty landblocks. -- Synchronized nine-stop resource soak: 393.581 seconds, graceful exit; stable - 21,025 entity and 13 animation-owner counts at the Caul return and Caul - plateau; update p95 0.8 ms. Working/private memory delta was +112.2/+111.0 - MiB, inside the gate. -- The 35 missing VFX table/emitter diagnostics are known DAT-driven records, - not a Slice 6 regression. -- TS-50, TS-51, and TS-53 remain registered; Slice 6 introduced no new retail +- Release suite: 7,341 passed / 5 fixture or environment skips. +- Connected lifecycle/reconnect: 315.6 seconds, graceful capped close and fresh + process uncapped reconnect passed; only 25 expected world-edge empty + landblocks. +- The six lifecycle PNGs preserve Slice 6 world geometry, depth, UI/paperdoll, + private viewport, and presentation. Differences are live weather/particles, + authoritative vitals, and small scripted camera settling. +- Synchronized nine-stop resource soak: 395.2 seconds, nine materializations, + all movement/jump/combat exercises, and graceful exit passed; update p95 was + at or below 0.8 ms. Caul return → plateau working/private delta was + +85.7/+59.3 MiB, inside the unchanged gate. +- Two immediately preceding identical-binary runs exceeded the coarse process- + residency threshold while deterministic ownership/lifecycle/timing checks + passed. #232 adds canonical resource-owner snapshots; do not solve this by + loosening the process-memory threshold or treating residency alone as an + identified owner leak. +- The 18–33 missing VFX table/emitter diagnostics are known DAT-driven records, + not a Slice 7 regression. +- TS-50, TS-51, and TS-53 remain registered; Slice 7 introduced no new retail divergence. ## Next work -Slice 7 extracts `RenderFrameOrchestrator`: the modern draw graph, portal/PView -visibility routing, landscape/opaque/shared-alpha flushes, particles, debug -draw, paperdoll, retained UI, frame fences, and reusable frame-local scratch. -It must keep the exact accepted order and use a small immutable service set plus -explicit per-frame input. It may not become a GL service locator or call -substantial draw bodies back on `GameWindow`. - -Slice 8 then groups composition and shutdown wiring and reduces Silk callbacks -to narrow calls into input, update, render, resize, focus, and shutdown owners. +Slice 8 groups composition and shutdown wiring and reduces Silk callbacks to +narrow calls into input, update, render, resize, focus, and shutdown owners. +It removes leftover feature state only where Slices 1–7 already established a +canonical owner; it does not redesign gameplay, rendering, or resource lifetime. ## Do not retry - Do not reintroduce delegate facades into `GameWindow`; an ordering wrapper is not an extraction until the state and behavior body move. -- Do not reorder the frozen update graph during structural work. TS-53 records - the known retail host-order difference for a separate evidence-backed port. +- Do not reorder the frozen update or render graphs during structural work. + TS-53 records the known retail host-order difference for a separate evidence- + backed port. - Do not add a second GUID, session, spatial, or resource owner. - Do not fold full `GameEntity` aggregation into this campaign; evaluate it after Slice 8. +- Do not loosen the soak process-memory threshold to hide #232. Add canonical + owner snapshots and preserve process residency as a secondary guard. - Small value/policy callbacks and diagnostics are acceptable only when they do not call a substantial window body or hide mutable ownership. From 20ebf217e2a25c466bc00d2d35ade1755f4c9e0f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 09:14:24 +0200 Subject: [PATCH 90/98] docs(architecture): plan GameWindow lifecycle shell Freeze host ordering, resource ownership, callback quiescence, partial-startup rollback, and canonical soak evidence as twelve reviewed, bisectable Slice 8 checkpoints. Co-authored-by: Codex --- docs/architecture/code-structure.md | 3 + ...amewindow-slice-8-composition-lifecycle.md | 610 ++++++++++++++++++ memory/project_gamewindow_decomposition.md | 2 + 3 files changed, 615 insertions(+) create mode 100644 docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 08e0ff6c..82eac7fa 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -676,6 +676,9 @@ residency without weakening leak detection. #### Slice 8 — composition and shutdown cleanup — ACTIVE +Detailed execution ledger: +[`docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md`](../plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md). + Keep GL/window construction in `GameWindow.OnLoad`, but group creation into small composition functions and delete feature state left behind by prior slices. `OnClosing` delegates to the existing retryable shutdown transaction. diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md new file mode 100644 index 00000000..e42161a5 --- /dev/null +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -0,0 +1,610 @@ +# GameWindow Slice 8 — composition and lifecycle shell + +**Status:** Active 2026-07-22. +**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 8. +**Baseline:** `96f8bfcf`; `GameWindow.cs` is 4,666 raw lines, 196 fields, and +70 methods. The Release suite passes 7,341 tests / 5 fixture or environment +skips. The connected lifecycle/reconnect and synchronized nine-stop soak gates +pass. +**Behavior rule:** This is the final behavior-preserving ownership slice. It +must not change accepted input priority, session/reset behavior, update/render +order, world presentation, quality settings, or retail gameplay behavior. It +may fix resource and callback lifetime defects proven by this slice's ownership +audit. + +## Progress ledger + +- [ ] A — freeze construction/callback/shutdown order and delete proven dead or + test-facade residue. +- [ ] B — make native-window callback intake an explicit reversible owner and + define host-quiescence failure semantics. +- [ ] C — extract live-session host/reset/binding callbacks and verify the + embedded skill formula against named retail. +- [ ] D — extract retail world-environment/day/weather behavior. +- [ ] E — extract two-phase raw pointer/camera input, focus, and framebuffer + resize behind symmetric named subscriptions. +- [ ] F — extract the sole gameplay input-action router plus focused combat and + diagnostic command owners. +- [ ] G — extract two-phase persisted settings/display/quality ownership. +- [ ] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots + explicit single ownership and transfer seams. +- [ ] I — group `OnLoad` into small ordered, fakeable composition phases with + transactional partial-acquisition rollback. +- [ ] J — move the exact retryable shutdown manifest to a focused lifetime + owner and prove all partial-load/reentrant/retry paths. +- [ ] K — in a separate #232 commit, add canonical owner snapshots to every soak + checkpoint without weakening the process-memory guard. +- [ ] L — corrected-diff reviews, focused and full Release gates, connected + lifecycle/soak, framebuffer comparison, documentation, memory, and final + visual handoff. + +Each checked checkpoint lands as a bisectable commit. A checkpoint is not done +while a new class delegates a substantial body back into `GameWindow`, stores +`GameWindow`, or replaces 196 fields with one runtime/service-locator bag. + +## 1. Outcome and non-goals + +At slice exit `GameWindow` is the native construction shell: + +```text +GameWindow + ├── creates Window + GL + input context + ├── invokes ordered composition functions + ├── forwards tiny Silk callbacks to typed owners + ├── starts LiveSessionController last + └── asks GameWindowLifetime to close synchronously +``` + +The shell may retain injected Core/UI game-state roots whose replacement would +be the separately deferred `GameEntity`/client-state aggregation migration. It +must not retain AC algorithms, entity scans, session binding/reset bodies, +settings algorithms, the input switch, anonymous device subscriptions, or the +shutdown operation manifest. + +This slice does **not**: + +- alter the frozen twelve-phase update graph or ten-phase render graph; +- port a new retail host loop or retire TS-53; +- change input bindings, mouse feel, focus/reset semantics, camera formulas, + resize/DPI behavior, frame pacing, VSync, MSAA, quality, draw distance, or + resource budgets; +- change live-session authority, packet parsing, reset order, the 35-second + graceful F653 confirmation, reconnect, or transport teardown; +- change UI layout, magic, portal presentation, selection, world rendering, or + gameplay behavior; +- fold full `GameEntity` aggregation, headless mode, Linux support, or GPU + particle work into this campaign; +- loosen the soak's working/private-memory threshold. + +## 2. Frozen host behavior + +No fresh retail research is required for the host boundaries. One audited +exception exists: the embedded skill-formula helper currently cites ACE only, +so Checkpoint C must complete the named-retail pseudocode/cross-reference/test +workflow before moving or claiming that formula. + +### 2.1 Startup and callbacks + +Preserve this exact order: + +1. before `Window.Create`, load persisted Display and resolve VSync/MSAA because + these are native-context attributes; +2. create the window and bind `DisplayFramePacingController` immediately; +3. register Load, Update, main Render, pacing Render, Closing, FocusChanged, + Move pacing, StateChanged pacing, and FramebufferResize in that order; +4. in `OnLoad`, acquire GL and input from the native window; +5. compose camera/DAT/effects/audio, persisted settings, optional devtools, + world/render resources, retained UI, live presentation, streaming, session, + teleport, then update/render roots; +6. call `LiveSessionController.Start` last, including when live mode or + credentials are absent, because its disabled path still performs the + canonical reset. + +Main Render remains registered before pacing so the wait occurs after all frame +work and before Silk's swap. Direct raw MouseMove remains ordered after +SilkKeyboard/MouseSource + InputDispatcher subscriptions and before retained +`UiHost` device wiring. + +### 2.2 Input, focus, and resize + +Preserve the exact input-action priority: + +1. pointer press/release transitions; +2. scroll; +3. combat press/release transitions; +4. Press/DoubleClick gate; +5. retained UI semantic actions; +6. selection interactions; +7. pressed movement/autorun; +8. the remaining UI/debug/player/combat/escape commands. + +Mouse capture remains three distinct operations: + +- focus loss and camera-mode exit use lifecycle end and may publish a final + movement update; +- session reset uses reset semantics and cannot send into the ending session; +- process close retires the session first, then releases presentation without + an active session. + +FramebufferResize continues to ignore non-positive sizes, then updates GL +viewport, `ViewportAspectState`, camera aspect, and the forced devtools layout +reset. It must not mutate retained `UiRoot` size; the next UI draw does that. + +### 2.3 Session and reset + +`LiveSessionController` remains the only session lifecycle owner. The extracted +host adapter may hold typed binding/reset collaborators but no second session, +generation, identity, GUID, routing, or command map. + +Preserve `LiveSessionResetManifest` exactly: projection/capture/UI first; +equipped projection before the canonical live runtime; live-runtime convergence +before identity reset; pending effects/hooks/presentation last. The AC skill +formula currently embedded in the window moves only after Checkpoint C adds its +missing named-retail citation and conformance tests (or records the proven +remaining adaptation). + +### 2.4 Update and render + +`OnUpdate` remains profiler scope plus one immutable +`UpdateFrameOrchestrator.Tick`. `OnRender` takes the existing one window-size +snapshot and calls one `RenderFrameOrchestrator.Render`. PView's +`FramebufferSize` source remains distinct from the logical Window.Size used by +the frame/UI/portal/screenshot path. TS-33 and TS-53 remain registered. + +### 2.5 Shutdown + +Closing synchronously completes the retryable transaction while the GL context +is current. Direct `Dispose` is the constructed-never-run, Run-failure, and +retry fallback. The native window is disposed only after successful completion +or the explicitly reported last-resort incomplete fallback described below. + +The frozen dependency order is: + +1. set a no-throw host-quiescence gate and converge live-session shutdown as + independent all-attempted **hard** operations; this guarantees F653 and + transport teardown before dependents; +2. attempt physical detachment of all window/Silk/UI ingress as explicitly + reportable **soft** operations: retry them, record any persistent failure, + but do not block now-safe owner/GPU teardown after quiescence; +3. withdraw **both** update and render frame graphs through their shared slot; +4. retire session/UI dependents; +5. clear the live runtime while teardown callbacks remain alive; +6. retire live dependents; +7. wait for submitted GPU work; +8. retire private/render frontends; +9. retire shared textures; +10. retire the mesh adapter; +11. retire remaining render borrowers, including terrain renderer; +12. retire separately owned borrowed assets, including terrain atlas and sky + shader, only after their respective renderer stage converges; +13. retire remaining render owners and frame-flight ownership; +14. close DAT mappings; +15. dispose the already-detached input context; +16. dispose GL last; +17. return to `GameWindow.Dispose`, which disposes the native window. + +The logical quiescence gate makes callbacks inert even when a physical event +unsubscribe reports failure. `ResourceShutdownTransaction` therefore gains an +explicit non-blocking/reportable operation policy for physical detach only. +Those failures remain in the final result and are never swallowed, but they do +not strand unrelated resources. Session convergence and GPU drain remain hard +barriers. Operations within a stage remain independent and all-attempted. Later +stages remain protected until the current stage converges. Successful operations are +never replayed on retry or reentrant completion. + +Silk's `Closing` event is not cancellable. `OnClosing` therefore performs the +first synchronous completion attempt and reports an incomplete transaction +without pretending success. The outer `Dispose` retries the same retained +transaction. If a persistent native/driver failure still prevents convergence, +the native window/context is disposed as the explicit last-resort safety net +and the incomplete result is logged with the blocked stage; it is never +reported as a clean shutdown. Tests pin this fallback instead of assuming a +close-cancellation mechanism Silk does not provide. + +The lifetime state is explicit: `Active`, `RetryableIncomplete`, clean +`Complete`, `CompleteWithCleanupFailures`, or terminal `AbandonedIncomplete`. +Persistent soft-detach failure followed by otherwise complete teardown produces +`CompleteWithCleanupFailures`; repeated Dispose is inert and returns the same +immutable non-clean report. Hard-barrier retry is allowed only while the native +context is retained. Last-resort native disposal moves to +`AbandonedIncomplete`; every later Dispose is inert and reports the retained +blocked-stage result without touching GL, callbacks, or the already-destroyed +window. + +## 3. Architecture and interfaces + +### 3.1 Native window intake + +Add a focused reversible binding equivalent to: + +```csharp +internal sealed class SilkWindowCallbackBinding : IDisposable +{ + public static SilkWindowCallbackBinding Attach( + IWindow window, + WindowCallbackTargets targets, + DisplayFramePacingController pacing); +} +``` + +`WindowCallbackTargets` is a fixed typed set for Load, Update, Render, Closing, +FocusChanged, and FramebufferResize. It is not a general callback list. Attach +rolls back in reverse order if any registration fails; Dispose detaches in +reverse registration order, is idempotent/reentrant-safe, and makes later +window events inert. The separate pacing Render callback remains ordered after +main Render. Every callback first checks the shared no-throw host-quiescence +gate, so a failed physical unsubscribe cannot re-enter a retired owner. + +### 3.2 Input owners + +Use two focused owners: + +- `CameraPointerInputController` owns named raw MouseMove subscriptions, + pointer position, camera-mode cursor transitions, focus loss, typed scroll + operations, and the existing mode-specific sensitivities. Construction is + deliberately two-phase: `AttachRaw` runs after dispatcher/source wiring and + before retained `UiHost` wiring; `BindGameplayFrame` fills a focused deferred + slot after `GameplayInputFrameController` exists. It consumes canonical + `LocalPlayerModeState`, `ChaseCameraInputState`, input capture, and camera + owners. +- `GameplayInputActionRouter` is the **only** gameplay subscriber to + `InputDispatcher.Fired` and owns the frozen priority graph above. Its pointer + and scroll edges call typed operations on `CameraPointerInputController`; the + pointer owner does not independently subscribe to `Fired`. The router calls existing typed + retained-UI, selection, movement, player-mode, interaction, combat-command, + and diagnostic-command owners. + +The window binding, router Fired subscription, pointer raw MouseMove, +Combat/Camera events, dispatcher-to-source links, source-to-Silk links, and +retained-UI-to-Silk links must all be named and reversible. Shutdown's first +stage deactivates them without disposing the input context or the UI owners +needed by session reset. `UiHost` therefore exposes a separate idempotent input +deactivation seam; its later full Dispose owns windows and rendering. Do not +replace these edges with anonymous lambdas retained by a generic subscription +bag. + +Every wrapper on those edges—including Silk sources, dispatcher, raw pointer, +retained UiHost, and optional devtools input—checks the same quiescence gate (or +performs its no-throw logical deactivate in stage 1). No device callback can +re-enter during the potentially 35-second live-session close while physical +event removal waits for the following soft-detach stage. + +Every multi-event Attach is transactional: if the Nth subscription fails, the +already-attached prefix is removed in reverse order before the exception +escapes. This applies to native window, dispatcher/source/Silk, raw pointer, +Combat/Camera, retained `UiHost`, and optional devtools input bindings. + +### 3.3 Session and world environment + +- A focused App session host owns the current selection/entered/reset/binding + factories around `LiveSessionLifecycleHost`, `LiveSessionEventRouter`, and + `LiveSessionResetManifest`. It resolves the current session through + `LiveSessionController`; it does not mirror it. +- `WorldEnvironmentController` owns current loaded sky/day state, + `AdminEnvirons`, provider refresh, WorldTime synchronization, and Weather + changes. The existing retail day-group/weather algorithm moves verbatim. +- A named skill-credit resolver owns the formula formerly nested in the session + binding only after Checkpoint C verifies it against named retail; until then + the current ACE-only interpretation is not overclaimed as retail-faithful. + +### 3.4 Settings and diagnostics + +`RuntimeSettingsController` owns SettingsStore, active toon key, persisted +display/audio/gameplay/chat/character values, quality reapply, and UI +lock/FPS/combat preferences. It is constructed before `Window.Create` and is +the single source for one immutable startup snapshot and Display/VSync/MSAA. +After GL/input sources/input dispatcher/camera/audio exist—but still before +devtools, the world render dispatcher, terrain, retained UI, or streaming—it +applies window/pacing/audio values and resolves the final quality snapshot. +Later factories consume that resolved snapshot. A fixed +typed `RuntimeSettingsTargets` implementation is late-bound only to support +future runtime changes; binding it does not replay startup display or quality +transitions. The controller exposes typed state/commands to existing consumers; +it does not own those renderers, UI trees, or session state. + +`RuntimeDiagnosticCommandController` owns command routing for collision wires, +time/weather, sensitivity, and nearby-world dumps. State remains solely in its +canonical owner: time/weather operations call `WorldEnvironmentController`, +sensitivity calls `CameraPointerInputController.AdjustSensitivity`, and world +queries use canonical runtime views. It creates no duplicate state or entity +cache. Diagnostic algorithms do not remain in the window merely because their +input actions originate there. + +`FramebufferResizeController` is a separate GL-aware typed target. It owns the +frozen viewport → `ViewportAspectState` → camera aspect → devtools layout-reset +sequence and is not folded into the pointer owner. + +### 3.5 Frame-root slots + +A focused `GameFrameGraphSlot` is the sole publication point for the current +`UpdateFrameOrchestrator` and `RenderFrameOrchestrator`. The two Silk stubs +resolve through this slot; `GameWindowLifetime` withdraws it after session +convergence and before borrowed owners retire. Clearing a lifetime snapshot is +not withdrawal while a direct window field can still invoke the graph. + +### 3.6 Ordered composition + +`OnLoad` invokes the narrow production platform phase that owns +`GL.GetApi(_window)` and `_window.CreateInput()`, then invokes small composition +functions at these existing boundaries: + +1. host input/camera; +2. content/effects/audio; +3. persisted settings/devtools; +4. world/render resources; +5. interactions/retained UI; +6. live presentation/landblock publishers; +7. streaming/session/local player/teleport; +8. update/render roots; +9. session start. + +Each function receives only typed dependencies and may return a small immutable +result needed by the next function. There is no stored mega-context. Stable +owners are copied to locals before retained delegates are constructed so those +delegates cannot capture `GameWindow` accidentally. Construction cycles use +the existing focused deferred slots; they do not use callbacks into the window. + +The production ordering lives in a fixed `GameWindowCompositionPipeline` with +typed phase interfaces/factories. Tests invoke that same pipeline with fake +platform/acquisition phases; no test-only clone or source-text assertion stands +in for partial-load execution. A narrow production +`GameWindowPlatformAcquisition` phase is invoked by `OnLoad` and alone calls +`GL.GetApi(window)` and `window.CreateInput()` through injectable factories. It +publishes GL to the lifetime before attempting input, then publishes input +before later phases. Tests execute this exact method with fake factories, +including GL success followed by input failure. Each concrete phase uses a +transactional acquisition scope so failure after any internal acquisition +unwinds the exact prefix. + +Every acquired disposable is published to the lifetime owner immediately. A +composer that cannot publish incrementally must unwind its own partial prefix +before throwing. The portal-tunnel transfer remains explicit: publish fallback, +prepare GL resources, transfer to `LocalPlayerTeleportController`, then clear +the fallback before frame graphs are published. + +Retained UI uses one `RetailUiRuntimeLease` retained by `GameWindowLifetime` +from Host construction through final teardown. The lease initially owns +`UiHost`, retains a partially constructed `RetailUiRuntime` before Initialize, +and exposes the runtime as a borrower only after Mount succeeds. Constructor +failure leaves Host in the lease; Initialize/cleanup failure leaves the exact +partial runtime in the lease for later retry; success makes lease disposal call +runtime disposal. There is no fallback-to-runtime ownership gap and never two +independent host disposers. + +### 3.7 Lifetime owner + +`GameWindowLifetime` owns the one `ResourceShutdownTransaction` and typed +teardown-only roots. It does not expose runtime service lookup or gameplay +operations. It exists before `Window.Create`, so window/bootstrap acquisitions +can publish their ownership immediately. Its transaction preserves §2.5 and +fixes these audited gaps: + +- update and render orchestrators are both withdrawn; +- `TerrainAtlas` residency/textures are explicitly released after submitted + work and before GL; +- the dedicated sky `Shader` is explicitly disposed; +- a published `UiHost` is disposed even if `RetailUiRuntime.Mount` fails; +- window, dispatcher, pointer, combat, camera, and UI-root callbacks detach + before the owners they target. + +## 4. Detailed checkpoint execution + +### A — freeze and prune + +- Add structural freeze tests for the current startup/session-start, input, + resize, update/render, and shutdown boundaries; later checkpoints replace + these with functional owner tests rather than treating source checks as final + acceptance. +- Extend `ResourceShutdownTransactionTests` for multiple same-stage failures, + failure then explicit retry, empty stages, and reentrant completion. +- Delete only proven dead/test-facade residue: `_capturedMouse`, obsolete + `_streamingRadius`, unused snap constants, `IsPlayerGuid`, `IsDoorName`, and + test-only forwarding helpers. Tests call canonical owners directly. + +### B — native window binding and host quiescence + +- Implement exact attach/reverse-detach/rollback and the no-throw quiescence + gate. +- Replace direct `Run` event wiring with one owned binding while preserving the + two Render handlers and their order. +- Add post-detach silence, failure-after-Nth-attach, repeated/reentrant Dispose, + callback-during-detach, and persistent physical-detach tests. + +### C — live-session host and skill formula + +- Extract selection/entry/reset/binding factories around the existing canonical + session, router, command, and reset owners. +- Before moving the formula, write pseudocode for + `SkillFormula::Calculate @ 0x00591960`, cross-check the DAT field semantics + against ACE plus a second reference, then add conformance tests. If current + behavior differs, register it rather than silently calling the ACE-only + formula retail-faithful inside a structural commit. +- Keep `LiveSessionController.Start` last; both disabled-live and + missing-credential starts must execute the reset path. + +### D — world environment + +- Move loaded sky/day state, `RefreshSkyForCurrentDay`, `AdminEnvirons`, + WorldTime synchronization, provider swaps, and Weather changes verbatim into + `WorldEnvironmentController`. +- Preserve existing named-retail citations and registered sound adaptation; + diagnostics call typed commands rather than owning time/weather state. + +### E — pointer, focus, and framebuffer resize + +- Make SilkKeyboard/Mouse sources and dispatcher links reversible, then attach + the raw pointer owner in the existing pre-UiHost order. +- Late-bind the gameplay-frame slot without resubscribing or reordering input. +- Extract camera mode, lifecycle focus loss, scroll, and sensitivity state. +- Extract the independent framebuffer resize target and pin logical Window.Size + versus FramebufferSize behavior. +- Require rollback after every Nth event add and silence between every shutdown + stage for raw/retained/devtools device events. + +### F — gameplay action and command routing + +- Move the frozen action-priority graph into the sole Fired subscriber. +- Extract combat mode command behavior and diagnostic command routing to typed + owners; retained UI, selection, movement, player mode, and item interaction + remain canonical. +- Make Combat, Camera, dispatcher, and retained-root subscriptions named, + transactional, and symmetrically detached. + +### G — two-phase runtime settings + +- Construct `RuntimeSettingsController` before Window.Create and source startup + Display/VSync/MSAA from it. +- After GL/camera/audio and before devtools/world, apply startup + display/pacing/audio and resolve the immutable quality snapshot consumed by + downstream factories. +- Late-bind typed runtime targets without replaying startup transitions, then + move runtime display/quality changes, frame-rate/UI-lock/combat preferences, + and toon-scoped state. +- Preserve restart-required quality behavior and test transitions/previews + without requiring GL. + +### H — explicit single resource ownership and frame slots + +- Publish TerrainAtlas and the dedicated sky Shader as separate lifetime roots; + renderers remain borrowers and retire before the sole atlas/shader disposal. +- Publish `RetailUiRuntimeLease` before Host/Mount acquisition; add separate + idempotent input deactivation and test constructor failure, Initialize failure + with successful cleanup, transient cleanup followed by lifetime retry, + persistent cleanup followed by terminal abandonment, and exactly one Host + disposal/no unowned Host on every path. +- Publish update/render roots only through `GameFrameGraphSlot` and withdraw the + slot after session convergence. +- Pin portal fallback/transfer unchanged and test every normal/partial prefix + for no leak or double delete. +- Make `Shader` construction and `TerrainAtlas.Build` internally exception-safe; + phase scopes cannot recover GL names created inside a factory that throws. + Inject GL compile/link/texture-allocation failure after every internal name + and prove exact rollback/residency release. + +### I — ordered production composition + +- Split the 2,289-line `OnLoad` body at §3.6 boundaries using the fixed typed + production pipeline, small immutable phase results, and no stored mega-context. +- Remove retained GameWindow captures via stable locals, typed adapters, and + existing deferred slots. +- Run the same pipeline with fake acquisition phases and inject failure after + platform, GL, input, DAT/effects, render resources, retained UI, live/session, + teleport transfer, and frame-root publication. Each concrete phase also + proves transactional rollback of its internal acquisition prefix. + +### J — lifetime cutover and shutdown failure policy + +- Move the teardown-only stage records and shutdown manifest out of GameWindow. +- Co-stage no-throw quiescence and live-session convergence as hard operations; + retry/report physical detach as non-blocking operations, withdraw both frame + graphs next, then preserve the remaining frozen order. +- Cover constructed-never-run, Load-never-fired, normal WM_CLOSE, WM_CLOSE then + Dispose, direct/repeated/reentrant Dispose, transient/persistent failures, + session deferred disposal, optional-devtools absence, GPU-drain failure, and + every partial-load prefix. +- Verify acquired owners dispose exactly once, unacquired owners never dispose, + completed operations never replay, and native-window fallback is last and + explicitly reported incomplete when a non-cancellable close cannot converge. +- Verify persistent physical-detach failure still retires downstream owners and + is reported, while persistent session/GPU barriers still protect dependents; + after native fallback the terminal abandoned state makes repeated Dispose + inert. + +### K — canonical soak checkpoints (#232, separate commit) + +- Extract `WorldLifecycleResourceSnapshotSource` from the window and reuse the + existing render diagnostics source where fields overlap. +- Change `checkpoint ` from an immediate retained-UI-time capture to a + FIFO request with an acknowledgement token. The script runner treats that + token as a command barrier: it executes no later command until the token is + resolved. After private presentation returns and + `RenderFrameDiagnosticsController.Publish` has published the current frame, + a post-diagnostics checkpoint phase drains requests using that same immutable + `RenderFrameOutcome` plus one sample from every canonical source. This avoids + previous-frame diagnostics mixed with current-frame owner counts. +- Mark the token Succeeded only after serialization/write completes. Deferred + capture/write failure marks it Failed, and the runner stops with that exact + error on its next tick. Preserve request sequence/name across the deferred + edge, drain independent producers in FIFO order, and cancel pending tokens + with an explicit shutdown result. +- Store one token against the active script-command index. Repeated pending + ticks poll that token and never enqueue again—even when a render failed before + the post-diagnostics drain. Clear it only after Success, Failure, or + Cancellation is consumed; one command therefore produces one request, write, + and sequence increment. +- Add exactly nine ordered named `checkpoint` commands, parse the JSONL into the + soak report, and assert checkpoint identity/order. +- Gate pending teardown, retirement, staging, and warmup at zero; compare exact + canonical owner/cache counts and bytes at Caul return → Caul plateau; label + authoritative entity/animation population changes as workload warnings. +- Preserve process working/private memory as a separately labelled secondary + residency guard with unchanged thresholds. Require two fresh-process clean + route runs before closing #232. + +### L — final closeout + +- Run three independent corrected-diff reviews: retail conformance, + architecture/integration, and adversarial lifecycle/failure analysis. +- Resolve every confirmed finding and re-review until clean. +- Run focused App/Core/UI tests, Release build, and the complete Release suite. +- Run connected lifecycle/reconnect and two fresh-process synchronized + nine-stop soaks; compare stable framebuffer checkpoints with Slice 7. +- Audit every active divergence row that still points at `GameWindow`; retarget + ownership paths only. Keep TS-53 and other unresolved mechanisms open. +- Update architecture, milestones, roadmap, issues, AGENTS/CLAUDE, and durable + memory with final metrics and exact gates. + +## 5. Automated acceptance + +- exact window callback attach order, reverse detach, rollback, reentrancy, and + post-detach silence; +- transactional rollback after every Nth add for all multi-event bindings, plus + retained/devtools/raw device silence between every shutdown stage; +- exact input subscription/priority order and focus/reset/close semantics; +- exact resize order and non-positive-size rejection; +- disabled-live and missing-credentials startup still perform reset; live + startup remains last; +- PView still reads FramebufferSize while RenderFrameInput, retained UI, + portal/private presentation, and screenshots share one Window.Size snapshot; +- exact session reset trace and current-session borrowing; +- partial-load rollback at every composition checkpoint; +- TerrainAtlas, sky Shader, UiHost, frame graphs, GPU-flight, DAT, input, GL, + and native window have explicit tested lifetimes; terrain/sky renderers remain + borrowers and cannot also dispose the separately owned atlas/shader; +- throwing Shader/TerrainAtlas leaf factories release every internal GL name + and bindless-residency prefix; +- shutdown retry/all-attempted/no-replay semantics remain intact; +- persistent non-cancellable close fallback names the blocked stage and never + reports a clean shutdown; +- completed teardown with soft-detach failures reports immutable + `CompleteWithCleanupFailures`, and repeated Dispose is inert; +- no anonymous MouseMove subscription and no substantial Silk callback body; +- no stored `GameWindow`, broad service locator, or feature-owner callback + facade into the window; the fixed native `WindowCallbackTargets` binding is + the intentional host-boundary exception; +- `OnLoad` is ordered composition, `OnUpdate`/`OnRender` remain one handoff, + and `OnClosing`/`Dispose` delegate to one lifetime owner; +- all canonical soak snapshots are post-diagnostics same-frame atomic, + round-trip, preserve request/name order across the deferred edge and nine-name + order across two fresh processes, and name the exact owner/property on growth; +- checkpoint acknowledgement blocks following script commands, propagates + deferred write failure, and reports shutdown cancellation; +- repeated pending ticks enqueue exactly once and cannot duplicate a checkpoint + after a failed/delayed render; +- complete Release suite, lifecycle gate, soak gate, and framebuffer comparison + pass. + +## 6. Final visual handoff + +The only user pause is one connected visual smoke gate after all automation is +green: + +- first login world bootstrap and radar; +- movement, mouse look/orbit, resize, focus loss/return, combat toggle; +- inventory/skills/spellbook shared panel and retained UI input; +- outdoor, building, dungeon, portal/recall, paperdoll, particles, alpha; +- graceful close and fresh-process reconnect. + +After that pass the GameWindow structural campaign is complete. The roadmap +returns to the carried M3 magic/spell-bar/spellbook/component-book and final +two-client portal-out/materialization visual gates before new M4 feature work. diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index c10261c2..130392b4 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -100,6 +100,8 @@ Slice 8 groups composition and shutdown wiring and reduces Silk callbacks to narrow calls into input, update, render, resize, focus, and shutdown owners. It removes leftover feature state only where Slices 1–7 already established a canonical owner; it does not redesign gameplay, rendering, or resource lifetime. +The reviewed A–L execution ledger is +`docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md`. ## Do not retry From 476c2e6de1df057b71f7349c58de6a607110b7c7 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 09:27:55 +0200 Subject: [PATCH 91/98] test(app): freeze GameWindow lifecycle boundaries Pin the accepted startup, input, frame, resize, shutdown, and native-window order before Slice 8 moves those edges, while deleting only unread duplicate state and test-only GameWindow facades. Co-authored-by: Codex --- ...amewindow-slice-8-composition-lifecycle.md | 8 +- src/AcDream.App/Rendering/GameWindow.cs | 43 --- .../LocalPlayerOutboundCombatStyleTests.cs | 5 +- .../Input/PlayerMouseLookMovementTests.cs | 5 +- .../GameWindowSlice8BoundaryTests.cs | 277 ++++++++++++++++++ .../ResourceShutdownTransactionTests.cs | 81 +++++ .../Vfx/EntityScriptActivatorTests.cs | 17 +- 7 files changed, 380 insertions(+), 56 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index e42161a5..61eccef9 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -14,7 +14,7 @@ audit. ## Progress ledger -- [ ] A — freeze construction/callback/shutdown order and delete proven dead or +- [x] A — freeze construction/callback/shutdown order and delete proven dead or test-facade residue. - [ ] B — make native-window callback intake an explicit reversible owner and define host-quiescence failure semantics. @@ -401,6 +401,12 @@ fixes these audited gaps: `_streamingRadius`, unused snap constants, `IsPlayerGuid`, `IsDoorName`, and test-only forwarding helpers. Tests call canonical owners directly. +Result: the exact native attributes/callbacks, input subscription priority, +frame-root/session-start boundary, framebuffer behavior, shutdown stages, and +native-window-last edge are frozen. Dead duplicate state and two test-only +window facades are removed. Three corrected-diff reviews are clean; 56 focused +tests and the complete App suite (2,991 pass / 3 intentional skips) pass. + ### B — native window binding and host quiescence - Implement exact attach/reverse-detach/rollback and the no-throw quiescence diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8161ebe6..e94cbb6d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -30,7 +30,6 @@ public sealed class GameWindow : IDisposable /// at draw time but allocated + disposed here. private Shader? _terrainModernShader; private CameraController? _cameraController; - private IMouse? _capturedMouse; private IDatReaderWriter? _dats; private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new(); private float _lastMouseX @@ -118,7 +117,6 @@ public sealed class GameWindow : IDisposable _localPlayerTeleportSink = new(); private AcDream.App.Streaming.LocalPlayerTeleportController? _localPlayerTeleport; - private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange = new(nearRadius: 4, farRadius: 12); private int _nearRadius @@ -317,37 +315,6 @@ public sealed class GameWindow : IDisposable private AcDream.App.World.LiveEntityRuntime? _liveEntities; private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness; - /// Soft-snap decay rate (1/sec). At this rate the residual - /// halves every 1/rate seconds. 8.0 → ~100ms half-life, so even a - /// 2m residual fades within ~300ms without visible snap. - private const float SnapResidualDecayRate = 8.0f; - /// - /// When the prediction error exceeds this many meters, we treat the - /// update as a teleport / rubber-band and hard-snap (no soft lerp). - /// Prevents the soft-snap logic from trying to smooth a genuine portal - /// or force-move event. - /// - /// - /// Matches retail's GetAutonomyBlipDistance (ACE - /// PhysicsObj.cs:545): 20m for creatures, 25m for players. - /// We use 20m as a conservative default — any delta larger than this - /// must be a teleport (portal, recall, spawn). A running character - /// with 1-second UpdatePosition cadence at 9.5 m/s produces deltas - /// of ~9.5m, well below this threshold, so normal movement flows - /// through the interpolation queue instead of hard-snapping. - /// - /// - private const float SnapHardSnapThreshold = 20.0f; - - /// - /// Soft-snap window in seconds: after an UpdatePosition arrives for a - /// remote entity, dead-reckoning continues but the "origin" for - /// predicted position is the server pos. This matches retail's snap - /// behavior — the server is authoritative, we just interpolate between - /// authoritative samples. - /// - private const float DeadReckonMaxPredictSeconds = 1.0f; - // Phase F.1-H.1 — client-side state classes fed by GameEventWiring. // Exposed publicly so plugins + UI panels can bind directly. public readonly AcDream.Core.Chat.ChatLog Chat = new(); @@ -611,8 +578,6 @@ public sealed class GameWindow : IDisposable // path. Populated for remotes plus the PlayerModeController local entry // (player); pruned only by logical LiveEntityRuntime teardown. - private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; - private static bool IsDoorName(string? name) => name == "Door"; // ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184 // Slice 2a — the DR tick's stale-velocity anim-stop was its only user). public GameWindow( @@ -2349,7 +2314,6 @@ public sealed class GameWindow : IDisposable if (_options.LegacyStreamRadius is { } sr) { _nearRadius = sr; - _streamingRadius = sr; // keep debug overlay in sync _farRadius = System.Math.Max(sr, _farRadius); } Console.WriteLine( @@ -3340,8 +3304,6 @@ public sealed class GameWindow : IDisposable session.SendTurbineChatTo( roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) => - entity.Id; /// /// Phase 5d — retail AdminEnvirons (0xEA60) dispatcher. /// Routes fog presets into the weather system's sticky override @@ -3427,7 +3389,6 @@ public sealed class GameWindow : IDisposable // held is handled separately by the gameplay input owner. bool needsRawCursor = _cameraController?.IsFlyMode == true; mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal; - _capturedMouse = needsRawCursor ? mouse : null; } // Performance overlay state — updated every ~0.5s and written to the @@ -4217,10 +4178,6 @@ public sealed class GameWindow : IDisposable _debugVm?.AddToast(text); } - internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState( - AcDream.App.Input.MovementResult result) => - AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result); - /// /// Item-target-mode world pick at the current cursor. The renderer supplies the /// exact visible CPhysicsPart equivalents and RetailWorldPicker performs diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerOutboundCombatStyleTests.cs b/tests/AcDream.App.Tests/Input/LocalPlayerOutboundCombatStyleTests.cs index 2dfa18fa..1103bb6b 100644 --- a/tests/AcDream.App.Tests/Input/LocalPlayerOutboundCombatStyleTests.cs +++ b/tests/AcDream.App.Tests/Input/LocalPlayerOutboundCombatStyleTests.cs @@ -1,6 +1,5 @@ using System.Buffers.Binary; using AcDream.App.Input; -using AcDream.App.Rendering; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; using AcDream.Core.Physics; @@ -21,7 +20,7 @@ public sealed class LocalPlayerOutboundCombatStyleTests MovementResult movement = controller.CaptureMovementResult( mouseLookEvent: false); - RawMotionState outbound = GameWindow.BuildOutboundRawMotionState( + RawMotionState outbound = LocalPlayerOutboundController.BuildRawMotionState( movement); Assert.Equal(combatStyle, movement.CurrentStyle); @@ -43,7 +42,7 @@ public sealed class LocalPlayerOutboundCombatStyleTests { var controller = new PlayerMovementController(new PhysicsEngine()); - RawMotionState outbound = GameWindow.BuildOutboundRawMotionState( + RawMotionState outbound = LocalPlayerOutboundController.BuildRawMotionState( controller.CaptureMovementResult(mouseLookEvent: false)); Assert.Equal(RawMotionState.Default.CurrentStyle, outbound.CurrentStyle); diff --git a/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs b/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs index a93c51ec..c0946d21 100644 --- a/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs +++ b/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs @@ -1,6 +1,5 @@ using System.Numerics; using AcDream.App.Input; -using AcDream.App.Rendering; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using Xunit; @@ -259,7 +258,7 @@ public sealed class PlayerMouseLookMovementTests controller.BeginMouseLook(new MovementInput(Run: true)); MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false); - RawMotionState wire = GameWindow.BuildOutboundRawMotionState(press); + RawMotionState wire = LocalPlayerOutboundController.BuildRawMotionState(press); Assert.Equal(HoldKey.Run, controller.Motion.RawState.CurrentHoldKey); Assert.True(press.IsRunning); @@ -325,7 +324,7 @@ public sealed class PlayerMouseLookMovementTests controller.BeginMouseLook(held); MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false); - RawMotionState wire = GameWindow.BuildOutboundRawMotionState(press); + RawMotionState wire = LocalPlayerOutboundController.BuildRawMotionState(press); Assert.Equal(MotionCommand.SideStepRight, press.SidestepCommand); Assert.True(press.SidestepUsesRunHold); diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs new file mode 100644 index 00000000..f466e66b --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -0,0 +1,277 @@ +namespace AcDream.App.Tests.Rendering; + +/// +/// Temporary source-shape freeze for the Slice 8 cutover. Each later checkpoint +/// replaces the corresponding assertion with functional tests on the extracted +/// owner; this file prevents a mechanical move from silently reordering host +/// behavior before that owner exists. +/// +public sealed class GameWindowSlice8BoundaryTests +{ + [Fact] + public void Run_PreservesNativeAttributeAndCallbackOrder() + { + string body = MethodBody("public void Run()", "private void OnLoad()"); + + AssertAppearsInOrder( + body, + "startupStore.LoadDisplay()", + "QualitySettings.WithEnvOverrides(startupBase)", + "_displayFramePacing.InitializeStartup(startupDisplay.VSync)", + "VSync = startupPacing.UseVSync", + "Samples = startupQuality.MsaaSamples", + "Window.Create(options)", + "_displayFramePacing.BindSurface(", + "_window.Load += OnLoad;", + "_window.Update += OnUpdate;", + "_window.Render += OnRender;", + "_window.Render += _displayFramePacing.OnFrameRendered;", + "_window.Closing += OnClosing;", + "_window.FocusChanged += OnFocusChanged;", + "_window.Move += _displayFramePacing.OnWindowMoved;", + "_window.StateChanged += _displayFramePacing.OnWindowStateChanged;", + "_window.FramebufferResize += OnFramebufferResize;", + "_window.Run();"); + + string[] registrations = + [ + "_window.Load += OnLoad;", + "_window.Update += OnUpdate;", + "_window.Render += OnRender;", + "_window.Render += _displayFramePacing.OnFrameRendered;", + "_window.Closing += OnClosing;", + "_window.FocusChanged += OnFocusChanged;", + "_window.Move += _displayFramePacing.OnWindowMoved;", + "_window.StateChanged += _displayFramePacing.OnWindowStateChanged;", + "_window.FramebufferResize += OnFramebufferResize;", + ]; + foreach (string registration in registrations) + Assert.Equal(1, CountOccurrences(body, registration)); + } + + [Fact] + public void SessionStart_FollowsFrameGraphPublication() + { + string body = MethodBody( + "private void OnLoad()", + "private AcDream.App.Net.LiveSessionLifecycleHost"); + + AssertAppearsInOrder( + body, + "_gl = GL.GetApi(_window!);", + "_input = _window!.CreateInput();", + "_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);", + "_mouseSource = new AcDream.App.Input.SilkMouseSource(", + "_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(", + "_movementInput.Bind(_inputDispatcher);", + "_cameraInput.Bind(_inputDispatcher);", + "_inputDispatcher.Fired += OnInputAction;", + "mouse.MouseMove +=", + "_cameraController = new CameraController(orbit, fly);", + "_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);", + "LoadAndApplyPersistedSettings();", + "_uiHost = new AcDream.App.UI.UiHost(", + "_uiHost.WireMouse(m)", + "_uiHost.WireKeyboard(kb)", + "_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(", + "_liveEntities = new AcDream.App.World.LiveEntityRuntime(", + "_renderFrameOrchestrator =", + "_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(", + "_liveSessionController.Start("); + Assert.Equal(1, CountOccurrences(body, "_liveSessionController.Start(")); + int start = body.IndexOf("_liveSessionController.Start(", StringComparison.Ordinal); + string postStart = body[start..]; + Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal); + Assert.Empty(System.Text.RegularExpressions.Regex.Matches( + postStart, + @"(?m)^\s+_[A-Za-z]\w*\s*(?:\?\?=|=)")); + Assert.DoesNotContain("new ", postStart, StringComparison.Ordinal); + Assert.DoesNotContain(".Mount(", postStart, StringComparison.Ordinal); + Assert.DoesNotContain(".Bind(", postStart, StringComparison.Ordinal); + Assert.DoesNotContain(".Attach(", postStart, StringComparison.Ordinal); + Assert.DoesNotContain(".Compose(", postStart, StringComparison.Ordinal); + Assert.DoesNotContain("PrepareResources(", postStart, StringComparison.Ordinal); + } + + [Fact] + public void InputAction_PreservesRetailAcceptedPriority() + { + string body = MethodBody( + "private void OnInputAction(", + "private void ToggleLiveCombatMode()"); + + AssertAppearsInOrder( + body, + "HandlePointerAction(action, activation)", + "action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp", + "HandleScrollAction(action);", + "HandleCombatAction(action, activation)", + "activation != AcDream.UI.Abstractions.Input.ActivationType.Press", + "_retailUiRuntime?.HandleInputAction(action)", + "_selectionInteractions?.HandleInputAction(action)", + "HandlePressedMovementAction(action)", + "switch (action)"); + } + + [Fact] + public void FramebufferResize_PreservesViewportCameraAndDevtoolsOrder() + { + string body = MethodBody( + "private void OnFramebufferResize(", + "private void ApplyDisplayWindowState("); + + AssertAppearsInOrder( + body, + "if (newSize.X <= 0 || newSize.Y <= 0) return;", + "_gl?.Viewport(", + "_viewportAspect.Update(", + "_cameraController?.SetAspect(", + "_devToolsFramePresenter?.ResetLayout("); + Assert.DoesNotContain("_uiHost", body, StringComparison.Ordinal); + } + + [Fact] + public void UpdateRenderFocusAndCloseRemainNarrowHostEdges() + { + string source = GameWindowSource(); + string update = Slice( + source, + "private void OnUpdate(double dt)", + "private void OnCameraModeChanged("); + string render = Slice( + source, + "private void OnRender(double deltaSeconds)", + "private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot"); + string focus = Slice( + source, + "private void OnFocusChanged(bool focused)", + "public void Dispose()"); + string close = Slice( + source, + "private void OnClosing()", + "private ResourceShutdownTransaction CreateShutdownTransaction()"); + + Assert.Equal(1, CountOccurrences(update, "_updateFrameOrchestrator.Tick(")); + Assert.Equal(1, CountOccurrences(render, "_renderFrameOrchestrator!.Render(")); + AssertAppearsInOrder( + render, + "Vector2D size = _window!.Size;", + "_renderFrameOrchestrator!.Render(", + "new AcDream.App.Rendering.RenderFrameInput("); + Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal); + AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();"); + AssertAppearsInOrder(close, "=> CompleteShutdown();", "_shutdown.CompleteOrThrow();"); + } + + [Fact] + public void Shutdown_PreservesDependencyStagesAndNativeWindowLast() + { + string source = GameWindowSource(); + string manifest = Slice( + source, + "private ResourceShutdownTransaction CreateShutdownTransaction()", + "private void OnFocusChanged(bool focused)"); + string dispose = Slice( + source, + "public void Dispose()", + "private sealed class NullAnimLoader"); + + string[] stages = + [ + "new ResourceShutdownStage(\"session lifetime\"", + "new ResourceShutdownStage(\"frame borrowers\"", + "new ResourceShutdownStage(\"session dependents\"", + "new ResourceShutdownStage(\"live entities\"", + "new ResourceShutdownStage(\"live entity dependents\"", + "new ResourceShutdownStage(\"submitted GPU work\"", + "new ResourceShutdownStage(\"render frontends\"", + "new ResourceShutdownStage(\"shared texture owners\"", + "new ResourceShutdownStage(\"mesh adapter\"", + "new ResourceShutdownStage(\"remaining render owners\"", + "new ResourceShutdownStage(\"frame flight owner\"", + "new ResourceShutdownStage(\"content mappings\"", + "new ResourceShutdownStage(\"input\"", + "new ResourceShutdownStage(\"OpenGL context\"", + ]; + AssertAppearsInOrder(manifest, stages); + Assert.Equal(stages.Length, CountOccurrences(manifest, "new ResourceShutdownStage(")); + foreach (string stage in stages) + Assert.Equal(1, CountOccurrences(manifest, stage)); + AssertAppearsInOrder( + dispose, + "CompleteShutdown();", + "_window?.Dispose();", + "_window = null;"); + Assert.Equal(1, CountOccurrences(dispose, "CompleteShutdown();")); + Assert.Equal(1, CountOccurrences(dispose, "_window?.Dispose();")); + Assert.Equal(1, CountOccurrences(dispose, "_window = null;")); + int nativeReleased = dispose.IndexOf("_window = null;", StringComparison.Ordinal) + + "_window = null;".Length; + string afterNativeRelease = dispose[nativeReleased..]; + int fallbackDocumentation = afterNativeRelease.IndexOf( + "/// ", + StringComparison.Ordinal); + if (fallbackDocumentation >= 0) + afterNativeRelease = afterNativeRelease[..fallbackDocumentation]; + Assert.All( + afterNativeRelease, + character => Assert.True(char.IsWhiteSpace(character) || character == '}')); + } + + private static string MethodBody(string start, string end) => + Slice(GameWindowSource(), start, end); + + private static string Slice(string source, string start, string end) + { + int first = source.IndexOf(start, StringComparison.Ordinal); + int last = source.IndexOf(end, first + 1, StringComparison.Ordinal); + Assert.True(first >= 0, $"Missing source boundary: {start}"); + Assert.True(last > first, $"Missing source boundary: {end}"); + return source[first..last]; + } + + private static int CountOccurrences(string source, string value) + { + int count = 0; + int cursor = 0; + while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0) + { + count++; + cursor += value.Length; + } + + return count; + } + + private static void AssertAppearsInOrder(string source, params string[] fragments) + { + int cursor = -1; + foreach (string fragment in fragments) + { + int next = source.IndexOf(fragment, cursor + 1, StringComparison.Ordinal); + Assert.True(next >= 0, $"Missing expected source fragment: {fragment}"); + Assert.True(next > cursor, $"Out-of-order source fragment: {fragment}"); + cursor = next; + } + } + + private static string GameWindowSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ResourceShutdownTransactionTests.cs b/tests/AcDream.App.Tests/Rendering/ResourceShutdownTransactionTests.cs index 3bdfcfc9..20e56a53 100644 --- a/tests/AcDream.App.Tests/Rendering/ResourceShutdownTransactionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ResourceShutdownTransactionTests.cs @@ -78,4 +78,85 @@ public sealed class ResourceShutdownTransactionTests Assert.Equal(1, calls); Assert.True(transaction.IsComplete); } + + [Fact] + public void PersistentFailuresInOneStageAreAllAttemptedAndNamed() + { + int firstCalls = 0; + int secondCalls = 0; + var transaction = new ResourceShutdownTransaction( + new ResourceShutdownStage("owners", + [ + new("first", () => + { + firstCalls++; + throw new InvalidOperationException("first failure"); + }), + new("second", () => + { + secondCalls++; + throw new InvalidOperationException("second failure"); + }), + ])); + + AggregateException error = Assert.Throws( + transaction.CompleteOrThrow); + + Assert.True(firstCalls > 0); + Assert.Equal(firstCalls, secondCalls); + Assert.Contains( + error.InnerExceptions, + exception => exception.Message.Contains("first", StringComparison.Ordinal)); + Assert.Contains( + error.InnerExceptions, + exception => exception.Message.Contains("second", StringComparison.Ordinal)); + Assert.False(transaction.IsComplete); + } + + [Fact] + public void ExplicitRetryAfterFailedCompletionDoesNotReplaySuccessfulOperations() + { + int completeCalls = 0; + int pendingCalls = 0; + bool allowPending = false; + var transaction = new ResourceShutdownTransaction( + new ResourceShutdownStage("owners", + [ + new("complete", () => completeCalls++), + new("pending", () => + { + pendingCalls++; + if (!allowPending) + throw new InvalidOperationException("not yet"); + }), + ])); + + Assert.Throws(transaction.CompleteOrThrow); + int pendingCallsBeforeRetry = pendingCalls; + allowPending = true; + + transaction.CompleteOrThrow(); + + Assert.Equal(1, completeCalls); + Assert.Equal(pendingCallsBeforeRetry + 1, pendingCalls); + Assert.True(transaction.IsComplete); + } + + [Fact] + public void EmptyStagesConvergeWithoutSkippingLaterOperations() + { + int calls = 0; + var transaction = new ResourceShutdownTransaction( + new ResourceShutdownStage("empty", []), + new ResourceShutdownStage("owner", + [ + new("owner", () => calls++), + ]), + new ResourceShutdownStage("empty tail", [])); + + transaction.CompleteOrThrow(); + + Assert.Equal(1, calls); + Assert.True(transaction.IsComplete); + } } diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs index 65c76a20..7af60e5e 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Numerics; -using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; using AcDream.Core.Physics; using AcDream.Core.Vfx; @@ -472,7 +471,6 @@ public sealed class EntityScriptActivatorTests Assert.Contains("globally unique", error.Message, StringComparison.Ordinal); Assert.Equal(1, p.Runner.ActiveOwnerCount); - Assert.Equal(first.Id, GameWindow.ParticleEntityKey(first)); } [Fact] @@ -569,8 +567,13 @@ public sealed class EntityScriptActivatorTests [Fact] public void LiveParticleFilterUsesCanonicalLocalEffectOwnerNotServerGuid() { - var p = BuildPipeline(); - var activator = new EntityScriptActivator(p.Runner, p.Sink, p.Poses, _ => null); + var p = BuildPipeline( + (0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); + var activator = new EntityScriptActivator( + p.Runner, + p.Sink, + p.Poses, + StaticResolver(0xAAu)); var entity = new WorldEntity { Id = 1_000_123u, @@ -582,9 +585,11 @@ public sealed class EntityScriptActivatorTests }; activator.OnCreate(entity); + p.Runner.Tick(0.001f); - Assert.Equal(entity.Id, GameWindow.ParticleEntityKey(entity)); - Assert.NotEqual(entity.ServerGuid, GameWindow.ParticleEntityKey(entity)); + Assert.Single(p.Recording.Calls); + Assert.Equal(entity.Id, p.Recording.Calls[0].EntityId); + Assert.NotEqual(entity.ServerGuid, p.Recording.Calls[0].EntityId); } [Fact] From 18d4b999de65a7b36a693544808c2fdd3809b4bc Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 09:59:10 +0200 Subject: [PATCH 92/98] refactor(app): own native window callback lifetime Publish the fixed Silk callback binding before acquisition, quiesce admitted callbacks before teardown, and retain failed physical detach ownership for retry. Preserve the frozen callback order while covering partial event accessors, concurrency, reentrancy, and shutdown completion. Co-authored-by: Codex --- ...amewindow-slice-8-composition-lifecycle.md | 38 +- src/AcDream.App/Rendering/GameWindow.cs | 51 +- .../Rendering/HostQuiescenceGate.cs | 55 ++ .../Rendering/SilkWindowCallbackBinding.cs | 413 +++++++++ .../GameWindowSlice8BoundaryTests.cs | 51 +- .../Rendering/HostQuiescenceGateTests.cs | 57 ++ .../SilkWindowCallbackBindingTests.cs | 801 ++++++++++++++++++ 7 files changed, 1416 insertions(+), 50 deletions(-) create mode 100644 src/AcDream.App/Rendering/HostQuiescenceGate.cs create mode 100644 src/AcDream.App/Rendering/SilkWindowCallbackBinding.cs create mode 100644 tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index 61eccef9..b60e4e37 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -16,7 +16,7 @@ audit. - [x] A — freeze construction/callback/shutdown order and delete proven dead or test-facade residue. -- [ ] B — make native-window callback intake an explicit reversible owner and +- [x] B — make native-window callback intake an explicit reversible owner and define host-quiescence failure semantics. - [ ] C — extract live-session host/reset/binding callbacks and verify the embedded skill formula against named retail. @@ -220,20 +220,31 @@ Add a focused reversible binding equivalent to: ```csharp internal sealed class SilkWindowCallbackBinding : IDisposable { - public static SilkWindowCallbackBinding Attach( + public static SilkWindowCallbackBinding Create( IWindow window, WindowCallbackTargets targets, DisplayFramePacingController pacing); + + public void Attach(); } ``` `WindowCallbackTargets` is a fixed typed set for Load, Update, Render, Closing, -FocusChanged, and FramebufferResize. It is not a general callback list. Attach -rolls back in reverse order if any registration fails; Dispose detaches in -reverse registration order, is idempotent/reentrant-safe, and makes later -window events inert. The separate pacing Render callback remains ordered after -main Render. Every callback first checks the shared no-throw host-quiescence -gate, so a failed physical unsubscribe cannot re-enter a retired owner. +FocusChanged, and FramebufferResize. It is not a general callback list. The +binding is published into its lifetime slot after `Create` and before `Attach` +begins, so even an attach failure plus rollback failure retains a cleanup owner. +Attach treats the current edge as possibly acquired before calling a custom +event add accessor and rolls back in reverse order if registration fails. +The explicit lifecycle state monitor is released around every external event +accessor; external cleanup waits for Attaching/Detaching to converge, while a +gate-owned reentrant cleanup reports typed deferred completion instead of +deadlocking or falsely succeeding. Dispose detaches in reverse registration +order, joins concurrent callers, is idempotent/reentrant-safe, and makes later +window events inert. `GameWindow` clears the retained slot only after terminal +disposal. The separate pacing Render callback remains ordered after main Render. +Every callback enters the shared no-throw host-quiescence monitor, so external +shutdown drains an admitted callback, Closing can stop reentrantly, and a failed +physical unsubscribe cannot re-enter a retired owner. ### 3.2 Input owners @@ -416,6 +427,17 @@ tests and the complete App suite (2,991 pass / 3 intentional skips) pass. - Add post-detach silence, failure-after-Nth-attach, repeated/reentrant Dispose, callback-during-detach, and persistent physical-detach tests. +Result: one fixed typed binding now owns the exact nine Silk edges, including +the two ordered Render callbacks. Create/publish/Attach and the explicit +lifecycle state preserve cleanup ownership across partial event-accessor +failure, rollback failure, concurrent or reentrant shutdown, and condition +wakeups. The shared host gate drains admitted callbacks and makes failed +physical detach logically inert. Three corrected-diff review loops are clean; +54 focused tests, the App suite (3,027 pass / 3 intentional skips), and the full +Release suite (7,386 pass / 5 intentional skips) pass. No connected gate was +required because this checkpoint changes ownership only and preserves the +frozen callback behavior. + ### C — live-session host and skill formula - Extract selection/entry/reset/binding factories around the existing canonical diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e94cbb6d..b1cefe90 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -22,7 +22,9 @@ public sealed class GameWindow : IDisposable private readonly WorldGameState _worldGameState; private readonly WorldEvents _worldEvents; private readonly AcDream.Core.Selection.SelectionState _selection; + private readonly HostQuiescenceGate _hostQuiescence = new(); private IWindow? _window; + private SilkWindowCallbackBinding? _windowCallbacks; private GL? _gl; private IInputContext? _input; private TerrainModernRenderer? _terrain; @@ -667,23 +669,20 @@ public sealed class GameWindow : IDisposable _window = Window.Create(options); _displayFramePacing.BindSurface( new SilkDisplayFramePacingSurface(_window)); - _window.Load += OnLoad; - _window.Update += OnUpdate; - _window.Render += OnRender; - // Registered after OnRender so software pacing waits after all frame - // work and before Silk.NET performs its automatic buffer swap. - _window.Render += _displayFramePacing.OnFrameRendered; - _window.Closing += OnClosing; - _window.FocusChanged += OnFocusChanged; - _window.Move += _displayFramePacing.OnWindowMoved; - _window.StateChanged += _displayFramePacing.OnWindowStateChanged; - // L.0 Display tab: keep the GL viewport + camera aspect in sync - // with the window framebuffer. Without this handler, resizing - // the window (or applying a Display-tab Resolution change at - // startup) leaves the viewport pinned to the original size — - // user sees a small render in the corner of a big window. - _window.FramebufferResize += OnFramebufferResize; - + // The fixed binding preserves main Render before post-render pacing, + // owns rollback/reverse-detach, and gates every native entry point. + _windowCallbacks = SilkWindowCallbackBinding.Create( + _window, + new WindowCallbackTargets( + OnLoad, + OnUpdate, + OnRender, + OnClosing, + OnFocusChanged, + OnFramebufferResize), + _displayFramePacing, + _hostQuiescence); + _windowCallbacks.Attach(); _window.Run(); } @@ -4375,10 +4374,14 @@ public sealed class GameWindow : IDisposable } private void OnClosing() - => CompleteShutdown(); + { + _hostQuiescence.StopAccepting(); + CompleteShutdown(); + } private void CompleteShutdown() { + _hostQuiescence.StopAccepting(); _shutdown ??= CreateShutdownTransaction(); try { @@ -4574,6 +4577,18 @@ public sealed class GameWindow : IDisposable ]), new ResourceShutdownStage("input", [ + new("native window callbacks", () => + { + SilkWindowCallbackBinding? binding = _windowCallbacks; + if (binding is null) + return; + + binding.Dispose(); + if (!binding.IsDisposalComplete) + throw new NativeWindowCallbackCleanupDeferredException(); + if (ReferenceEquals(_windowCallbacks, binding)) + _windowCallbacks = null; + }), new("combat input subscription", () => Combat.CombatModeChanged -= SetInputCombatScope), new("input context", () => diff --git a/src/AcDream.App/Rendering/HostQuiescenceGate.cs b/src/AcDream.App/Rendering/HostQuiescenceGate.cs new file mode 100644 index 00000000..8685e173 --- /dev/null +++ b/src/AcDream.App/Rendering/HostQuiescenceGate.cs @@ -0,0 +1,55 @@ +namespace AcDream.App.Rendering; + +/// +/// Single logical admission gate for callbacks entering the application host. +/// Physical event removal remains important for ownership, but closing this +/// gate first makes a still-attached native callback inert while teardown +/// converges. +/// +internal sealed class HostQuiescenceGate +{ + private readonly object _sync = new(); + private bool _accepting = true; + + public bool IsAccepting + { + get + { + lock (_sync) + return _accepting; + } + } + + internal bool IsEnteredByCurrentThread => Monitor.IsEntered(_sync); + + /// + /// Closes admission after any callback already executing on another + /// thread has returned. Monitor reentrancy lets the native Closing + /// callback close its own gate without waiting on itself. + /// + public void StopAccepting() + { + lock (_sync) + _accepting = false; + } + + public void Invoke(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); + lock (_sync) + { + if (_accepting) + callback(); + } + } + + public void Invoke(Action callback, T value) + { + ArgumentNullException.ThrowIfNull(callback); + lock (_sync) + { + if (_accepting) + callback(value); + } + } +} diff --git a/src/AcDream.App/Rendering/SilkWindowCallbackBinding.cs b/src/AcDream.App/Rendering/SilkWindowCallbackBinding.cs new file mode 100644 index 00000000..73081938 --- /dev/null +++ b/src/AcDream.App/Rendering/SilkWindowCallbackBinding.cs @@ -0,0 +1,413 @@ +using Silk.NET.Maths; +using Silk.NET.Windowing; + +namespace AcDream.App.Rendering; + +internal sealed class NativeWindowCallbackCleanupDeferredException() + : InvalidOperationException( + "Native window callback cleanup was requested reentrantly and remains pending."); + +/// Fixed application callbacks accepted from the native window. +internal sealed class WindowCallbackTargets +{ + public WindowCallbackTargets( + Action load, + Action update, + Action render, + Action closing, + Action focusChanged, + Action> framebufferResize) + { + Load = load ?? throw new ArgumentNullException(nameof(load)); + Update = update ?? throw new ArgumentNullException(nameof(update)); + Render = render ?? throw new ArgumentNullException(nameof(render)); + Closing = closing ?? throw new ArgumentNullException(nameof(closing)); + FocusChanged = focusChanged ?? throw new ArgumentNullException(nameof(focusChanged)); + FramebufferResize = framebufferResize + ?? throw new ArgumentNullException(nameof(framebufferResize)); + } + + public Action Load { get; } + public Action Update { get; } + public Action Render { get; } + public Action Closing { get; } + public Action FocusChanged { get; } + public Action> FramebufferResize { get; } +} + +/// +/// Narrow event surface used to test native callback acquisition without +/// implementing Silk's complete contract. +/// +internal interface IWindowCallbackSurface +{ + void AddLoad(Action callback); + void RemoveLoad(Action callback); + void AddUpdate(Action callback); + void RemoveUpdate(Action callback); + void AddRender(Action callback); + void RemoveRender(Action callback); + void AddClosing(Action callback); + void RemoveClosing(Action callback); + void AddFocusChanged(Action callback); + void RemoveFocusChanged(Action callback); + void AddMove(Action> callback); + void RemoveMove(Action> callback); + void AddStateChanged(Action callback); + void RemoveStateChanged(Action callback); + void AddFramebufferResize(Action> callback); + void RemoveFramebufferResize(Action> callback); +} + +/// Production adapter over Silk's native event surface. +internal sealed class SilkWindowCallbackSurface(IWindow window) : IWindowCallbackSurface +{ + private readonly IWindow _window = window ?? throw new ArgumentNullException(nameof(window)); + + public void AddLoad(Action callback) => _window.Load += callback; + public void RemoveLoad(Action callback) => _window.Load -= callback; + public void AddUpdate(Action callback) => _window.Update += callback; + public void RemoveUpdate(Action callback) => _window.Update -= callback; + public void AddRender(Action callback) => _window.Render += callback; + public void RemoveRender(Action callback) => _window.Render -= callback; + public void AddClosing(Action callback) => _window.Closing += callback; + public void RemoveClosing(Action callback) => _window.Closing -= callback; + public void AddFocusChanged(Action callback) => _window.FocusChanged += callback; + public void RemoveFocusChanged(Action callback) => _window.FocusChanged -= callback; + public void AddMove(Action> callback) => _window.Move += callback; + public void RemoveMove(Action> callback) => _window.Move -= callback; + public void AddStateChanged(Action callback) => _window.StateChanged += callback; + public void RemoveStateChanged(Action callback) => _window.StateChanged -= callback; + public void AddFramebufferResize(Action> callback) => + _window.FramebufferResize += callback; + public void RemoveFramebufferResize(Action> callback) => + _window.FramebufferResize -= callback; +} + +/// +/// Owns the exact GameWindow-to-Silk callback edge. Registration is +/// transactional, physical removal is retriable, and the shared logical gate +/// is closed before any detach can re-enter a retiring owner. +/// +internal sealed class SilkWindowCallbackBinding : IDisposable +{ + private enum LifecycleState + { + Created, + Attaching, + Attached, + AttachFailed, + Detaching, + DetachPending, + Detached, + } + + private const int LoadIndex = 0; + private const int UpdateIndex = 1; + private const int MainRenderIndex = 2; + private const int PacingRenderIndex = 3; + private const int ClosingIndex = 4; + private const int FocusChangedIndex = 5; + private const int MoveIndex = 6; + private const int StateChangedIndex = 7; + private const int FramebufferResizeIndex = 8; + private const int AttachmentCount = 9; + + private readonly IWindowCallbackSurface _surface; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _load; + private readonly Action _update; + private readonly Action _mainRender; + private readonly Action _pacingRender; + private readonly Action _closing; + private readonly Action _focusChanged; + private readonly Action> _moved; + private readonly Action _stateChanged; + private readonly Action> _framebufferResize; + private readonly ResourceShutdownTransaction _detach; + private readonly bool[] _attached = new bool[AttachmentCount]; + private readonly object _lifecycleSync = new(); + private int _attachedCount; + private int _detachRequested; + private int _lifecycleOwnerThreadId; + private LifecycleState _lifecycleState = LifecycleState.Created; + + private SilkWindowCallbackBinding( + IWindowCallbackSurface surface, + WindowCallbackTargets targets, + DisplayFramePacingController pacing, + HostQuiescenceGate quiescence) + { + _surface = surface ?? throw new ArgumentNullException(nameof(surface)); + ArgumentNullException.ThrowIfNull(targets); + ArgumentNullException.ThrowIfNull(pacing); + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); + + _load = () => _quiescence.Invoke(targets.Load); + _update = value => _quiescence.Invoke(targets.Update, value); + _mainRender = value => _quiescence.Invoke(targets.Render, value); + _pacingRender = value => _quiescence.Invoke(pacing.OnFrameRendered, value); + _closing = () => _quiescence.Invoke(targets.Closing); + _focusChanged = value => _quiescence.Invoke(targets.FocusChanged, value); + _moved = value => _quiescence.Invoke(pacing.OnWindowMoved, value); + _stateChanged = value => _quiescence.Invoke(pacing.OnWindowStateChanged, value); + _framebufferResize = value => _quiescence.Invoke(targets.FramebufferResize, value); + + _detach = new ResourceShutdownTransaction( + new ResourceShutdownStage("native window callbacks", + [ + new("framebuffer resize", () => Remove(FramebufferResizeIndex)), + new("window state", () => Remove(StateChangedIndex)), + new("window move", () => Remove(MoveIndex)), + new("focus changed", () => Remove(FocusChangedIndex)), + new("closing", () => Remove(ClosingIndex)), + new("pacing render", () => Remove(PacingRenderIndex)), + new("main render", () => Remove(MainRenderIndex)), + new("update", () => Remove(UpdateIndex)), + new("load", () => Remove(LoadIndex)), + ])); + } + + public bool IsDetached + { + get + { + lock (_lifecycleSync) + return _attached.All(static attached => !attached); + } + } + + public bool IsDisposalComplete + { + get + { + lock (_lifecycleSync) + return _lifecycleState == LifecycleState.Detached; + } + } + + public static SilkWindowCallbackBinding Create( + IWindow window, + WindowCallbackTargets targets, + DisplayFramePacingController pacing, + HostQuiescenceGate quiescence) => + Create(new SilkWindowCallbackSurface(window), targets, pacing, quiescence); + + internal static SilkWindowCallbackBinding Create( + IWindowCallbackSurface surface, + WindowCallbackTargets targets, + DisplayFramePacingController pacing, + HostQuiescenceGate quiescence) => + new(surface, targets, pacing, quiescence); + + public void Attach() + { + int threadId = Environment.CurrentManagedThreadId; + lock (_lifecycleSync) + { + if (_lifecycleState != LifecycleState.Created) + { + throw _lifecycleState == LifecycleState.Detached + ? new ObjectDisposedException(nameof(SilkWindowCallbackBinding)) + : new InvalidOperationException( + "Native window callback attachment has already started."); + } + if (Volatile.Read(ref _detachRequested) != 0 || _detach.IsComplete) + throw new ObjectDisposedException(nameof(SilkWindowCallbackBinding)); + + _lifecycleState = LifecycleState.Attaching; + _lifecycleOwnerThreadId = threadId; + } + + try + { + Attach(LoadIndex); + Attach(UpdateIndex); + Attach(MainRenderIndex); + Attach(PacingRenderIndex); + Attach(ClosingIndex); + Attach(FocusChangedIndex); + Attach(MoveIndex); + Attach(StateChangedIndex); + Attach(FramebufferResizeIndex); + + lock (_lifecycleSync) + { + if (Volatile.Read(ref _detachRequested) != 0) + { + throw new ObjectDisposedException( + nameof(SilkWindowCallbackBinding), + "Shutdown was requested while native callbacks were attaching."); + } + + _lifecycleState = LifecycleState.Attached; + _lifecycleOwnerThreadId = 0; + System.Threading.Monitor.PulseAll(_lifecycleSync); + } + } + catch (Exception attachError) + { + Interlocked.Exchange(ref _detachRequested, 1); + _quiescence.StopAccepting(); + List? rollbackErrors = null; + for (int index = _attachedCount - 1; index >= 0; index--) + { + try + { + Remove(index); + } + catch (Exception rollbackError) + { + (rollbackErrors ??= []).Add(new InvalidOperationException( + $"Failed to roll back native window callback index {index}.", + rollbackError)); + } + } + + lock (_lifecycleSync) + { + _lifecycleState = LifecycleState.AttachFailed; + _lifecycleOwnerThreadId = 0; + System.Threading.Monitor.PulseAll(_lifecycleSync); + } + + if (rollbackErrors is not null) + { + rollbackErrors.Insert(0, new InvalidOperationException( + "Native window callback registration failed.", attachError)); + throw new AggregateException( + "Native window callback registration and rollback both failed.", + rollbackErrors); + } + + throw new InvalidOperationException( + "Native window callback registration failed and was rolled back.", + attachError); + } + } + + private void Attach(int index) + { + // A custom event add accessor is allowed to perform its side effect + // before throwing. Treat the edge as possibly acquired until a + // matching remove succeeds; removing an unattached delegate is safe. + lock (_lifecycleSync) + { + _attached[index] = true; + _attachedCount = Math.Max(_attachedCount, index + 1); + } + switch (index) + { + case LoadIndex: _surface.AddLoad(_load); break; + case UpdateIndex: _surface.AddUpdate(_update); break; + case MainRenderIndex: _surface.AddRender(_mainRender); break; + case PacingRenderIndex: _surface.AddRender(_pacingRender); break; + case ClosingIndex: _surface.AddClosing(_closing); break; + case FocusChangedIndex: _surface.AddFocusChanged(_focusChanged); break; + case MoveIndex: _surface.AddMove(_moved); break; + case StateChangedIndex: _surface.AddStateChanged(_stateChanged); break; + case FramebufferResizeIndex: + _surface.AddFramebufferResize(_framebufferResize); + break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + + if (Volatile.Read(ref _detachRequested) != 0) + { + throw new ObjectDisposedException( + nameof(SilkWindowCallbackBinding), + "Shutdown was requested while native callbacks were attaching."); + } + } + + private void Remove(int index) + { + lock (_lifecycleSync) + { + if (index >= _attachedCount || !_attached[index]) + return; + } + + switch (index) + { + case LoadIndex: _surface.RemoveLoad(_load); break; + case UpdateIndex: _surface.RemoveUpdate(_update); break; + case MainRenderIndex: _surface.RemoveRender(_mainRender); break; + case PacingRenderIndex: _surface.RemoveRender(_pacingRender); break; + case ClosingIndex: _surface.RemoveClosing(_closing); break; + case FocusChangedIndex: _surface.RemoveFocusChanged(_focusChanged); break; + case MoveIndex: _surface.RemoveMove(_moved); break; + case StateChangedIndex: _surface.RemoveStateChanged(_stateChanged); break; + case FramebufferResizeIndex: + _surface.RemoveFramebufferResize(_framebufferResize); + break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + + lock (_lifecycleSync) + _attached[index] = false; + } + + public void Dispose() + { + Interlocked.Exchange(ref _detachRequested, 1); + // Gate order is deliberate: never hold the physical-detach monitor + // while waiting for an in-flight native callback to finish. Closing + // itself holds the gate reentrantly and may enter this method. + _quiescence.StopAccepting(); + int threadId = Environment.CurrentManagedThreadId; + bool ownsGate = _quiescence.IsEnteredByCurrentThread; + while (true) + { + lock (_lifecycleSync) + { + if (_lifecycleState == LifecycleState.Detached) + return; + if (_lifecycleState == LifecycleState.Attaching) + { + if (_lifecycleOwnerThreadId == threadId || ownsGate) + throw new NativeWindowCallbackCleanupDeferredException(); + + System.Threading.Monitor.Wait(_lifecycleSync); + continue; + } + if (_lifecycleState == LifecycleState.Detaching) + { + if (_lifecycleOwnerThreadId == threadId) + return; + if (ownsGate) + throw new NativeWindowCallbackCleanupDeferredException(); + + System.Threading.Monitor.Wait(_lifecycleSync); + continue; + } + + _lifecycleState = LifecycleState.Detaching; + _lifecycleOwnerThreadId = threadId; + break; + } + } + + try + { + _detach.CompleteOrThrow(); + lock (_lifecycleSync) + { + _lifecycleState = LifecycleState.Detached; + _lifecycleOwnerThreadId = 0; + System.Threading.Monitor.PulseAll(_lifecycleSync); + } + } + catch + { + lock (_lifecycleSync) + { + _lifecycleState = LifecycleState.DetachPending; + _lifecycleOwnerThreadId = 0; + System.Threading.Monitor.PulseAll(_lifecycleSync); + } + + throw; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index f466e66b..e2f0da7d 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -22,31 +22,25 @@ public sealed class GameWindowSlice8BoundaryTests "Samples = startupQuality.MsaaSamples", "Window.Create(options)", "_displayFramePacing.BindSurface(", - "_window.Load += OnLoad;", - "_window.Update += OnUpdate;", - "_window.Render += OnRender;", - "_window.Render += _displayFramePacing.OnFrameRendered;", - "_window.Closing += OnClosing;", - "_window.FocusChanged += OnFocusChanged;", - "_window.Move += _displayFramePacing.OnWindowMoved;", - "_window.StateChanged += _displayFramePacing.OnWindowStateChanged;", - "_window.FramebufferResize += OnFramebufferResize;", + "_windowCallbacks = SilkWindowCallbackBinding.Create(", + "new WindowCallbackTargets(", + "OnLoad,", + "OnUpdate,", + "OnRender,", + "OnClosing,", + "OnFocusChanged,", + "OnFramebufferResize),", + "_displayFramePacing,", + "_hostQuiescence);", + "_windowCallbacks.Attach();", "_window.Run();"); - string[] registrations = - [ - "_window.Load += OnLoad;", - "_window.Update += OnUpdate;", - "_window.Render += OnRender;", - "_window.Render += _displayFramePacing.OnFrameRendered;", - "_window.Closing += OnClosing;", - "_window.FocusChanged += OnFocusChanged;", - "_window.Move += _displayFramePacing.OnWindowMoved;", - "_window.StateChanged += _displayFramePacing.OnWindowStateChanged;", - "_window.FramebufferResize += OnFramebufferResize;", - ]; - foreach (string registration in registrations) - Assert.Equal(1, CountOccurrences(body, registration)); + Assert.Equal(1, CountOccurrences(body, "SilkWindowCallbackBinding.Create(")); + Assert.Equal(1, CountOccurrences(body, "_windowCallbacks.Attach();")); + Assert.DoesNotContain("_window.Load +=", body, StringComparison.Ordinal); + Assert.DoesNotContain("_window.Update +=", body, StringComparison.Ordinal); + Assert.DoesNotContain("_window.Render +=", body, StringComparison.Ordinal); + Assert.DoesNotContain("_window.Closing +=", body, StringComparison.Ordinal); } [Fact] @@ -160,7 +154,11 @@ public sealed class GameWindowSlice8BoundaryTests "new AcDream.App.Rendering.RenderFrameInput("); Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal); AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();"); - AssertAppearsInOrder(close, "=> CompleteShutdown();", "_shutdown.CompleteOrThrow();"); + AssertAppearsInOrder( + close, + "_hostQuiescence.StopAccepting();", + "CompleteShutdown();", + "_shutdown.CompleteOrThrow();"); } [Fact] @@ -197,6 +195,11 @@ public sealed class GameWindowSlice8BoundaryTests Assert.Equal(stages.Length, CountOccurrences(manifest, "new ResourceShutdownStage(")); foreach (string stage in stages) Assert.Equal(1, CountOccurrences(manifest, stage)); + AssertAppearsInOrder( + manifest, + "binding.Dispose();", + "if (!binding.IsDisposalComplete)", + "_windowCallbacks = null;"); AssertAppearsInOrder( dispose, "CompleteShutdown();", diff --git a/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs b/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs new file mode 100644 index 00000000..04602aa3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/HostQuiescenceGateTests.cs @@ -0,0 +1,57 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class HostQuiescenceGateTests +{ + [Fact] + public async Task ExternalStopWaitsForAdmittedCallbackToReturn() + { + var gate = new HostQuiescenceGate(); + using var callbackEntered = new ManualResetEventSlim(false); + using var releaseCallback = new ManualResetEventSlim(false); + int calls = 0; + Task callback = Task.Run(() => gate.Invoke(() => + { + callbackEntered.Set(); + Assert.True(releaseCallback.Wait(TimeSpan.FromSeconds(5))); + calls++; + })); + Assert.True(callbackEntered.Wait(TimeSpan.FromSeconds(5))); + + using var stopStarted = new ManualResetEventSlim(false); + Task stop = Task.Run(() => + { + stopStarted.Set(); + gate.StopAccepting(); + }); + Assert.True(stopStarted.Wait(TimeSpan.FromSeconds(5))); + await Task.Delay(50); + Assert.False(stop.IsCompleted); + + releaseCallback.Set(); + await callback; + await stop; + gate.Invoke(() => calls++); + + Assert.Equal(1, calls); + Assert.False(gate.IsAccepting); + } + + [Fact] + public void CallbackCanStopGateReentrantlyWithoutDeadlock() + { + var gate = new HostQuiescenceGate(); + int calls = 0; + + gate.Invoke(() => + { + calls++; + gate.StopAccepting(); + }); + gate.Invoke(() => calls++); + + Assert.Equal(1, calls); + Assert.False(gate.IsAccepting); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs b/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs new file mode 100644 index 00000000..f7480830 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/SilkWindowCallbackBindingTests.cs @@ -0,0 +1,801 @@ +using AcDream.App.Diagnostics; +using AcDream.App.Rendering; +using Silk.NET.Maths; +using Silk.NET.Windowing; + +namespace AcDream.App.Tests.Rendering; + +public sealed class SilkWindowCallbackBindingTests +{ + [Fact] + public void AttachAndDisposeUseExactForwardAndReverseOrder() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + + using SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + Targets(gate), + pacing, + gate); + + Assert.Equal( + [ + "add load", + "add update", + "add render main", + "add render pacing", + "add closing", + "add focus", + "add move", + "add state", + "add framebuffer", + ], surface.Operations); + + surface.Operations.Clear(); + binding.Dispose(); + + Assert.Equal( + [ + "remove framebuffer", + "remove state", + "remove move", + "remove focus", + "remove closing", + "remove render pacing", + "remove render main", + "remove update", + "remove load", + ], surface.Operations); + Assert.True(binding.IsDetached); + Assert.False(gate.IsAccepting); + } + + [Fact] + public void MainRenderIsRegisteredBeforePacingRender() + { + var order = new List(); + var clock = new OrderingClock(); + using var profiler = new FrameProfiler(); + using var pacing = new DisplayFramePacingController( + uncappedRendering: false, + profiler, + new FramePacingController(clock, new OrderingWaiter(clock, order))); + pacing.InitializeStartup(requestedVSync: false); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + WindowCallbackTargets targets = Targets( + gate, + render: _ => order.Add("main render")); + using SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + targets, + pacing, + gate); + + Assert.Equal(2, surface.RenderCallbacks.Count); + + surface.RaiseRender(1d / 60d); + + Assert.Equal(["main render", "pacing render"], order); + } + + public static IEnumerable AttachFailureCases => + Enumerable.Range(1, 9).SelectMany(attempt => new[] + { + new object[] { attempt, false }, + new object[] { attempt, true }, + }); + + [Theory] + [MemberData(nameof(AttachFailureCases))] + public void FailureAfterNthAttachRollsBackPossiblyAcquiredPrefix( + int failedAttempt, + bool failAfterSideEffect) + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface + { + FailAddAttempt = failedAttempt, + FailAddAfterSideEffect = failAfterSideEffect, + }; + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding binding = SilkWindowCallbackBinding.Create( + surface, + Targets(gate), + pacing, + gate); + + InvalidOperationException error = Assert.Throws(binding.Attach); + + Assert.Contains("rolled back", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, surface.SubscriptionCount); + Assert.False(gate.IsAccepting); + + string[] added = surface.Operations + .Where(operation => operation.StartsWith("add ", StringComparison.Ordinal)) + .Take(failedAttempt) + .ToArray(); + string[] removed = surface.Operations + .Where(operation => operation.StartsWith("remove ", StringComparison.Ordinal)) + .ToArray(); + Assert.Equal( + added + .Reverse() + .Select(operation => operation.Replace("add ", "remove ", StringComparison.Ordinal)), + removed); + Assert.True(binding.IsDetached); + + int operationsAfterRollback = surface.Operations.Count; + binding.Dispose(); + Assert.Equal(operationsAfterRollback, surface.Operations.Count); + } + + [Fact] + public void RollbackFailureIsReportedAndRemainingCallbackIsLogicallySilent() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface + { + FailAddAttempt = 4, + }; + surface.PersistentRemoveFailures.Add("update"); + var gate = new HostQuiescenceGate(); + int updateCalls = 0; + SilkWindowCallbackBinding binding = SilkWindowCallbackBinding.Create( + surface, + Targets(gate, update: _ => updateCalls++), + pacing, + gate); + + AggregateException error = Assert.Throws(binding.Attach); + + Assert.Contains( + error.InnerExceptions, + exception => exception.Message.Contains("registration failed", StringComparison.Ordinal)); + Assert.Contains( + error.InnerExceptions, + exception => exception.Message.Contains("index 1", StringComparison.Ordinal)); + Assert.False(gate.IsAccepting); + Assert.Equal(1, surface.SubscriptionCount); + + surface.RaiseUpdate(0.1d); + + Assert.Equal(0, updateCalls); + + surface.PersistentRemoveFailures.Clear(); + binding.Dispose(); + + Assert.True(binding.IsDetached); + Assert.Equal(0, surface.SubscriptionCount); + } + + [Fact] + public void PersistentPhysicalDetachFailureIsRetriableWithoutReplayingSuccesses() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + surface.PersistentRemoveFailures.Add("focus"); + var gate = new HostQuiescenceGate(); + int updateCalls = 0; + SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + Targets(gate, update: _ => updateCalls++), + pacing, + gate); + + Assert.Throws(binding.Dispose); + Assert.False(binding.IsDetached); + Assert.False(gate.IsAccepting); + int successfulRemovalCount = surface.Operations.Count(operation => + operation.StartsWith("remove ", StringComparison.Ordinal) + && operation != "remove focus"); + Assert.Equal(8, successfulRemovalCount); + + surface.RaiseUpdate(0.1d); + Assert.Equal(0, updateCalls); + + surface.PersistentRemoveFailures.Clear(); + binding.Dispose(); + + Assert.True(binding.IsDetached); + Assert.Equal(0, surface.SubscriptionCount); + Assert.Equal(8, surface.Operations.Count(operation => + operation.StartsWith("remove ", StringComparison.Ordinal) + && operation != "remove focus")); + } + + [Fact] + public void RemoveThenTransientThrowIsRetriedToPhysicalConvergence() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + surface.RemoveAfterSideEffectFailures.Add("focus"); + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + Targets(gate), + pacing, + gate); + + binding.Dispose(); + + Assert.True(binding.IsDetached); + Assert.Equal(0, surface.SubscriptionCount); + Assert.Equal(2, surface.Operations.Count(operation => operation == "remove focus")); + } + + [Fact] + public void DisposeIsRepeatedAndReentrantSafe() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding? binding = null; + surface.Removing = _ => binding!.Dispose(); + binding = CreateAttachedBinding(surface, Targets(gate), pacing, gate); + + binding.Dispose(); + int operationsAfterFirstDispose = surface.Operations.Count; + binding.Dispose(); + + Assert.True(binding.IsDetached); + Assert.Equal(operationsAfterFirstDispose, surface.Operations.Count); + Assert.Equal(0, surface.SubscriptionCount); + } + + [Fact] + public async Task ConcurrentDisposeWaitsForPhysicalDetachToComplete() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface + { + BlockRemoveName = "framebuffer", + }; + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + Targets(gate), + pacing, + gate); + + Task first = Task.Run(() => Record.Exception(binding.Dispose)); + Assert.True(surface.RemoveEntered.Wait(TimeSpan.FromSeconds(5))); + using var secondEntered = new ManualResetEventSlim(false); + Task second = Task.Run(() => + { + secondEntered.Set(); + return Record.Exception(binding.Dispose); + }); + Assert.True(secondEntered.Wait(TimeSpan.FromSeconds(5))); + await Task.Delay(50); + Assert.False(second.IsCompleted); + + surface.ContinueRemove.Set(); + + Assert.Null(await first); + Assert.Null(await second); + Assert.True(binding.IsDetached); + } + + [Fact] + public async Task ConcurrentDisposeCannotHidePhysicalDetachFailure() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface + { + BlockRemoveName = "framebuffer", + }; + surface.PersistentRemoveFailures.Add("framebuffer"); + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + Targets(gate), + pacing, + gate); + + Task first = Task.Run(() => Record.Exception(binding.Dispose)); + Assert.True(surface.RemoveEntered.Wait(TimeSpan.FromSeconds(5))); + using var secondEntered = new ManualResetEventSlim(false); + Task second = Task.Run(() => + { + secondEntered.Set(); + return Record.Exception(binding.Dispose); + }); + Assert.True(secondEntered.Wait(TimeSpan.FromSeconds(5))); + await Task.Delay(50); + Assert.False(second.IsCompleted); + + surface.ContinueRemove.Set(); + + Assert.IsType(await first); + Assert.IsType(await second); + Assert.False(binding.IsDetached); + + surface.PersistentRemoveFailures.Clear(); + binding.Dispose(); + Assert.True(binding.IsDetached); + } + + [Fact] + public async Task DisposeDuringBlockedAttachForcesRollbackBeforeDetachCompletes() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface + { + BlockAddName = "load", + }; + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding binding = SilkWindowCallbackBinding.Create( + surface, + Targets(gate), + pacing, + gate); + + Task attach = Task.Run(() => Record.Exception(binding.Attach)); + Assert.True(surface.AddEntered.Wait(TimeSpan.FromSeconds(5))); + Task dispose = Task.Run(() => Record.Exception(binding.Dispose)); + Assert.True(SpinWait.SpinUntil( + () => !gate.IsAccepting, + TimeSpan.FromSeconds(5))); + Assert.False(dispose.IsCompleted); + + surface.ContinueAdd.Set(); + + Assert.IsType(await attach); + Assert.Null(await dispose); + Assert.True(binding.IsDetached); + Assert.Equal(0, surface.SubscriptionCount); + } + + [Fact] + public async Task ConcurrentDoubleAttachAllowsOnlyTheLifecycleOwner() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface + { + BlockAddName = "load", + }; + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding binding = SilkWindowCallbackBinding.Create( + surface, + Targets(gate), + pacing, + gate); + + Task first = Task.Run(() => Record.Exception(binding.Attach)); + Assert.True(surface.AddEntered.Wait(TimeSpan.FromSeconds(5))); + using var secondStarted = new ManualResetEventSlim(false); + Task second = Task.Run(() => + { + secondStarted.Set(); + return Record.Exception(binding.Attach); + }); + Assert.True(secondStarted.Wait(TimeSpan.FromSeconds(5))); + Exception secondError = await second.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsType(secondError); + + surface.ContinueAdd.Set(); + + Assert.Null(await first); + binding.Dispose(); + Assert.True(binding.IsDetached); + } + + [Fact] + public void AddAccessorCanRequestDisposeReentrantlyWithoutDeadlock() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding? binding = null; + surface.Adding = _ => binding!.Dispose(); + binding = SilkWindowCallbackBinding.Create( + surface, + Targets(gate), + pacing, + gate); + + Assert.Throws(binding.Attach); + Assert.False(binding.IsDisposalComplete); + binding.Dispose(); + + Assert.True(binding.IsDetached); + Assert.True(binding.IsDisposalComplete); + Assert.Equal(0, surface.SubscriptionCount); + } + + [Fact] + public void ReentrantAttachCleanupRemainsPublishedUntilRetryCompletes() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + surface.PersistentRemoveFailures.Add("load"); + var gate = new HostQuiescenceGate(); + SilkWindowCallbackBinding? slot = SilkWindowCallbackBinding.Create( + surface, + Targets(gate), + pacing, + gate); + ResourceShutdownTransaction? shutdown = null; + shutdown = new ResourceShutdownTransaction( + new ResourceShutdownStage("input", + [ + new("window callbacks", () => + { + SilkWindowCallbackBinding? current = slot; + if (current is null) + return; + + current.Dispose(); + if (!current.IsDisposalComplete) + throw new NativeWindowCallbackCleanupDeferredException(); + slot = null; + }), + ])); + surface.Adding = _ => shutdown.CompleteOrThrow(); + + Assert.Throws(slot.Attach); + Assert.NotNull(slot); + Assert.False(slot.IsDisposalComplete); + + surface.PersistentRemoveFailures.Clear(); + shutdown.CompleteOrThrow(); + + Assert.Null(slot); + Assert.True(shutdown.IsComplete); + Assert.Equal(0, surface.SubscriptionCount); + } + + [Fact] + public async Task ConcurrentCallbackDisposeAndLaterAddFailureCannotDeadlock() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + using var loadEntered = new ManualResetEventSlim(false); + SilkWindowCallbackBinding? binding = null; + Task? callback = null; + var targets = new WindowCallbackTargets( + load: () => + { + loadEntered.Set(); + binding!.Dispose(); + }, + update: static _ => { }, + render: static _ => { }, + closing: gate.StopAccepting, + focusChanged: static _ => { }, + framebufferResize: static _ => { }); + binding = SilkWindowCallbackBinding.Create(surface, targets, pacing, gate); + surface.Adding = name => + { + if (name != "update") + return; + + callback = Task.Run(() => Record.Exception(surface.RaiseLoad)); + Assert.True(loadEntered.Wait(TimeSpan.FromSeconds(5))); + throw new InvalidOperationException("synthetic later add failure"); + }; + + Task attach = Task.Run(() => Record.Exception(binding.Attach)); + Exception attachError = await attach.WaitAsync(TimeSpan.FromSeconds(5)); + Exception callbackError = await callback!.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.IsType(attachError); + Assert.IsType(callbackError); + Assert.False(binding.IsDisposalComplete); + + binding.Dispose(); + Assert.True(binding.IsDisposalComplete); + Assert.Equal(0, surface.SubscriptionCount); + } + + [Fact] + public void CallbackRaisedDuringDetachAndAfterDetachIsSilent() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + int updateCalls = 0; + SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + Targets(gate, update: _ => updateCalls++), + pacing, + gate); + surface.Removing = _ => surface.RaiseUpdate(0.1d); + + binding.Dispose(); + surface.RaiseUpdate(0.1d); + + Assert.Equal(0, updateCalls); + } + + [Fact] + public void ClosingCanQuiesceEveryLaterNativeCallback() + { + using var profiler = new FrameProfiler(); + using var pacing = CreatePacing(profiler); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + int closingCalls = 0; + int updateCalls = 0; + WindowCallbackTargets targets = Targets( + gate, + update: _ => updateCalls++, + closing: () => + { + closingCalls++; + gate.StopAccepting(); + }); + using SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + targets, + pacing, + gate); + + surface.RaiseClosing(); + surface.RaiseClosing(); + surface.RaiseUpdate(0.1d); + + Assert.Equal(1, closingCalls); + Assert.Equal(0, updateCalls); + Assert.False(gate.IsAccepting); + } + + [Fact] + public void ClosedGateSilencesEveryNativeWindowEdge() + { + var pacingCalls = new List(); + var clock = new OrderingClock(); + using var profiler = new FrameProfiler(); + using var pacing = new DisplayFramePacingController( + uncappedRendering: false, + profiler, + new FramePacingController(clock, new OrderingWaiter(clock, pacingCalls))); + pacing.InitializeStartup(requestedVSync: false); + var pacingSurface = new CountingPacingSurface(); + pacing.BindSurface(pacingSurface); + var surface = new FakeWindowCallbackSurface(); + var gate = new HostQuiescenceGate(); + int targetCalls = 0; + var targets = new WindowCallbackTargets( + () => targetCalls++, + _ => targetCalls++, + _ => targetCalls++, + () => targetCalls++, + _ => targetCalls++, + _ => targetCalls++); + using SilkWindowCallbackBinding binding = CreateAttachedBinding( + surface, + targets, + pacing, + gate); + gate.StopAccepting(); + + surface.RaiseLoad(); + surface.RaiseUpdate(0.1d); + surface.RaiseRender(0.1d); + surface.RaiseClosing(); + surface.RaiseFocusChanged(false); + surface.RaiseMove(default); + surface.RaiseStateChanged(default); + surface.RaiseFramebufferResize(new Vector2D(1280, 720)); + + Assert.Equal(0, targetCalls); + Assert.Empty(pacingCalls); + Assert.Equal(0, pacingSurface.RefreshReadCount); + } + + private static DisplayFramePacingController CreatePacing(FrameProfiler profiler) + { + var pacing = new DisplayFramePacingController( + uncappedRendering: false, + profiler); + pacing.InitializeStartup(requestedVSync: true); + return pacing; + } + + private static SilkWindowCallbackBinding CreateAttachedBinding( + IWindowCallbackSurface surface, + WindowCallbackTargets targets, + DisplayFramePacingController pacing, + HostQuiescenceGate gate) + { + SilkWindowCallbackBinding binding = SilkWindowCallbackBinding.Create( + surface, + targets, + pacing, + gate); + binding.Attach(); + return binding; + } + + private static WindowCallbackTargets Targets( + HostQuiescenceGate gate, + Action? update = null, + Action? render = null, + Action? closing = null) => new( + load: static () => { }, + update: update ?? (static _ => { }), + render: render ?? (static _ => { }), + closing: closing ?? gate.StopAccepting, + focusChanged: static _ => { }, + framebufferResize: static _ => { }); + + private sealed class FakeWindowCallbackSurface : IWindowCallbackSurface + { + private readonly List _load = []; + private readonly List> _update = []; + private readonly List> _render = []; + private readonly List _closing = []; + private readonly List> _focus = []; + private readonly List>> _move = []; + private readonly List> _state = []; + private readonly List>> _framebuffer = []; + private readonly Dictionary, string> _renderNames = + new(ReferenceEqualityComparer.Instance); + private int _addAttempts; + private int _addBlockUsed; + private int _removeBlockUsed; + + public List Operations { get; } = []; + public HashSet PersistentRemoveFailures { get; } = []; + public HashSet RemoveAfterSideEffectFailures { get; } = []; + public int? FailAddAttempt { get; init; } + public bool FailAddAfterSideEffect { get; init; } + public string? BlockAddName { get; init; } + public string? BlockRemoveName { get; init; } + public ManualResetEventSlim AddEntered { get; } = new(false); + public ManualResetEventSlim ContinueAdd { get; } = new(false); + public ManualResetEventSlim RemoveEntered { get; } = new(false); + public ManualResetEventSlim ContinueRemove { get; } = new(false); + public Action? Adding { get; set; } + public Action? Removing { get; set; } + public IReadOnlyList> RenderCallbacks => _render; + + public int SubscriptionCount => + _load.Count + _update.Count + _render.Count + _closing.Count + + _focus.Count + _move.Count + _state.Count + _framebuffer.Count; + + public void AddLoad(Action callback) => Add("load", _load, callback); + public void RemoveLoad(Action callback) => Remove("load", _load, callback); + public void AddUpdate(Action callback) => Add("update", _update, callback); + public void RemoveUpdate(Action callback) => Remove("update", _update, callback); + public void AddRender(Action callback) + { + string name = _render.Count == 0 ? "render main" : "render pacing"; + _renderNames.Add(callback, name); + Add(name, _render, callback); + } + public void RemoveRender(Action callback) + { + string name = _renderNames[callback]; + Remove(name, _render, callback); + _renderNames.Remove(callback); + } + public void AddClosing(Action callback) => Add("closing", _closing, callback); + public void RemoveClosing(Action callback) => Remove("closing", _closing, callback); + public void AddFocusChanged(Action callback) => Add("focus", _focus, callback); + public void RemoveFocusChanged(Action callback) => Remove("focus", _focus, callback); + public void AddMove(Action> callback) => Add("move", _move, callback); + public void RemoveMove(Action> callback) => Remove("move", _move, callback); + public void AddStateChanged(Action callback) => Add("state", _state, callback); + public void RemoveStateChanged(Action callback) => Remove("state", _state, callback); + public void AddFramebufferResize(Action> callback) => + Add("framebuffer", _framebuffer, callback); + public void RemoveFramebufferResize(Action> callback) => + Remove("framebuffer", _framebuffer, callback); + + public void RaiseLoad() => InvokeSnapshot(_load); + public void RaiseUpdate(double delta) => InvokeSnapshot(_update, delta); + public void RaiseRender(double delta) => InvokeSnapshot(_render, delta); + public void RaiseClosing() => InvokeSnapshot(_closing); + public void RaiseFocusChanged(bool focused) => InvokeSnapshot(_focus, focused); + public void RaiseMove(Vector2D position) => InvokeSnapshot(_move, position); + public void RaiseStateChanged(WindowState state) => InvokeSnapshot(_state, state); + public void RaiseFramebufferResize(Vector2D size) => + InvokeSnapshot(_framebuffer, size); + + private void Add(string name, List callbacks, T callback) + { + Operations.Add($"add {name}"); + _addAttempts++; + if (name == BlockAddName + && Interlocked.Exchange(ref _addBlockUsed, 1) == 0) + { + AddEntered.Set(); + if (!ContinueAdd.Wait(TimeSpan.FromSeconds(10))) + throw new TimeoutException("Synthetic add block timed out."); + } + bool fail = FailAddAttempt == _addAttempts; + if (fail && !FailAddAfterSideEffect) + throw new InvalidOperationException($"synthetic add failure: {name}"); + callbacks.Add(callback); + Adding?.Invoke(name); + if (fail) + throw new InvalidOperationException($"synthetic add-after-side-effect failure: {name}"); + } + + private void Remove(string name, List callbacks, T callback) + { + Operations.Add($"remove {name}"); + Removing?.Invoke(name); + if (name == BlockRemoveName + && Interlocked.Exchange(ref _removeBlockUsed, 1) == 0) + { + RemoveEntered.Set(); + if (!ContinueRemove.Wait(TimeSpan.FromSeconds(10))) + throw new TimeoutException("Synthetic remove block timed out."); + } + if (PersistentRemoveFailures.Contains(name)) + throw new InvalidOperationException($"synthetic remove failure: {name}"); + callbacks.Remove(callback); + if (RemoveAfterSideEffectFailures.Remove(name)) + { + throw new InvalidOperationException( + $"synthetic remove-after-side-effect failure: {name}"); + } + } + + private static void InvokeSnapshot(List callbacks) + { + foreach (Action callback in callbacks.ToArray()) + callback(); + } + + private static void InvokeSnapshot(List> callbacks, T value) + { + foreach (Action callback in callbacks.ToArray()) + callback(value); + } + } + + private sealed class OrderingClock : IFramePacingClock + { + public long Frequency => 1_000; + public long Timestamp { get; set; } + public long GetTimestamp() => Timestamp; + } + + private sealed class OrderingWaiter( + OrderingClock clock, + List order) : IFramePacingWaiter + { + public void Wait(long durationTicks, long clockFrequency) + { + order.Add("pacing render"); + clock.Timestamp += durationTicks; + } + } + + private sealed class CountingPacingSurface : IDisplayFramePacingSurface + { + public bool VSync { get; set; } + public int RefreshReadCount { get; private set; } + + public int? ActiveMonitorRefreshHz + { + get + { + RefreshReadCount++; + return 144; + } + } + } +} From 557eb7ef6bffecd67c72150f5d011627b61f7252 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 10:36:06 +0200 Subject: [PATCH 93/98] refactor(net): own live session composition Extract reset, selection, entered-world, and route construction behind LiveSessionHost while preserving the sole LiveSessionController authority. Retain partial route and subscription cleanup for retry, and replace the embedded ACE-only shortcut with the exact named-retail unsigned skill formula. Co-authored-by: Codex --- AGENTS.md | 16 +- CLAUDE.md | 16 +- docs/ISSUES.md | 41 +- docs/architecture/code-structure.md | 6 +- docs/plans/2026-04-11-roadmap.md | 9 +- docs/plans/2026-05-12-milestones.md | 6 +- ...amewindow-slice-8-composition-lifecycle.md | 15 +- .../skill_formula_calculate_pseudocode.md | 65 +++ memory/project_gamewindow_decomposition.md | 24 +- .../Net/LiveSessionCommandRouter.cs | 2 +- src/AcDream.App/Net/LiveSessionEventRouter.cs | 164 ++++++-- src/AcDream.App/Net/LiveSessionHost.cs | 242 +++++++++++ src/AcDream.App/Net/RetailSkillFormula.cs | 72 ++++ src/AcDream.App/Rendering/GameWindow.cs | 183 +++----- src/AcDream.Core.Net/SubscriptionSet.cs | 97 ++++- .../GameWindowLiveSessionOwnershipTests.cs | 10 +- .../Net/LiveSessionEventRouterTests.cs | 94 +++-- .../Net/LiveSessionHostTests.cs | 391 ++++++++++++++++++ .../Net/LiveSessionSubscriptionSetTests.cs | 60 +++ .../Net/RetailSkillFormulaTests.cs | 112 +++++ .../GameWindowSlice8BoundaryTests.cs | 9 +- .../SubscriptionSetTests.cs | 32 +- 22 files changed, 1430 insertions(+), 236 deletions(-) create mode 100644 docs/research/skill_formula_calculate_pseudocode.md create mode 100644 src/AcDream.App/Net/LiveSessionHost.cs create mode 100644 src/AcDream.App/Net/RetailSkillFormula.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionSubscriptionSetTests.cs create mode 100644 tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs diff --git a/AGENTS.md b/AGENTS.md index 8122d41e..6df5a163 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,11 +127,17 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -`GameWindow` is 4,666 raw lines / 196 fields / 70 methods, down 11,057 lines -(70.3%) from the 15,723-line campaign baseline. The 7,341-test Release suite, -315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop -soak pass. Final Slice 8 composition/shutdown and Silk callback cleanup is -active. See `docs/architecture/code-structure.md`. **Carried:** #232, #153, +Slice 8 checkpoints A–C are also complete: dead host residue is removed, +native-window callback intake has reversible lifetime ownership, and +`LiveSessionHost` owns reset/binding composition over the sole canonical +session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods, +down 11,134 lines (70.8%) from the 15,723-line campaign baseline. The 7,408-test +Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second +synchronized nine-stop soak pass. Slice 8 checkpoints D–L—world environment, +input/resize, action routing, settings, resource ownership, ordered composition, +retryable shutdown, soak snapshots, and final connected gates—remain active. +See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and +`docs/architecture/code-structure.md`. **Carried:** #232, #153, #116, remaining R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/CLAUDE.md b/CLAUDE.md index 4a332db0..d78eec38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,11 +125,17 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -`GameWindow` is 4,666 raw lines / 196 fields / 70 methods, down 11,057 lines -(70.3%) from the 15,723-line campaign baseline. The 7,341-test Release suite, -315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop -soak pass. Final Slice 8 composition/shutdown and Silk callback cleanup is -active. See `docs/architecture/code-structure.md`. **Carried:** #232, #153, +Slice 8 checkpoints A–C are also complete: dead host residue is removed, +native-window callback intake has reversible lifetime ownership, and +`LiveSessionHost` owns reset/binding composition over the sole canonical +session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods, +down 11,134 lines (70.8%) from the 15,723-line campaign baseline. The 7,408-test +Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second +synchronized nine-stop soak pass. Slice 8 checkpoints D–L—world environment, +input/resize, action routing, settings, resource ownership, ordered composition, +retryable shutdown, soak snapshots, and final connected gates—remain active. +See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and +`docs/architecture/code-structure.md`. **Carried:** #232, #153, #116, remaining R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's lifestone/particle alpha visual gate. diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c0ca02ae..331b0497 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -27,9 +27,11 @@ What does NOT go here: ## Current queue — 2026-07-22 - **Active structural work:** `GameWindow` decomposition. Slices 1–7 are - complete; `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed - orchestration handoffs and the class is 4,666 lines / 196 fields / 70 - methods. Final Slice 8 composition/shutdown cleanup is active in + complete; Slice 8 checkpoints A–C now own native callbacks and live-session + composition outside the shell. `GameWindow.OnUpdate` and + `GameWindow.OnRender` are typed orchestration handoffs and the class is 4,589 + lines / 196 fields / 69 methods. Remaining Slice 8 composition/shutdown + cleanup is active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. - **Connected-gate fidelity:** `#232` adds canonical managed/GPU/cache-owner @@ -73,6 +75,39 @@ Copy this block when adding a new issue: --- +## #233 — Live skill-credit resolver used an ACE shortcut, not retail's formula + +**Status:** DONE (2026-07-22, GameWindow Slice 8 Checkpoint C) +**Severity:** MEDIUM +**Filed:** 2026-07-22 +**Component:** live character state / DAT skill formulas + +**Description:** The live skill-update route calculated a skill's primary- +attribute contribution with an ACE-oriented shortcut. It rejected every +formula whose first multiplier was zero, truncated division, and added `W` +after division. Custom DAT formulas—and any stock boundary landing on a half— +could therefore display the wrong skill value. + +**Root cause / status:** The binding cited ACE without first checking named +retail. `SkillFormula::Calculate @ 0x00591960` treats all four arithmetic words +as unsigned, fails only for `Z == 0`, forms the wrapping numerator +`X*a + Y*b + W`, and rounds `numerator/Z` half-up. Checkpoint C moved that +algorithm into `RetailSkillFormula`, added a named `LiveSkillCreditResolver`, +and pinned zero-X, W placement, half rounding, unsigned reinterpretation, and +32-bit wrap with conformance tests. + +**Files:** `src/AcDream.App/Net/RetailSkillFormula.cs`; +`tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs`. + +**Research:** +[`docs/research/skill_formula_calculate_pseudocode.md`](research/skill_formula_calculate_pseudocode.md). + +**Acceptance:** Live skill updates resolve the DAT entry and reproduce the +named retail function for all unsigned field values; missing skills or +attributes remain safely projected as zero. + +--- + ## #232 — Nine-stop soak process-memory gate lacks canonical owner snapshots **Status:** OPEN diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 82eac7fa..e967c89d 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -232,7 +232,9 @@ src/AcDream.App/ │ └── Vfx/ # (already exists) ├── Net/ │ ├── LiveSessionController.cs # owns complete WorldSession connect/enter/logout/reconnect lifecycle -│ ├── LiveSessionLifecycleHost.cs # narrow App composition + exact borrowed-session attachment +│ ├── LiveSessionHost.cs # App reset/selection/entry/route-factory composition over the canonical controller +│ ├── LiveSessionLifecycleHost.cs # exact borrowed-session attachment guard +│ ├── RetailSkillFormula.cs # named-retail unsigned skill-credit calculation + DAT lookup │ └── LiveSessionEventRouter.cs # typed subscriptions → focused domain handlers ├── Physics/ │ ├── ProjectileController.cs # canonical live-record projectile orchestration @@ -436,7 +438,7 @@ useful ordering seam, but its ownership status is **partial**. | Area | Status | Current truth | |---|---|---| | Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. | -| Network session | **Complete** | `LiveSessionController` owns resolve/create/bind/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal; `LiveSessionLifecycleHost` is the narrow App composition boundary; `LiveSessionEventRouter` and `LiveSessionCommandRouter` own exact inbound/outbound lifetimes. `GameWindow` retains one controller field and focused domain sink factories only (`d9ccf8a6`, `6a5d9e2e`). | +| Network session | **Complete** | `LiveSessionController` remains the sole resolve/create/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal and current-session owner. `LiveSessionHost` owns App reset/selection/entry ordering plus create→attach route factories; `LiveSessionLifecycleHost` guards exact borrowed-session attachment. `LiveSessionEventRouter` and `LiveSessionCommandRouter` own exact inbound/outbound lifetimes, with retryable per-edge teardown before reset or replacement. `GameWindow` retains the controller, focused host, and typed domain binding factories—no mirrored session or reset plan. | | Live identity/lifetime | **Complete App integration** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, and logical/spatial lifetime. `LiveEntityHydrationController`, `LiveEntityRuntimeTeardownController`, and `LiveEntityNetworkUpdateController` own DAT-backed hydration, retryable cleanup, and accepted Position/Vector/State/Movement presentation without a second GUID owner (`aa90c646`). | | Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). | | World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 8e405354..68735a60 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-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 are complete and final Slice 8 composition/shutdown cleanup is active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–C are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. 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. --- @@ -79,7 +79,12 @@ second synchronized soak pass with graceful exits, and the six stable PNG checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse process-residency gate's diagnostic blind spot; no tolerance was loosened. -Final Slice 8 composition/shutdown cleanup is now active. +Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–C now own +the frozen shell boundary, all nine native callback edges, and live-session +reset/selection/entry/route composition. `GameWindow` is 4,589 lines / 196 +fields / 69 methods; the Checkpoint C Release suite passes 7,408 tests / 5 +skips. Environment, input, settings, startup/resource transfer, and shutdown +checkpoints D–L remain. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 91eb7ccc..403b3a17 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -39,8 +39,10 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized nine-destination soak pass with graceful exits, and six stable PNG checkpoints -preserve Slice 6 presentation. Final Slice 8 composition/shutdown cleanup is -active. Issue #232 tracks process-residency variance in the soak without +preserve Slice 6 presentation. Final Slice 8 checkpoints A–C are complete: +native callbacks and live-session composition now have explicit retryable +owners, and `GameWindow` is 4,589 lines / 196 fields / 69 methods. Checkpoints +D–L remain active. Issue #232 tracks process-residency variance in the soak without loosening its leak threshold. Carried: diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index b60e4e37..d6410f63 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -18,7 +18,7 @@ audit. test-facade residue. - [x] B — make native-window callback intake an explicit reversible owner and define host-quiescence failure semantics. -- [ ] C — extract live-session host/reset/binding callbacks and verify the +- [x] C — extract live-session host/reset/binding callbacks and verify the embedded skill formula against named retail. - [ ] D — extract retail world-environment/day/weather behavior. - [ ] E — extract two-phase raw pointer/camera input, focus, and framebuffer @@ -450,6 +450,19 @@ frozen callback behavior. - Keep `LiveSessionController.Start` last; both disabled-live and missing-credential starts must execute the reset path. +Result: `LiveSessionHost` now owns reset-plan construction, exact selection and +entered-world ordering, and create→attach route factories while resolving all +session/command/in-world state through the sole `LiveSessionController`. +Partially attached routes and every individual subscription edge retain failed +cleanup work; successful edges are never replayed, and reset/new generations +remain blocked until teardown converges. Named-retail research corrected the +former ACE-only skill shortcut to exact unsigned `SkillFormula::Calculate @ +0x00591960` semantics. Three corrected-diff review loops are clean; focused +session/formula/ledger tests, the App suite (3,048 pass / 3 intentional skips), +Core.Net (548 pass), the Release build, and the full suite (7,408 pass / 5 +intentional skips) pass. No connected gate was required for this ownership +checkpoint; the final connected lifecycle gate remains Checkpoint L. + ### D — world environment - Move loaded sky/day state, `RefreshSkyForCurrentDay`, `AdminEnvirons`, diff --git a/docs/research/skill_formula_calculate_pseudocode.md b/docs/research/skill_formula_calculate_pseudocode.md new file mode 100644 index 00000000..08e7a6ad --- /dev/null +++ b/docs/research/skill_formula_calculate_pseudocode.md @@ -0,0 +1,65 @@ +# Retail skill-formula calculation + +## Oracle + +- Named retail: `SkillFormula::Calculate @ 0x00591960` in + `named-retail/acclient_2013_pseudo_c.txt`. +- Retail layout: `SkillFormula` in `named-retail/acclient.h:40199` stores six + unsigned 32-bit fields in wire order: `W`, `X`, `Y`, `Z`, `Attr1`, `Attr2`. + +The signed-looking branches in the recovered x87 code are not signed formula +semantics. They are the compiler's unsigned-32-bit-to-floating-point fix-up: +when the high bit is set, it adds `2^32` after the initial signed conversion. + +## Pseudocode + +```text +bool SkillFormula.Calculate(uint attribute1, uint attribute2, out uint result) +{ + uint divisor = Z; + if (divisor == 0) + return false; + + // Every operation on this line is uint32 and wraps modulo 2^32. + uint numerator = X * attribute1 + Y * attribute2 + W; + + // Formula values are non-negative. Retail rounds to nearest integer, + // with exact halves rounding upward. + result = uint(floor(double(numerator) / double(divisor) + 0.5)); + return true; +} +``` + +Consequences pinned by the port: + +- `Z == 0` is the only failure gate. `X == 0` remains a valid formula. +- `W` participates in the numerator before division; it is not added after + division. +- multiplication and addition wrap as unsigned 32-bit arithmetic before the + floating-point division. +- the division rounds half-up; it does not truncate. + +## Reference cross-check + +ACE's [`SkillFormula`](https://github.com/ACEmulator/ACE/blob/master/Source/ACE.DatLoader/Entity/SkillFormula.cs) +confirms the unsigned DAT order `W, X, Y, Z, Attr1, Attr2`. ACE's +[`AttributeFormula.GetFormula`](https://github.com/ACEmulator/ACE/blob/master/Source/ACE.Server/Entity/AttributeFormula.cs) +is only an interpretation aid: it rejects `X == 0`, assumes unit multipliers, +omits `W`, and rounds a summed-attribute quotient. That is sufficient for +ordinary stock ACE data but is not the general retail algorithm. + +Chorizite DatReaderWriter's generated +[`SkillFormula`](https://github.com/Chorizite/DatReaderWriter/blob/master/DatReaderWriter/Generated/Types/SkillFormula.generated.cs) +independently confirms the same six-field order and maps its public names to +`W`, `X`, `Y`, and `Z`. It exposes the four arithmetic words as signed `int`, +so the retail port must reinterpret their bits as `uint`. Its summary places +the additive value after division; the named retail function proves that the +actual client adds `W` to the numerator instead. + +## Port decision + +`RetailSkillFormula` is the sole App-layer calculation seam. It reinterprets +the DAT words as unsigned, performs the exact unchecked 32-bit numerator, and +uses the retail half-up division. `LiveSkillCreditResolver` performs only the +skill-table lookup and treats a missing attribute as zero, matching the prior +live-property projection behavior. diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index 130392b4..a6f535d4 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -2,10 +2,11 @@ ## Current state -The behavior-preserving App ownership campaign is complete through Slice 7. -`GameWindow.cs` moved from the 2026-07-21 baseline of 15,723 lines / 278 fields / -205 methods to 4,666 lines / 196 fields / 70 methods: 11,057 lines (70.3%) were -removed without changing accepted gameplay or rendering behavior. +The behavior-preserving App ownership campaign is complete through Slice 7 and +Slice 8 checkpoints A–C. `GameWindow.cs` moved from the 2026-07-21 baseline of +15,723 lines / 278 fields / 205 methods to 4,589 lines / 196 fields / 69 +methods: 11,134 lines (70.8%) were removed without changing accepted gameplay +or rendering behavior. | Slice | Ownership moved out | Closeout size | |---|---|---:| @@ -17,6 +18,15 @@ removed without changing accepted gameplay or rendering behavior. | 6 | complete update-frame orchestration | 7,026 / 241 / 108 | | 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 | +Slice 8 is an ordered checkpoint campaign rather than another feature-body +move. A froze/pruned the shell boundary; B gave all nine native window callback +edges one reversible owner plus host quiescence; C added `LiveSessionHost` for +reset/selection/entry/route composition while keeping `LiveSessionController` +the sole session owner. C also replaced the embedded ACE shortcut with the +named-retail `SkillFormula::Calculate @ 0x00591960` port. Partial route +construction and individual detach edges are retained and retried; reset and a +new generation cannot pass an incompletely detached route. + Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`, @@ -73,6 +83,9 @@ review rule live in `docs/architecture/code-structure.md`. ## Accepted verification +- Slice 8 Checkpoint C Release suite: 7,408 passed / 5 fixture or environment + skips; App 3,048/3 skips and Core.Net 548/0. All three independent corrected- + diff reviews are clean. - Release suite: 7,341 passed / 5 fixture or environment skips. - Connected lifecycle/reconnect: 315.6 seconds, graceful capped close and fresh process uncapped reconnect passed; only 25 expected world-edge empty @@ -96,7 +109,8 @@ review rule live in `docs/architecture/code-structure.md`. ## Next work -Slice 8 groups composition and shutdown wiring and reduces Silk callbacks to +Slice 8 checkpoints D–L group the remaining environment, input, settings, +resource ownership, startup, and shutdown wiring and reduce Silk callbacks to narrow calls into input, update, render, resize, focus, and shutdown owners. It removes leftover feature state only where Slices 1–7 already established a canonical owner; it does not redesign gameplay, rendering, or resource lifetime. diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 3390b7b2..50ab0949 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -21,7 +21,7 @@ internal sealed record LiveSessionCommandBindings( /// itself is the published bus, so a retained reference becomes inert before /// the displaced transport is disposed. /// -internal sealed class LiveSessionCommandRouter : ICommandBus, IDisposable +internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting { private readonly object _gate = new(); private LiveCommandBus? _commands; diff --git a/src/AcDream.App/Net/LiveSessionEventRouter.cs b/src/AcDream.App/Net/LiveSessionEventRouter.cs index 585dbe08..084f0ea3 100644 --- a/src/AcDream.App/Net/LiveSessionEventRouter.cs +++ b/src/AcDream.App/Net/LiveSessionEventRouter.cs @@ -57,12 +57,19 @@ internal sealed record LiveSocialSessionBindings( /// Owns every inbound subscription for one exact live session. Domain state /// remains in the supplied sinks; this class owns only routing and teardown. /// -internal sealed class LiveSessionEventRouter : IDisposable +internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting { private readonly LiveSessionSubscriptionSet _subscriptions = new(); private readonly Action? _constructionCheckpoint; + private readonly WorldSession _session; + private readonly LiveEntitySessionSink _entities; + private readonly LiveEnvironmentSessionSink _environment; + private readonly LiveInventorySessionBindings _inventory; + private readonly LiveCharacterSessionBindings _character; + private readonly LiveSocialSessionBindings _social; private int _constructionStep; - private int _accepting = 1; + private int _accepting; + private int _lifecycleState; // 0 created, 1 attaching, 2 attached, 3 disposed public LiveSessionEventRouter( WorldSession session, @@ -75,7 +82,28 @@ internal sealed class LiveSessionEventRouter : IDisposable { ArgumentNullException.ThrowIfNull(session); Validate(entities, environment, inventory, character, social); + _session = session; + _entities = entities; + _environment = environment; + _inventory = inventory; + _character = character; + _social = social; _constructionCheckpoint = constructionCheckpoint; + } + + public void Attach() + { + if (Interlocked.CompareExchange(ref _lifecycleState, 1, 0) != 0) + throw new InvalidOperationException( + "Live-session event routing can only attach once."); + + Interlocked.Exchange(ref _accepting, 1); + WorldSession session = _session; + LiveEntitySessionSink entities = _entities; + LiveEnvironmentSessionSink environment = _environment; + LiveInventorySessionBindings inventory = _inventory; + LiveCharacterSessionBindings character = _character; + LiveSocialSessionBindings social = _social; try { @@ -182,22 +210,14 @@ internal sealed class LiveSessionEventRouter : IDisposable h => session.VitalCurrentUpdated += h, h => session.VitalCurrentUpdated -= h, vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current)); + + if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1) + throw new ObjectDisposedException(nameof(LiveSessionEventRouter)); } - catch (Exception constructionError) + catch { Interlocked.Exchange(ref _accepting, 0); - try - { - _subscriptions.Dispose(); - } - catch (Exception cleanupError) - { - throw new AggregateException( - "live-session event routing failed and cleanup also failed", - constructionError, - cleanupError); - } - + Interlocked.Exchange(ref _lifecycleState, 3); throw; } } @@ -207,6 +227,7 @@ internal sealed class LiveSessionEventRouter : IDisposable public void Dispose() { Interlocked.Exchange(ref _accepting, 0); + Interlocked.Exchange(ref _lifecycleState, 3); _subscriptions.Dispose(); } @@ -222,15 +243,10 @@ internal sealed class LiveSessionEventRouter : IDisposable }; attach(handler); - try - { - _subscriptions.Add(() => detach(handler)); - } - catch - { - detach(handler); - throw; - } + // Add assumes cleanup ownership before it can call an external detach. + // If the set is already closing, it retains/retries that exact edge; + // calling detach again here would replay a successful removal. + _subscriptions.Add(() => detach(handler)); ConstructionCheckpoint(); } @@ -290,31 +306,48 @@ internal sealed class LiveSessionEventRouter : IDisposable internal sealed class LiveSessionSubscriptionSet : IDisposable { - private List? _subscriptions = []; + private readonly object _gate = new(); + private readonly List _subscriptions = []; + private bool _disposeRequested; public void Add(IDisposable subscription) { ArgumentNullException.ThrowIfNull(subscription); - List? subscriptions = _subscriptions; - if (subscriptions is null) - { - subscription.Dispose(); - throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet)); - } - - subscriptions.Add(subscription); + AddRetained(new RetryableSubscription(subscription.Dispose)); } - public void Add(Action unsubscribe) => Add(new ActionSubscription(unsubscribe)); + public void Add(Action unsubscribe) + { + ArgumentNullException.ThrowIfNull(unsubscribe); + AddRetained(new RetryableSubscription(unsubscribe)); + } + + private void AddRetained(RetryableSubscription retained) + { + bool disposeNow; + lock (_gate) + { + disposeNow = _disposeRequested; + _subscriptions.Add(retained); + } + + if (!disposeNow) + return; + retained.Dispose(); + throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet)); + } public void Dispose() { - List? subscriptions = Interlocked.Exchange(ref _subscriptions, null); - if (subscriptions is null) - return; + RetryableSubscription[] subscriptions; + lock (_gate) + { + _disposeRequested = true; + subscriptions = _subscriptions.ToArray(); + } List? errors = null; - for (int index = subscriptions.Count - 1; index >= 0; index--) + for (int index = subscriptions.Length - 1; index >= 0; index--) { try { @@ -332,10 +365,59 @@ internal sealed class LiveSessionSubscriptionSet : IDisposable errors); } - private sealed class ActionSubscription(Action unsubscribe) : IDisposable + private sealed class RetryableSubscription(Action dispose) : IDisposable { - private Action? _unsubscribe = unsubscribe; + private readonly object _gate = new(); + private Action? _dispose = dispose; + private bool _executing; + private int _executingThreadId; - public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke(); + public void Dispose() + { + Action? operation; + int threadId = Environment.CurrentManagedThreadId; + lock (_gate) + { + while (_executing) + { + if (_executingThreadId == threadId) + { + throw new InvalidOperationException( + "Live-session subscription cleanup cannot complete reentrantly."); + } + Monitor.Wait(_gate); + } + + operation = _dispose; + if (operation is null) + return; + _executing = true; + _executingThreadId = threadId; + } + + try + { + operation(); + } + catch + { + CompleteAttempt(succeeded: false); + throw; + } + + CompleteAttempt(succeeded: true); + } + + private void CompleteAttempt(bool succeeded) + { + lock (_gate) + { + if (succeeded) + _dispose = null; + _executing = false; + _executingThreadId = 0; + Monitor.PulseAll(_gate); + } + } } } diff --git a/src/AcDream.App/Net/LiveSessionHost.cs b/src/AcDream.App/Net/LiveSessionHost.cs new file mode 100644 index 00000000..80523456 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionHost.cs @@ -0,0 +1,242 @@ +using System.Runtime.ExceptionServices; +using AcDream.Core.Net; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Net; + +internal interface ILiveSessionEventRouting : IDisposable +{ + void Attach(); +} + +internal interface ILiveSessionCommandRouting : ICommandBus, IDisposable +{ + void Activate(); +} + +internal sealed record LiveSessionRoutingFactories( + Func CreateEvents, + Func CreateCommands); + +internal sealed record LiveSessionSelectionBindings( + Action SetPlayerIdentity, + Action SetVitalsIdentity, + Action SetChatIdentity, + Action MarkPersistent, + Action SetVanishProbeIdentity, + Action ClearCombat); + +internal sealed record LiveSessionEnteredWorldBindings( + Action SetActiveCharacter, + Action RestoreLayout, + Action SyncToolbar, + Action LoadCharacterSettings, + Action ArmPlayerModeAutoEntry); + +internal sealed record LiveSessionHostBindings( + LiveSessionRoutingFactories Routing, + LiveSessionResetBindings Reset, + LiveSessionSelectionBindings Selection, + LiveSessionEnteredWorldBindings EnteredWorld, + Action Connecting, + Action Connected); + +/// +/// App composition owner for the one canonical . +/// It owns callback ordering and per-generation route factories, but never +/// mirrors session, generation, identity, routing, or command state. +/// +internal sealed class LiveSessionHost +{ + private sealed class PendingRouteRollback( + ILiveSessionCommandRouting? commands, + ILiveSessionEventRouting? events) + { + private ILiveSessionCommandRouting? _commands = commands; + private ILiveSessionEventRouting? _events = events; + + public bool IsComplete => _commands is null && _events is null; + + public void Drain() + { + List? failures = null; + TryDispose(ref _commands, ref failures); + TryDispose(ref _events, ref failures); + if (failures is not null) + { + throw new AggregateException( + "Live-session route rollback did not converge.", + failures); + } + } + + private static void TryDispose( + ref TOwner? owner, + ref List? failures) + where TOwner : class, IDisposable + { + if (owner is null) + return; + try + { + owner.Dispose(); + owner = null; + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + } + + private readonly LiveSessionController _controller; + private readonly LiveSessionRoutingFactories _routing; + private readonly LiveSessionSelectionBindings _selection; + private readonly LiveSessionEnteredWorldBindings _enteredWorld; + private readonly LiveSessionResetPlan _resetPlan; + private readonly LiveSessionLifecycleHost _lifecycle; + private PendingRouteRollback? _pendingRouteRollback; + + public LiveSessionHost( + LiveSessionController controller, + LiveSessionHostBindings bindings) + { + _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + ArgumentNullException.ThrowIfNull(bindings); + _routing = bindings.Routing ?? throw new ArgumentNullException(nameof(bindings.Routing)); + _selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection)); + _enteredWorld = bindings.EnteredWorld + ?? throw new ArgumentNullException(nameof(bindings.EnteredWorld)); + ArgumentNullException.ThrowIfNull(_routing.CreateEvents); + ArgumentNullException.ThrowIfNull(_routing.CreateCommands); + ArgumentNullException.ThrowIfNull(bindings.Connecting); + ArgumentNullException.ThrowIfNull(bindings.Connected); + Validate(_selection, _enteredWorld); + + _resetPlan = LiveSessionResetManifest.Create(bindings.Reset); + _lifecycle = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings( + Bind: BindSession, + Reset: ResetSessionState, + Connecting: bindings.Connecting, + Connected: bindings.Connected, + Selected: ApplySelection, + Entered: ApplyEnteredWorld)); + } + + public WorldSession? CurrentSession => _controller.CurrentSession; + public ICommandBus Commands => _controller.Commands; + public bool IsInWorld => _controller.IsInWorld; + + public LiveSessionStartResult Start(RuntimeOptions options) => + _controller.Start(options, _lifecycle); + + public LiveSessionStartResult Reconnect(RuntimeOptions options) => + _controller.Reconnect(options, _lifecycle); + + private LiveSessionBinding BindSession(WorldSession session) + { + DrainPendingRouteRollback(); + + ILiveSessionEventRouting? events = null; + ILiveSessionCommandRouting? commands = null; + try + { + events = _routing.CreateEvents(session) + ?? throw new InvalidOperationException( + "The live-session event factory returned null."); + events.Attach(); + commands = _routing.CreateCommands(session) + ?? throw new InvalidOperationException( + "The live-session command factory returned null."); + return new LiveSessionBinding( + session, + commands, + activateCommands: commands.Activate, + deactivateCommands: commands.Dispose, + detachEvents: events.Dispose); + } + catch (Exception creationError) + { + RethrowWithRetryableRollback(creationError, commands, events); + throw; + } + } + + private void ResetSessionState() + { + // An incompletely detached route can still deliver callbacks into the + // state below. Treat physical route convergence as the same hard + // barrier used by normal LiveSessionBinding teardown. + DrainPendingRouteRollback(); + _resetPlan.Execute(); + } + + private void ApplySelection(LiveSessionCharacterSelection selection) + { + uint id = selection.CharacterId; + _selection.SetPlayerIdentity(id); + _selection.SetVitalsIdentity(id); + _selection.SetChatIdentity(id); + _selection.MarkPersistent(id); + _selection.SetVanishProbeIdentity(id); + _selection.ClearCombat(); + } + + private void ApplyEnteredWorld(LiveSessionCharacterSelection selection) + { + string name = selection.CharacterName; + _enteredWorld.SetActiveCharacter(name); + _enteredWorld.RestoreLayout(); + _enteredWorld.SyncToolbar(); + _enteredWorld.LoadCharacterSettings(name); + _enteredWorld.ArmPlayerModeAutoEntry(); + } + + private void RethrowWithRetryableRollback( + Exception creationError, + ILiveSessionCommandRouting? commands, + ILiveSessionEventRouting? events) + { + _pendingRouteRollback = new PendingRouteRollback(commands, events); + try + { + DrainPendingRouteRollback(); + } + catch (AggregateException cleanupError) + { + var failures = new List { creationError }; + failures.AddRange(cleanupError.InnerExceptions); + throw new AggregateException( + "Live-session route construction and rollback both failed.", + failures); + } + + ExceptionDispatchInfo.Capture(creationError).Throw(); + } + + private void DrainPendingRouteRollback() + { + if (_pendingRouteRollback is not { } rollback) + return; + rollback.Drain(); + if (rollback.IsComplete) + _pendingRouteRollback = null; + } + + private static void Validate( + LiveSessionSelectionBindings selection, + LiveSessionEnteredWorldBindings entered) + { + ArgumentNullException.ThrowIfNull(selection.SetPlayerIdentity); + ArgumentNullException.ThrowIfNull(selection.SetVitalsIdentity); + ArgumentNullException.ThrowIfNull(selection.SetChatIdentity); + ArgumentNullException.ThrowIfNull(selection.MarkPersistent); + ArgumentNullException.ThrowIfNull(selection.SetVanishProbeIdentity); + ArgumentNullException.ThrowIfNull(selection.ClearCombat); + ArgumentNullException.ThrowIfNull(entered.SetActiveCharacter); + ArgumentNullException.ThrowIfNull(entered.RestoreLayout); + ArgumentNullException.ThrowIfNull(entered.SyncToolbar); + ArgumentNullException.ThrowIfNull(entered.LoadCharacterSettings); + ArgumentNullException.ThrowIfNull(entered.ArmPlayerModeAutoEntry); + } +} diff --git a/src/AcDream.App/Net/RetailSkillFormula.cs b/src/AcDream.App/Net/RetailSkillFormula.cs new file mode 100644 index 00000000..72688bae --- /dev/null +++ b/src/AcDream.App/Net/RetailSkillFormula.cs @@ -0,0 +1,72 @@ +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Net; + +/// +/// Exact unsigned formula used by retail SkillFormula::Calculate @ +/// 0x00591960. DAT reader fields are signed storage views, so their bits +/// are deliberately reinterpreted as retail's unsigned W/X/Y/Z words. +/// +internal static class RetailSkillFormula +{ + public static bool TryCalculate( + SkillFormula formula, + uint attribute1, + uint attribute2, + out uint result) + { + ArgumentNullException.ThrowIfNull(formula); + + uint divisor = unchecked((uint)formula.Divisor); + if (divisor == 0u) + { + result = 0u; + return false; + } + + uint x = unchecked((uint)formula.Attribute1Multiplier); + uint y = unchecked((uint)formula.Attribute2Multiplier); + uint w = unchecked((uint)formula.AdditiveBonus); + uint numerator = unchecked(x * attribute1 + y * attribute2 + w); + result = (uint)Math.Floor((double)numerator / divisor + 0.5d); + return true; + } +} + +/// +/// Named live-session resolver for the attribute contribution to one skill. +/// Table lookup and missing-property policy stay separate from retail math. +/// +internal sealed class LiveSkillCreditResolver(SkillTable? skillTable) +{ + public uint Resolve( + uint skillId, + IReadOnlyDictionary attributeCurrents) + { + ArgumentNullException.ThrowIfNull(attributeCurrents); + + if (skillTable?.Skills is null + || !skillTable.Skills.TryGetValue( + (DatReaderWriter.Enums.SkillId)skillId, + out var skillBase)) + { + return 0u; + } + + SkillFormula formula = skillBase.Formula; + attributeCurrents.TryGetValue( + (uint)formula.Attribute1, + out uint attribute1); + attributeCurrents.TryGetValue( + (uint)formula.Attribute2, + out uint attribute2); + return RetailSkillFormula.TryCalculate( + formula, + attribute1, + attribute2, + out uint result) + ? result + : 0u; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b1cefe90..3e3eb48b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -380,10 +380,6 @@ public sealed class GameWindow : IDisposable // DevToolsEnabled reads through typed RuntimeOptions. private bool DevToolsEnabled => _options.DevTools; - // Slice 3: the reset transaction remains cached by the host, while the - // lifecycle controller owns each exact event/command/session generation. - private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan; - // Phase G.1-G.2 world lighting/time state. public readonly AcDream.Core.World.WorldTimeService WorldTime = new AcDream.Core.World.WorldTimeService( @@ -485,7 +481,7 @@ public sealed class GameWindow : IDisposable } // Phase K.2 — auto-enter player mode after a successful login. Armed - // by ApplyLiveSessionEnteredWorld; ticked from + // by LiveSessionHost's entered-world transition; ticked from // OnUpdate; disarmed if the user manually enters fly mode (or any // other path that pre-empts the chase camera). Skipped entirely // offline (orbit camera stays the foreground). The class internally @@ -529,8 +525,9 @@ public sealed class GameWindow : IDisposable // Slice 3: the controller is the sole App owner. Outbound feature // callbacks resolve this borrowed handle at call time and never cache it. private AcDream.App.Net.LiveSessionController? _liveSessionController; + private AcDream.App.Net.LiveSessionHost? _liveSessionHost; private AcDream.Core.Net.WorldSession? LiveSession => - _liveSessionController?.CurrentSession; + _liveSessionHost?.CurrentSession; private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new(); private int _liveCenterX => _liveWorldOrigin.CenterX; private int _liveCenterY => _liveWorldOrigin.CenterY; @@ -1194,7 +1191,7 @@ public sealed class GameWindow : IDisposable try { // _activeToonKey is updated by - // ApplyLiveSessionEnteredWorld + // LiveSessionHost entered-world transition // so saving character settings always // writes under the chosen character's // name (or "default" pre-login). @@ -1483,7 +1480,7 @@ public sealed class GameWindow : IDisposable & AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 .DragItemOnPlayerOpensSecureTrade) != 0, toast: text => _debugVm?.AddToast(text), - readyForInventoryRequest: () => _liveSessionController?.IsInWorld == true, + readyForInventoryRequest: () => _liveSessionHost?.IsInWorld == true, playerOnGround: GetDebugPlayerOnGround, inNonCombatMode: () => Combat.CurrentMode == AcDream.Core.Combat.CombatMode.NonCombat, @@ -1534,7 +1531,7 @@ public sealed class GameWindow : IDisposable playerGuid: () => _playerServerGuid, activeToonName: () => _activeToonKey, fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter, - canSendRaise: () => _liveSessionController?.IsInWorld == true, + canSendRaise: () => _liveSessionHost?.IsInWorld == true, sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost), sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(statId, cost), sendRaiseSkill: (statId, cost) => LiveSession?.SendRaiseSkill(statId, cost), @@ -1555,7 +1552,7 @@ public sealed class GameWindow : IDisposable sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId), displayMessage: text => Chat.OnSystemMessage(text, 0x1Au), incrementBusy: () => _itemInteractionController.IncrementBusyCount(), - canSend: () => _liveSessionController?.IsInWorld == true); + canSend: () => _liveSessionHost?.IsInWorld == true); _uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside; // Feed Silk input to the UiRoot tree so windows drag / close / select. @@ -1653,7 +1650,7 @@ public sealed class GameWindow : IDisposable Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm), Chat: new AcDream.App.UI.ChatRuntimeBindings( retailChatVm, - () => _liveSessionController?.Commands + () => _liveSessionHost?.Commands ?? AcDream.UI.Abstractions.NullCommandBus.Instance), Radar: new AcDream.App.UI.RadarRuntimeBindings( radarSnapshotProvider.BuildSnapshot, @@ -2960,10 +2957,9 @@ public sealed class GameWindow : IDisposable new AcDream.App.Update.PlayerModeAutoEntryFramePhase( _playerModeAutoEntry), cameraFrame); + _liveSessionHost = CreateLiveSessionHost(); AcDream.App.Net.LiveSessionStartResult liveStart = - _liveSessionController.Start( - _options, - CreateLiveSessionLifecycleHost()); + _liveSessionHost.Start(_options); switch (liveStart.Status) { case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials: @@ -2976,11 +2972,27 @@ public sealed class GameWindow : IDisposable } } - private AcDream.App.Net.LiveSessionLifecycleHost CreateLiveSessionLifecycleHost() => - new(new AcDream.App.Net.LiveSessionLifecycleBindings( - Bind: CreateLiveSessionBinding, - Reset: () => - (_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(), + private AcDream.App.Net.LiveSessionHost CreateLiveSessionHost() => + new(_liveSessionController!, new AcDream.App.Net.LiveSessionHostBindings( + Routing: new( + CreateLiveSessionEventRouter, + session => new AcDream.App.Net.LiveSessionCommandRouter( + CreateLiveSessionCommandBindings(session))), + Reset: CreateLiveSessionResetBindings(), + Selection: new( + SetPlayerIdentity: id => _playerServerGuid = id, + SetVitalsIdentity: id => _vitalsVm?.SetLocalPlayerGuid(id), + SetChatIdentity: Chat.SetLocalPlayerGuid, + MarkPersistent: _worldState.MarkPersistent, + SetVanishProbeIdentity: id => + AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = id, + ClearCombat: Combat.Clear), + EnteredWorld: new( + SetActiveCharacter: name => _activeToonKey = name, + RestoreLayout: () => _retailUiRuntime?.RestoreLayout(), + SyncToolbar: SyncToolbarWindowButtons, + LoadCharacterSettings: LoadLiveSessionCharacterSettings, + ArmPlayerModeAutoEntry: () => _playerModeAutoEntry?.Arm()), Connecting: (host, port, user) => Chat.OnSystemMessage( $"connecting to {host}:{port} as {user}", @@ -2988,38 +3000,21 @@ public sealed class GameWindow : IDisposable Connected: () => Chat.OnSystemMessage( "connected — character list received", - chatType: 1), - Selected: ApplyLiveSessionSelection, - Entered: ApplyLiveSessionEnteredWorld)); + chatType: 1))); - private void ApplyLiveSessionSelection( - AcDream.App.Net.LiveSessionCharacterSelection selection) + private void LoadLiveSessionCharacterSettings(string characterName) { - _playerServerGuid = selection.CharacterId; - _vitalsVm?.SetLocalPlayerGuid(selection.CharacterId); - Chat.SetLocalPlayerGuid(selection.CharacterId); - _worldState.MarkPersistent(selection.CharacterId); - AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = selection.CharacterId; - Combat.Clear(); - } - - private void ApplyLiveSessionEnteredWorld( - AcDream.App.Net.LiveSessionCharacterSelection selection) - { - _activeToonKey = selection.CharacterName; - _retailUiRuntime?.RestoreLayout(); - SyncToolbarWindowButtons(); if (_settingsStore is not null && _settingsVm is not null) { - var toonBag = _settingsStore.LoadCharacter(_activeToonKey); + var toonBag = _settingsStore.LoadCharacter(characterName); _settingsVm.LoadCharacterContext(toonBag); - Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences"); + Console.WriteLine( + $"settings: loaded character[{characterName}] preferences"); } - _playerModeAutoEntry?.Arm(); } - private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() => - AcDream.App.Net.LiveSessionResetManifest.Create(new() + private AcDream.App.Net.LiveSessionResetBindings + CreateLiveSessionResetBindings() => new() { MouseCapture = ResetSessionMouseCapture, PlayerPresentation = ResetSessionPlayerPresentation, @@ -3052,7 +3047,7 @@ public sealed class GameWindow : IDisposable AnimationHookFrames = () => _animationHookFrames?.Clear(), LivePresentation = () => _liveEntityPresentation?.Clear(), RemoteMovementDiagnostics = _remoteMovementObservations.Clear, - }); + }; private void ResetSessionMouseCapture() => _gameplayInputFrame?.ResetSession(); @@ -3108,12 +3103,7 @@ public sealed class GameWindow : IDisposable } } - /// - /// Composes the exact inbound and outbound owners for one live session. - /// Registration completes before Connect; outbound commands remain inert - /// until EnterWorld succeeds. - /// - private AcDream.App.Net.LiveSessionBinding CreateLiveSessionBinding( + private AcDream.App.Net.LiveSessionEventRouter CreateLiveSessionEventRouter( AcDream.Core.Net.WorldSession session) { var skillTable = _dats?.Get(0x0E000004u); @@ -3126,39 +3116,19 @@ public sealed class GameWindow : IDisposable Console.WriteLine); } - AcDream.App.Net.LiveSessionEventRouter? events = null; - AcDream.App.Net.LiveSessionCommandRouter? commands = null; - try - { - events = new AcDream.App.Net.LiveSessionEventRouter( - session, - _liveEntitySessionEvents.CreateSink(), - new AcDream.App.Net.LiveEnvironmentSessionSink( - OnEnvironChanged, - ticks => - { - WorldTime.SyncFromServer(ticks); - RefreshSkyForCurrentDay(); - }), - CreateLiveInventorySessionBindings(), - CreateLiveCharacterSessionBindings(skillTable), - CreateLiveSocialSessionBindings()); - - commands = new AcDream.App.Net.LiveSessionCommandRouter( - CreateLiveSessionCommandBindings(session)); - return new AcDream.App.Net.LiveSessionBinding( - session, - commands, - activateCommands: commands.Activate, - deactivateCommands: commands.Dispose, - detachEvents: events.Dispose); - } - catch - { - commands?.Dispose(); - events?.Dispose(); - throw; - } + return new AcDream.App.Net.LiveSessionEventRouter( + session, + _liveEntitySessionEvents.CreateSink(), + new AcDream.App.Net.LiveEnvironmentSessionSink( + OnEnvironChanged, + ticks => + { + WorldTime.SyncFromServer(ticks); + RefreshSkyForCurrentDay(); + }), + CreateLiveInventorySessionBindings(), + CreateLiveCharacterSessionBindings(skillTable), + CreateLiveSocialSessionBindings()); } private AcDream.App.Net.LiveInventorySessionBindings @@ -3178,37 +3148,14 @@ public sealed class GameWindow : IDisposable private AcDream.App.Net.LiveCharacterSessionBindings CreateLiveCharacterSessionBindings( - DatReaderWriter.DBObjs.SkillTable? skillTable) => new( + DatReaderWriter.DBObjs.SkillTable? skillTable) + { + var skillCreditResolver = + new AcDream.App.Net.LiveSkillCreditResolver(skillTable); + return new( Combat, SpellBook, - ResolveSkillFormulaBonus: (skillId, attributeCurrents) => - { - // ACE AttributeFormula.GetFormula - // (references/ACE/Source/ACE.Entity/Models/AttributeFormula.cs:55-): - // Attribute1Multiplier == 0 means no attribute contribution. - // Otherwise the retail data formula is - // (attr1 * mult1 + attr2 * mult2) / divisor + additive. - // Guard a zero divisor because malformed/custom DAT input must not - // take down the live-session dispatcher. - if (skillTable?.Skills is null) - return 0u; - if (!skillTable.Skills.TryGetValue( - (DatReaderWriter.Enums.SkillId)skillId, - out var skillBase)) - return 0u; - - var formula = skillBase.Formula; - if (formula.Attribute1Multiplier == 0 || formula.Divisor == 0) - return 0u; - - attributeCurrents.TryGetValue((uint)formula.Attribute1, out uint attribute1); - attributeCurrents.TryGetValue((uint)formula.Attribute2, out uint attribute2); - long numerator = - (long)attribute1 * formula.Attribute1Multiplier + - (long)attribute2 * formula.Attribute2Multiplier; - long bonus = numerator / formula.Divisor + formula.AdditiveBonus; - return bonus < 0 ? 0u : (uint)bonus; - }, + ResolveSkillFormulaBonus: skillCreditResolver.Resolve, OnSkillsUpdated: (runSkill, jumpSkill) => { _localPlayerSkills.Update(runSkill, jumpSkill, _playerController); @@ -3228,6 +3175,7 @@ public sealed class GameWindow : IDisposable (AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1) options1, ClientTime: ClientTimerNow); + } private AcDream.App.Net.LiveSocialSessionBindings CreateLiveSocialSessionBindings() => new( @@ -3676,7 +3624,7 @@ public sealed class GameWindow : IDisposable // optional SettingsPanel and its visibility/input operations. private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm; // L.0: settings.json store + active toon key. The store is held as - // a field so ApplyLiveSessionEnteredWorld can re-load the chosen toon's + // a field so LiveSessionHost can re-load the chosen toon's // bag once we know its name (post-EnterWorld). Toon key starts as // "default" and gets swapped to the actual character name on the // first EnterWorld. @@ -3816,7 +3764,7 @@ public sealed class GameWindow : IDisposable _persistedGameplay = _settingsStore.LoadGameplay(); _persistedChat = _settingsStore.LoadChat(); // _activeToonKey is "default" pre-EnterWorld; the post-login - // ApplyLiveSessionEnteredWorld swaps to the chosen toon's + // LiveSessionHost swaps to the chosen toon's // name and re-loads via SettingsVM.LoadCharacterContext. _persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey); @@ -4159,7 +4107,7 @@ public sealed class GameWindow : IDisposable private void ToggleLiveCombatMode() { AcDream.Core.Net.WorldSession? session = LiveSession; - if (_liveSessionController?.IsInWorld != true || session is null) + if (_liveSessionHost?.IsInWorld != true || session is null) return; IReadOnlyList orderedEquipment = @@ -4193,7 +4141,7 @@ public sealed class GameWindow : IDisposable private void UseItemByGuid(uint guid) { AcDream.Core.Net.WorldSession? session = LiveSession; - if (_liveSessionController?.IsInWorld != true || session is null) + if (_liveSessionHost?.IsInWorld != true || session is null) return; uint sequence = session.NextGameActionSequence(); session.SendGameAction( @@ -4418,7 +4366,10 @@ public sealed class GameWindow : IDisposable } if (ReferenceEquals(_liveSessionController, controller)) + { + _liveSessionHost = null; _liveSessionController = null; + } }), ]), // Frame composition borrows equipped, effect, audio, and render diff --git a/src/AcDream.Core.Net/SubscriptionSet.cs b/src/AcDream.Core.Net/SubscriptionSet.cs index 5f271c02..9ba63f6d 100644 --- a/src/AcDream.Core.Net/SubscriptionSet.cs +++ b/src/AcDream.Core.Net/SubscriptionSet.cs @@ -7,36 +7,48 @@ namespace AcDream.Core.Net; /// internal sealed class SubscriptionSet : IDisposable { - private List? _subscriptions = []; + private readonly object _gate = new(); + private readonly List _subscriptions = []; + private bool _disposeRequested; public void Add(IDisposable subscription) { ArgumentNullException.ThrowIfNull(subscription); - List? subscriptions = _subscriptions; - if (subscriptions is null) - { - subscription.Dispose(); - throw new ObjectDisposedException(nameof(SubscriptionSet)); - } - - subscriptions.Add(subscription); + AddRetained(new RetryableSubscription(subscription.Dispose)); } public void Add(Action unsubscribe) { ArgumentNullException.ThrowIfNull(unsubscribe); - Add(new ActionSubscription(unsubscribe)); + AddRetained(new RetryableSubscription(unsubscribe)); + } + + private void AddRetained(RetryableSubscription retained) + { + bool disposeNow; + lock (_gate) + { + disposeNow = _disposeRequested; + _subscriptions.Add(retained); + } + + if (!disposeNow) + return; + retained.Dispose(); + throw new ObjectDisposedException(nameof(SubscriptionSet)); } public void Dispose() { - List? subscriptions = - Interlocked.Exchange(ref _subscriptions, null); - if (subscriptions is null) - return; + RetryableSubscription[] subscriptions; + lock (_gate) + { + _disposeRequested = true; + subscriptions = _subscriptions.ToArray(); + } List? errors = null; - for (int i = subscriptions.Count - 1; i >= 0; i--) + for (int i = subscriptions.Length - 1; i >= 0; i--) { try { @@ -52,10 +64,59 @@ internal sealed class SubscriptionSet : IDisposable throw new AggregateException("one or more subscriptions failed to detach", errors); } - private sealed class ActionSubscription(Action unsubscribe) : IDisposable + private sealed class RetryableSubscription(Action dispose) : IDisposable { - private Action? _unsubscribe = unsubscribe; + private readonly object _gate = new(); + private Action? _dispose = dispose; + private bool _executing; + private int _executingThreadId; - public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke(); + public void Dispose() + { + Action? operation; + int threadId = Environment.CurrentManagedThreadId; + lock (_gate) + { + while (_executing) + { + if (_executingThreadId == threadId) + { + throw new InvalidOperationException( + "Subscription cleanup cannot complete reentrantly."); + } + Monitor.Wait(_gate); + } + + operation = _dispose; + if (operation is null) + return; + _executing = true; + _executingThreadId = threadId; + } + + try + { + operation(); + } + catch + { + CompleteAttempt(succeeded: false); + throw; + } + + CompleteAttempt(succeeded: true); + } + + private void CompleteAttempt(bool succeeded) + { + lock (_gate) + { + if (succeeded) + _dispose = null; + _executing = false; + _executingThreadId = 0; + Monitor.PulseAll(_gate); + } + } } } diff --git a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs index 8d65f140..6eb45476 100644 --- a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs @@ -11,7 +11,7 @@ public sealed class GameWindowLiveSessionOwnershipTests BindingFlags.Instance | BindingFlags.NonPublic; [Fact] - public void GameWindowRetainsOnlyTheLifecycleControllerSessionOwner() + public void GameWindowRetainsControllerAndFocusedHostButNoMirroredSession() { FieldInfo[] fields = typeof(GameWindow).GetFields(PrivateInstance); @@ -19,8 +19,13 @@ public sealed class GameWindowLiveSessionOwnershipTests fields, field => field.Name == "_liveSessionController" && field.FieldType == typeof(LiveSessionController)); + Assert.Contains( + fields, + field => field.Name == "_liveSessionHost" + && field.FieldType == typeof(LiveSessionHost)); Assert.DoesNotContain(fields, field => field.Name == "_liveSession"); Assert.DoesNotContain(fields, field => field.FieldType == typeof(WorldSession)); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(LiveSessionResetPlan)); Assert.DoesNotContain(fields, field => field.Name == "_liveSessionEvents"); Assert.DoesNotContain(fields, field => field.Name == "_liveSessionCommands"); } @@ -30,6 +35,9 @@ public sealed class GameWindowLiveSessionOwnershipTests [InlineData("ClearInboundEntityState")] [InlineData("WireLiveSessionEvents")] [InlineData("DisposeLiveSessionRouting")] + [InlineData("CreateLiveSessionBinding")] + [InlineData("ApplyLiveSessionSelection")] + [InlineData("ApplyLiveSessionEnteredWorld")] public void DisplacedLifecycleBodiesAreAbsent(string methodName) { Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateInstance)); diff --git a/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs index 8402cbfb..bda1e946 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs @@ -13,6 +13,33 @@ namespace AcDream.App.Tests.Net; public sealed class LiveSessionEventRouterTests { + [Fact] + public void CreatedRouterPublishesNoHandlersUntilExplicitAttach() + { + using var session = NewSession(); + int baselineGameEvents = session.GameEvents.RegisteredHandlerCount; + var router = new LiveSessionEventRouter( + session, + new LiveEntitySessionSink( + _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, + _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }), + new LiveEnvironmentSessionSink(_ => { }, _ => { }), + NewInventoryBindings(), + NewCharacterBindings(), + NewSocialBindings()); + + AssertSessionHandlerCounts(session, multiplier: 0); + Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); + + router.Attach(); + AssertSessionHandlerCounts(session, multiplier: 1); + Assert.True(session.GameEvents.RegisteredHandlerCount > baselineGameEvents); + + router.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 0); + Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); + } + [Fact] public void Dispose_DetachesExactHandlersAndSilencesCopiedDelegates() { @@ -157,33 +184,46 @@ public sealed class LiveSessionEventRouterTests Counters counters, Action? constructionCheckpoint = null, CombatState? combat = null, - ChatLog? chat = null) => new( - session, - new LiveEntitySessionSink( - Spawned: _ => { }, - Deleted: _ => { }, - PickedUp: _ => { }, - MotionUpdated: _ => { }, - PositionUpdated: _ => { }, - VectorUpdated: _ => { }, - StateUpdated: _ => { }, - ParentUpdated: _ => { }, - TeleportStarted: _ => counters.Teleport++, - AppearanceUpdated: _ => { }, - PlayPhysicsScript: _ => { }, - PlayPhysicsScriptType: _ => { }), - new LiveEnvironmentSessionSink( - EnvironChanged: _ => { }, - ServerTimeUpdated: _ => - { - if (counters.ThrowOnServerTime) - throw new InvalidOperationException("injected sink failure"); - counters.ServerTime++; - }), - NewInventoryBindings(), - NewCharacterBindings(combat), - NewSocialBindings(chat), - constructionCheckpoint); + ChatLog? chat = null) + { + var router = new LiveSessionEventRouter( + session, + new LiveEntitySessionSink( + Spawned: _ => { }, + Deleted: _ => { }, + PickedUp: _ => { }, + MotionUpdated: _ => { }, + PositionUpdated: _ => { }, + VectorUpdated: _ => { }, + StateUpdated: _ => { }, + ParentUpdated: _ => { }, + TeleportStarted: _ => counters.Teleport++, + AppearanceUpdated: _ => { }, + PlayPhysicsScript: _ => { }, + PlayPhysicsScriptType: _ => { }), + new LiveEnvironmentSessionSink( + EnvironChanged: _ => { }, + ServerTimeUpdated: _ => + { + if (counters.ThrowOnServerTime) + throw new InvalidOperationException("injected sink failure"); + counters.ServerTime++; + }), + NewInventoryBindings(), + NewCharacterBindings(combat), + NewSocialBindings(chat), + constructionCheckpoint); + try + { + router.Attach(); + return router; + } + catch + { + router.Dispose(); + throw; + } + } private static LiveInventorySessionBindings NewInventoryBindings() => new( new ClientObjectTable(), diff --git a/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs new file mode 100644 index 00000000..1bf94684 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs @@ -0,0 +1,391 @@ +using System.Net; +using AcDream.App.Net; +using AcDream.App.Rendering; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Tests.Net; + +public sealed class LiveSessionHostTests +{ + [Fact] + public void HostOwnsRouteSelectionAndEntryOrderingWithoutMirroringSession() + { + var calls = new List(); + var operations = new TestOperations(calls); + var controller = new LiveSessionController(operations); + var events = new TestEventRouting(calls); + var commands = new TestCommandRouting(calls); + LiveSessionHost host = CreateHost( + controller, + calls, + _ => + { + calls.Add("events"); + return events; + }, + _ => + { + calls.Add("commands"); + return commands; + }); + + LiveSessionStartResult result = host.Start(LiveOptions()); + + Assert.Equal(LiveSessionStartStatus.Connected, result.Status); + Assert.Equal( + [ + "reset", "resolve", "create", "events", "attach-events", "commands", + "connecting", "connect", "connected", + "player:1342177282", "vitals:1342177282", + "chat:1342177282", "persistent:1342177282", + "vanish:1342177282", "clear-combat", "enter:1", + "activate", "active:Ready", "restore-layout", + "sync-toolbar", "load-settings:Ready", "arm-auto-entry", + ], + calls); + Assert.Same(controller.CurrentSession, host.CurrentSession); + Assert.Same(commands, host.Commands); + Assert.True(host.IsInWorld); + + controller.Dispose(); + } + + [Fact] + public void DisabledAndMissingCredentialStartsExecuteTheCanonicalResetPlan() + { + var calls = new List(); + var operations = new TestOperations(calls); + var controller = new LiveSessionController(operations); + LiveSessionHost host = CreateHost( + controller, + calls, + _ => throw new InvalidOperationException("must not bind"), + _ => throw new InvalidOperationException("must not bind")); + + Assert.Equal( + LiveSessionStartStatus.Disabled, + host.Start(LiveOptions(live: false)).Status); + Assert.Equal( + LiveSessionStartStatus.MissingCredentials, + host.Start(LiveOptions(user: null)).Status); + + Assert.Equal(["reset", "reset"], calls); + Assert.Null(host.CurrentSession); + Assert.False(host.IsInWorld); + } + + [Fact] + public void CommandFactoryFailureRollsBackTheAlreadyAttachedEventRoute() + { + var calls = new List(); + var operations = new TestOperations(calls); + var controller = new LiveSessionController(operations); + var events = new TestEventRouting(calls); + LiveSessionHost host = CreateHost( + controller, + calls, + _ => events, + _ => throw new InvalidOperationException("command failure")); + + LiveSessionStartResult result = host.Start(LiveOptions()); + + Assert.Equal(LiveSessionStartStatus.Failed, result.Status); + Assert.IsType(result.Error); + Assert.Equal(1, events.DisposeCount); + Assert.Contains("dispose-events", calls); + Assert.Null(host.CurrentSession); + } + + [Fact] + public void AttachFailureRetainsThePublishedRouteUntilRollbackConverges() + { + var calls = new List(); + var operations = new TestOperations(calls); + var controller = new LiveSessionController(operations); + var firstEvents = new TestEventRouting(calls) + { + AttachFailuresRemaining = 1, + FailuresRemaining = 1, + }; + int eventFactoryCount = 0; + int commandFactoryCount = 0; + LiveSessionHost host = CreateHost( + controller, + calls, + _ => eventFactoryCount++ == 0 + ? firstEvents + : new TestEventRouting(calls), + _ => + { + commandFactoryCount++; + return new TestCommandRouting(calls); + }); + + LiveSessionStartResult failed = host.Start(LiveOptions()); + + Assert.Equal(LiveSessionStartStatus.Failed, failed.Status); + Assert.Equal(1, firstEvents.AttachCount); + Assert.Equal(2, firstEvents.DisposeCount); + Assert.Equal(0, commandFactoryCount); + + LiveSessionStartResult retry = host.Start(LiveOptions()); + + Assert.Equal(LiveSessionStartStatus.Connected, retry.Status); + Assert.Equal(2, eventFactoryCount); + Assert.Equal(1, commandFactoryCount); + controller.Dispose(); + } + + [Fact] + public void TransientRollbackFailureIsReportedRetriedAndConvergesBeforeNextStart() + { + var calls = new List(); + var operations = new TestOperations(calls); + var controller = new LiveSessionController(operations); + var firstEvents = new TestEventRouting(calls) { FailuresRemaining = 1 }; + int eventFactoryCount = 0; + bool failCommands = true; + LiveSessionHost host = CreateHost( + controller, + calls, + _ => eventFactoryCount++ == 0 + ? firstEvents + : new TestEventRouting(calls), + _ => + { + if (failCommands) + { + failCommands = false; + throw new InvalidOperationException("command failure"); + } + return new TestCommandRouting(calls); + }); + + LiveSessionStartResult failed = host.Start(LiveOptions()); + + AggregateException error = Assert.IsType(failed.Error); + Assert.Equal(2, error.InnerExceptions.Count); + Assert.Contains(error.InnerExceptions, e => e.Message == "command failure"); + Assert.Contains(error.InnerExceptions, e => e.Message == "event cleanup failure"); + Assert.Equal(2, firstEvents.DisposeCount); + + LiveSessionStartResult retry = host.Start(LiveOptions()); + + Assert.Equal(LiveSessionStartStatus.Connected, retry.Status); + Assert.Equal(2, eventFactoryCount); + Assert.Equal(2, operations.Sessions.Count); + controller.Dispose(); + } + + [Fact] + public void PersistentRollbackFailureBlocksEveryLaterGeneration() + { + var calls = new List(); + var operations = new TestOperations(calls); + var controller = new LiveSessionController(operations); + var events = new TestEventRouting(calls) + { + FailuresRemaining = int.MaxValue, + }; + int eventFactoryCount = 0; + int commandFactoryCount = 0; + LiveSessionHost host = CreateHost( + controller, + calls, + _ => + { + eventFactoryCount++; + return events; + }, + _ => + { + commandFactoryCount++; + throw new InvalidOperationException("command failure"); + }); + + LiveSessionStartResult first = host.Start(LiveOptions()); + LiveSessionStartResult second = host.Start(LiveOptions()); + + Assert.Equal(LiveSessionStartStatus.Failed, first.Status); + Assert.Equal(LiveSessionStartStatus.Failed, second.Status); + Assert.Equal(3, events.DisposeCount); + Assert.Equal(1, eventFactoryCount); + Assert.Equal(1, commandFactoryCount); + Assert.Single(operations.Sessions); + Assert.Null(host.CurrentSession); + } + + private static LiveSessionHost CreateHost( + LiveSessionController controller, + List calls, + Func createEvents, + Func createCommands) => + new(controller, new LiveSessionHostBindings( + Routing: new(createEvents, createCommands), + Reset: ResetBindings(() => calls.Add("reset")), + Selection: new( + id => calls.Add($"player:{id}"), + id => calls.Add($"vitals:{id}"), + id => calls.Add($"chat:{id}"), + id => calls.Add($"persistent:{id}"), + id => calls.Add($"vanish:{id}"), + () => calls.Add("clear-combat")), + EnteredWorld: new( + name => calls.Add($"active:{name}"), + () => calls.Add("restore-layout"), + () => calls.Add("sync-toolbar"), + name => calls.Add($"load-settings:{name}"), + () => calls.Add("arm-auto-entry")), + Connecting: (_, _, _) => calls.Add("connecting"), + Connected: () => calls.Add("connected"))); + + private static LiveSessionResetBindings ResetBindings(Action reset) + { + Action noop = static () => { }; + return new LiveSessionResetBindings + { + MouseCapture = reset, + PlayerPresentation = noop, + TeleportTransit = noop, + SessionDialogs = noop, + ChatCommandTargets = noop, + SettingsCharacterContext = noop, + EquippedChildren = noop, + ExternalContainer = noop, + InteractionAndSelection = noop, + SelectionPresentation = noop, + ObjectTable = noop, + Spellbook = noop, + MagicRuntime = noop, + CombatAttack = noop, + CombatState = noop, + ItemMana = noop, + LocalPlayer = noop, + Friends = noop, + Squelch = noop, + TurbineChat = noop, + ParticleVisibility = noop, + InboundEventFifo = noop, + LiveLiveness = noop, + LiveRuntime = noop, + SessionIdentity = noop, + RemoteTeleport = noop, + NetworkEffects = noop, + AnimationHookFrames = noop, + LivePresentation = noop, + RemoteMovementDiagnostics = noop, + }; + } + + private static RuntimeOptions LiveOptions(bool live = true, string? user = "user") + { + var environment = new Dictionary + { + ["ACDREAM_LIVE"] = live ? "1" : "0", + ["ACDREAM_TEST_HOST"] = "127.0.0.1", + ["ACDREAM_TEST_PORT"] = "9000", + ["ACDREAM_TEST_USER"] = user, + ["ACDREAM_TEST_PASS"] = "password", + }; + return RuntimeOptions.Parse("dat", environment.GetValueOrDefault); + } + + private sealed class TestEventRouting(List calls) + : ILiveSessionEventRouting + { + public int FailuresRemaining { get; set; } + public int AttachFailuresRemaining { get; set; } + public int AttachCount { get; private set; } + public int DisposeCount { get; private set; } + + public void Attach() + { + AttachCount++; + calls.Add("attach-events"); + if (AttachFailuresRemaining > 0) + { + AttachFailuresRemaining--; + throw new InvalidOperationException("event attach failure"); + } + } + + public void Dispose() + { + DisposeCount++; + calls.Add("dispose-events"); + if (FailuresRemaining > 0) + { + FailuresRemaining--; + throw new InvalidOperationException("event cleanup failure"); + } + } + } + + private sealed class TestCommandRouting(List calls) + : ILiveSessionCommandRouting + { + public void Activate() => calls.Add("activate"); + public void Publish(T command) where T : notnull { } + public void Dispose() => calls.Add("dispose-commands"); + } + + private sealed class TestOperations(List calls) : ILiveSessionOperations + { + public List Sessions { get; } = []; + + public IPEndPoint ResolveEndpoint(string host, int port) + { + calls.Add("resolve"); + return new IPEndPoint(IPAddress.Loopback, port); + } + + public WorldSession CreateSession(IPEndPoint endpoint) + { + calls.Add("create"); + var session = new WorldSession(endpoint, new TestTransport()); + Sessions.Add(session); + return session; + } + + public void Connect(WorldSession session, string user, string password) => + calls.Add("connect"); + + public CharacterList.Parsed? GetCharacters(WorldSession session) => new( + 0u, + [ + new CharacterList.Character(0x50000001u, "Grey", 10u), + new CharacterList.Character(0x50000002u, "Ready", 0u), + ], + [], + 11, + "Canonical", + true, + true); + + public void EnterWorld(WorldSession session, int activeCharacterIndex) => + calls.Add($"enter:{activeCharacterIndex}"); + + public void Tick(WorldSession session) { } + + public void DisposeSession(WorldSession session) + { + calls.Add("dispose-session"); + session.Dispose(); + } + } + + private sealed class TestTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) { } + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + + public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) + { + from = null; + return null; + } + + public void Dispose() { } + } +} diff --git a/tests/AcDream.App.Tests/Net/LiveSessionSubscriptionSetTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionSubscriptionSetTests.cs new file mode 100644 index 00000000..e120aabc --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionSubscriptionSetTests.cs @@ -0,0 +1,60 @@ +using AcDream.App.Net; + +namespace AcDream.App.Tests.Net; + +public sealed class LiveSessionSubscriptionSetTests +{ + [Fact] + public void Dispose_RetriesOnlyFailedEdgesUntilTheyConverge() + { + var order = new List(); + var subscriptions = new LiveSessionSubscriptionSet(); + subscriptions.Add(() => order.Add(1)); + int secondFailures = 1; + subscriptions.Add(() => + { + order.Add(2); + if (secondFailures-- > 0) + throw new InvalidOperationException("second failed"); + }); + int thirdFailures = 1; + subscriptions.Add(() => + { + order.Add(3); + if (thirdFailures-- > 0) + throw new IOException("third failed"); + }); + + AggregateException error = Assert.Throws( + subscriptions.Dispose); + + Assert.Equal([3, 2, 1], order); + Assert.Collection( + error.InnerExceptions, + exception => Assert.IsType(exception), + exception => Assert.IsType(exception)); + + subscriptions.Dispose(); + subscriptions.Dispose(); + + Assert.Equal([3, 2, 1, 3, 2], order); + } + + [Fact] + public void Dispose_PersistentFailureNeverReplaysSuccessfulEdges() + { + var order = new List(); + var subscriptions = new LiveSessionSubscriptionSet(); + subscriptions.Add(() => order.Add(1)); + subscriptions.Add(() => + { + order.Add(2); + throw new InvalidOperationException("persistent"); + }); + + Assert.Throws(subscriptions.Dispose); + Assert.Throws(subscriptions.Dispose); + + Assert.Equal([2, 1, 2], order); + } +} diff --git a/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs b/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs new file mode 100644 index 00000000..50db95f1 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs @@ -0,0 +1,112 @@ +using AcDream.App.Net; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Net; + +public sealed class RetailSkillFormulaTests +{ + [Fact] + public void ZeroDivisorIsTheOnlyFormulaFailureGate() + { + SkillFormula invalid = Formula(w: 7, x: 1, y: 1, z: 0); + Assert.False(RetailSkillFormula.TryCalculate(invalid, 10u, 20u, out uint invalidResult)); + Assert.Equal(0u, invalidResult); + + SkillFormula zeroX = Formula(w: 7, x: 0, y: 2, z: 3); + Assert.True(RetailSkillFormula.TryCalculate(zeroX, 10u, 4u, out uint validResult)); + Assert.Equal(5u, validResult); + } + + [Theory] + [InlineData(9u, 4u, 2u)] + [InlineData(10u, 4u, 3u)] + [InlineData(11u, 4u, 3u)] + [InlineData(12u, 4u, 3u)] + public void DivisionRoundsToNearestWithExactHalvesUp( + uint numerator, + uint divisor, + uint expected) + { + SkillFormula formula = Formula(w: 0, x: 1, y: 0, z: divisor); + + Assert.True(RetailSkillFormula.TryCalculate( + formula, + numerator, + 0u, + out uint result)); + Assert.Equal(expected, result); + } + + [Fact] + public void AdditiveWordIsInsideTheNumerator() + { + SkillFormula formula = Formula(w: 3, x: 1, y: 1, z: 4); + + Assert.True(RetailSkillFormula.TryCalculate(formula, 4u, 2u, out uint result)); + + Assert.Equal(2u, result); + } + + [Fact] + public void NumeratorUsesUnsignedThirtyTwoBitWrap() + { + SkillFormula formula = Formula(w: 1, x: 2, y: 0, z: 2); + + Assert.True(RetailSkillFormula.TryCalculate( + formula, + uint.MaxValue, + 0u, + out uint result)); + + Assert.Equal(0x80000000u, result); + } + + [Fact] + public void SignedDatStorageIsReinterpretedAsRetailUnsignedWords() + { + SkillFormula formula = Formula(w: -1, x: 0, y: 0, z: 1); + + Assert.True(RetailSkillFormula.TryCalculate(formula, 0u, 0u, out uint result)); + + Assert.Equal(uint.MaxValue, result); + } + + [Fact] + public void LiveResolverLooksUpTheDatFormulaAndTreatsMissingAttributesAsZero() + { + const uint skillId = 0x345u; + const uint firstAttributeId = 1u; + const uint secondAttributeId = 2u; + var skillTable = new SkillTable(); + skillTable.Skills.Add((SkillId)skillId, new SkillBase + { + Formula = new SkillFormula + { + AdditiveBonus = 1, + Attribute1Multiplier = 1, + Attribute2Multiplier = 2, + Divisor = 3, + Attribute1 = (AttributeId)firstAttributeId, + Attribute2 = (AttributeId)secondAttributeId, + }, + }); + var resolver = new LiveSkillCreditResolver(skillTable); + + uint result = resolver.Resolve( + skillId, + new Dictionary { [firstAttributeId] = 8u }); + + Assert.Equal(3u, result); + Assert.Equal(0u, resolver.Resolve(0x999u, new Dictionary())); + } + + private static SkillFormula Formula(int w, int x, int y, uint z) => new() + { + AdditiveBonus = w, + Attribute1Multiplier = x, + Attribute2Multiplier = y, + Divisor = unchecked((int)z), + }; +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index e2f0da7d..e2e915b1 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -48,7 +48,7 @@ public sealed class GameWindowSlice8BoundaryTests { string body = MethodBody( "private void OnLoad()", - "private AcDream.App.Net.LiveSessionLifecycleHost"); + "private AcDream.App.Net.LiveSessionHost"); AssertAppearsInOrder( body, @@ -71,9 +71,10 @@ public sealed class GameWindowSlice8BoundaryTests "_liveEntities = new AcDream.App.World.LiveEntityRuntime(", "_renderFrameOrchestrator =", "_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(", - "_liveSessionController.Start("); - Assert.Equal(1, CountOccurrences(body, "_liveSessionController.Start(")); - int start = body.IndexOf("_liveSessionController.Start(", StringComparison.Ordinal); + "_liveSessionHost = CreateLiveSessionHost();", + "_liveSessionHost.Start(_options)"); + Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)")); + int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal); string postStart = body[start..]; Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal); Assert.Empty(System.Text.RegularExpressions.Regex.Matches( diff --git a/tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs b/tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs index 658983b1..c0d95557 100644 --- a/tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs +++ b/tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs @@ -18,20 +18,24 @@ public sealed class SubscriptionSetTests } [Fact] - public void Dispose_AttemptsEveryReleaseAndAggregatesFailures() + public void Dispose_RetriesOnlyFailedReleasesUntilTheyConverge() { var order = new List(); var subscriptions = new SubscriptionSet(); subscriptions.Add(() => order.Add(1)); + int secondFailures = 1; subscriptions.Add(() => { order.Add(2); - throw new InvalidOperationException("second failed"); + if (secondFailures-- > 0) + throw new InvalidOperationException("second failed"); }); + int thirdFailures = 1; subscriptions.Add(() => { order.Add(3); - throw new IOException("third failed"); + if (thirdFailures-- > 0) + throw new IOException("third failed"); }); AggregateException error = Assert.Throws( @@ -42,6 +46,28 @@ public sealed class SubscriptionSetTests error.InnerExceptions, exception => Assert.IsType(exception), exception => Assert.IsType(exception)); + subscriptions.Dispose(); + subscriptions.Dispose(); + + Assert.Equal([3, 2, 1, 3, 2], order); + } + + [Fact] + public void Dispose_PersistentFailureNeverReplaysSuccessfulReleases() + { + var order = new List(); + var subscriptions = new SubscriptionSet(); + subscriptions.Add(() => order.Add(1)); + subscriptions.Add(() => + { + order.Add(2); + throw new InvalidOperationException("persistent"); + }); + + Assert.Throws(subscriptions.Dispose); + Assert.Throws(subscriptions.Dispose); + + Assert.Equal([2, 1, 2], order); } } From d09e246d3a973ea828efc47acaa47e0a212a2885 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 10:54:33 +0200 Subject: [PATCH 94/98] refactor(world): own live environment state Move the DAT sky, selected day group, world clock, weather, AdminEnvirons bridge, and debug cycles into a one-shot WorldEnvironmentController while preserving GameWindow's public aliases and accepted startup/session/render order. Correct the named retail citations and register the remaining environment audio and fog/radar gaps. Co-authored-by: Codex --- AGENTS.md | 12 +- CLAUDE.md | 12 +- docs/ISSUES.md | 9 +- docs/architecture/code-structure.md | 5 +- .../retail-divergence-register.md | 9 +- docs/plans/2026-04-11-roadmap.md | 14 +- docs/plans/2026-05-12-milestones.md | 8 +- ...amewindow-slice-8-composition-lifecycle.md | 17 +- .../research/2026-04-23-daygroup-selection.md | 40 ++- .../2026-04-23-retail-memory-probe.md | 6 +- memory/project_gamewindow_decomposition.md | 22 +- src/AcDream.App/Rendering/GameWindow.cs | 295 ++---------------- .../Rendering/WorldSceneRuntimeSources.cs | 19 -- .../World/WorldEnvironmentController.cs | 249 +++++++++++++++ src/AcDream.Core/World/DerethDateTime.cs | 10 +- src/AcDream.Core/World/SkyDescLoader.cs | 23 +- src/AcDream.Core/World/WeatherState.cs | 2 +- .../GameWindowSlice8BoundaryTests.cs | 35 +++ .../World/WorldEnvironmentControllerTests.cs | 187 +++++++++++ 19 files changed, 606 insertions(+), 368 deletions(-) create mode 100644 src/AcDream.App/World/WorldEnvironmentController.cs create mode 100644 tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs diff --git a/AGENTS.md b/AGENTS.md index 6df5a163..1c2198b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,14 +127,16 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–C are also complete: dead host residue is removed, +Slice 8 checkpoints A–D are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical -session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods, -down 11,134 lines (70.8%) from the 15,723-line campaign baseline. The 7,408-test +session controller. `WorldEnvironmentController` solely owns the clock, DAT +sky/day group, weather, synchronization, and AdminEnvirons state. `GameWindow` +is 4,330 raw lines / 192 fields / 67 methods, down 11,393 lines (72.5%) from +the 15,723-line campaign baseline. The 7,419-test Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second -synchronized nine-stop soak pass. Slice 8 checkpoints D–L—world environment, -input/resize, action routing, settings, resource ownership, ordered composition, +synchronized nine-stop soak pass. Slice 8 checkpoints E–L—input/resize, action +routing, settings, resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, diff --git a/CLAUDE.md b/CLAUDE.md index d78eec38..1a38cd9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,14 +125,16 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–C are also complete: dead host residue is removed, +Slice 8 checkpoints A–D are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical -session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods, -down 11,134 lines (70.8%) from the 15,723-line campaign baseline. The 7,408-test +session controller. `WorldEnvironmentController` solely owns the clock, DAT +sky/day group, weather, synchronization, and AdminEnvirons state. `GameWindow` +is 4,330 raw lines / 192 fields / 67 methods, down 11,393 lines (72.5%) from +the 15,723-line campaign baseline. The 7,419-test Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second -synchronized nine-stop soak pass. Slice 8 checkpoints D–L—world environment, -input/resize, action routing, settings, resource ownership, ordered composition, +synchronized nine-stop soak pass. Slice 8 checkpoints E–L—input/resize, action +routing, settings, resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 331b0497..4ccdb45a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -27,10 +27,11 @@ What does NOT go here: ## Current queue — 2026-07-22 - **Active structural work:** `GameWindow` decomposition. Slices 1–7 are - complete; Slice 8 checkpoints A–C now own native callbacks and live-session - composition outside the shell. `GameWindow.OnUpdate` and - `GameWindow.OnRender` are typed orchestration handoffs and the class is 4,589 - lines / 196 fields / 69 methods. Remaining Slice 8 composition/shutdown + complete; Slice 8 checkpoints A–D now own native callbacks, live-session + composition, and world-environment state outside the shell. + `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration + handoffs and the class is 4,330 lines / 192 fields / 67 methods. Remaining + Slice 8 input/settings/composition/shutdown cleanup is active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index e967c89d..e5e4e6b3 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -31,6 +31,7 @@ after Slice 4 10,301 lines / 267 fields / 163 method after Slice 5 closeout 8,811 lines / 247 fields / 153 methods after Slice 6 closeout 7,026 lines / 241 fields / 108 methods after Slice 7 draw-frame cutover 4,666 lines / 196 fields / 70 methods +after Slice 8 checkpoint D 4,330 lines / 192 fields / 67 methods ``` `GameWindow` is the single object that: @@ -256,6 +257,7 @@ src/AcDream.App/ │ ├── RetailInboundEventDispatcher.cs # update-thread packet/frame FIFO barrier │ ├── RetailLiveFrameCoordinator.cs # shipped: object/network/command/reconcile phase order │ ├── LiveEntityPresentationController.cs # ordered Hidden/NoDraw/effect/collision side effects +│ ├── WorldEnvironmentController.cs # one-shot clock/DAT-sky/day/weather/AdminEnvirons owner │ ├── LiveEntityTeardown.cs # failure-isolated multi-owner lifecycle drain │ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations ├── Interaction/ @@ -439,13 +441,14 @@ useful ordering seam, but its ownership status is **partial**. |---|---|---| | Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. | | Network session | **Complete** | `LiveSessionController` remains the sole resolve/create/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal and current-session owner. `LiveSessionHost` owns App reset/selection/entry ordering plus create→attach route factories; `LiveSessionLifecycleHost` guards exact borrowed-session attachment. `LiveSessionEventRouter` and `LiveSessionCommandRouter` own exact inbound/outbound lifetimes, with retryable per-edge teardown before reset or replacement. `GameWindow` retains the controller, focused host, and typed domain binding factories—no mirrored session or reset plan. | +| World environment | **Complete ownership** | `WorldEnvironmentController` solely owns WorldTime, the loaded DAT sky, current day group, Weather, server synchronization, AdminEnvirons bridging, and debug time/weather cycles. `GameWindow` keeps ABI-compatible readonly aliases to the same clock/weather instances and composes one render source plus the two live-session callbacks. TS-54/TS-55 register the remaining centered UI sound and full fog/ambient/radar behavior gaps. | | Live identity/lifetime | **Complete App integration** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, and logical/spatial lifetime. `LiveEntityHydrationController`, `LiveEntityRuntimeTeardownController`, and `LiveEntityNetworkUpdateController` own DAT-backed hydration, retryable cleanup, and accepted Position/Vector/State/Movement presentation without a second GUID owner (`aa90c646`). | | Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). | | World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. | | Retained gameplay UI | **Mostly complete feature ownership** | `RetailUiRuntime` and focused panel/controllers own layout and behavior. `GameWindow.OnLoad` still performs substantial service composition, which is allowed until the final composition cleanup. | | Selection/interaction | **Complete** | `WorldSelectionQuery` owns read-only picking/classification/range queries; `SelectionInteractionController` owns selection intent, Use/PickUp transport, exact-incarnation queues, and auto-walk deferral; `ItemInteractionController` owns ItemHolder policy plus the shared retail inventory-request transaction. `GameWindow` retains construction and narrow lifecycle forwarding only. | | Landblock presentation | **Complete** | `LandblockBuildFactory` owns the captured-origin DAT transaction; concrete render/physics/DAT-static publishers and `LandblockPresentationPipeline` own publication and exact retryable retirement. `StreamingOriginRecenterCoordinator` serializes old-window retirement with teleport/session origin lifetimes. `GameWindow` retains construction and one pipeline field only (`c79d0a49`, closeout `4a205a3e`). | -| Render-frame orchestration | **Not extracted** | `OnRender` plus its portal/PView/alpha/particle helpers still own the draw graph and frame-local scratch state. There is no `RenderFrameOrchestrator` yet. | +| Render-frame orchestration | **Complete** | `RenderFrameOrchestrator` owns the GPU-flight, resource, world/PView/shared-alpha, private-presentation, diagnostics, screenshot, and recovery graph. `GameWindow.OnRender` takes one logical window-size snapshot and performs one immutable handoff (`9d7df1bf`). | | Unified `GameEntity` | **Deferred target** | `LiveEntityRuntime` already supplies the critical canonical owner. Full type aggregation is a separate high-risk migration and is not required to make `GameWindow` thin. | ### 4.3 Revised extraction sequence diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3949a045..30eb2abc 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -218,7 +218,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | | AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` | -## 4. Temporary stopgap (TS) — 35 active rows + 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) — 37 active rows + 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 | |---|---|---|---|---|---| @@ -261,10 +261,12 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveObjectFrameController` + `LiveEffectFrameController` shared `_particles.Tick` / `_scripts.Tick` tail); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order | | TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 | | TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | +| TS-54 | AdminEnvirons sound values `0x65..0x7B` are diagnosed by retail enum name but do not play audio. Retail checks that the local player physics object and UI sound table exist, then calls `SoundManager::PlaySoundFromCenter(Sound_UI_*, table)` for Roar through Thunder6. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`) | The current audio owner has no typed retail UI-sound-table binding; logging preserves the inbound evidence without inventing wave DIDs or routing the sounds through positional world audio. | Server-authored ambience/thunder packets are silent in acdream while retail plays the centered UI sound. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055E07F..0x0055E2C7`); `SoundManager::PlaySoundFromCenter @ 0x00550950` | +| TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) | --- -## 5. Unclear (UN) — 5 rows +## 5. Unclear (UN) — 4 rows These rows have a missing, contradictory, or never-argued justification. They are the highest-priority audits: each needs either a recorded @@ -273,7 +275,6 @@ equivalence argument (promote to AD/AP) or a fix. | # | Divergence | Where (file:line) | Recorded justification (deficient) | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 | -| UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs | | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | | UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md | @@ -294,7 +295,7 @@ WITH that phase, not before. 5. **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. 6. **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. 7. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger. -8. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler. +8. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together. 9. **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):** diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 68735a60..fec0c3dc 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-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–C are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–D are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. 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. --- @@ -79,12 +79,12 @@ second synchronized soak pass with graceful exits, and the six stable PNG checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse process-residency gate's diagnostic blind spot; no tolerance was loosened. -Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–C now own -the frozen shell boundary, all nine native callback edges, and live-session -reset/selection/entry/route composition. `GameWindow` is 4,589 lines / 196 -fields / 69 methods; the Checkpoint C Release suite passes 7,408 tests / 5 -skips. Environment, input, settings, startup/resource transfer, and shutdown -checkpoints D–L remain. +Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–D now own +the frozen shell boundary, all nine native callback edges, live-session +reset/selection/entry/route composition, and world clock/sky/day/weather/ +AdminEnvirons state. `GameWindow` is 4,330 lines / 192 fields / 67 methods; +the Checkpoint D Release suite passes 7,419 tests / 5 skips. Input, settings, +startup/resource transfer, and shutdown checkpoints E–L remain. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 403b3a17..1c92eb8d 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -39,10 +39,12 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized nine-destination soak pass with graceful exits, and six stable PNG checkpoints -preserve Slice 6 presentation. Final Slice 8 checkpoints A–C are complete: +preserve Slice 6 presentation. Final Slice 8 checkpoints A–D are complete: native callbacks and live-session composition now have explicit retryable -owners, and `GameWindow` is 4,589 lines / 196 fields / 69 methods. Checkpoints -D–L remain active. Issue #232 tracks process-residency variance in the soak without +owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky, +day group, weather, and AdminEnvirons state. `GameWindow` is 4,330 lines / 192 +fields / 67 methods; 7,419 Release tests pass / 5 skip. Checkpoints E–L remain +active. Issue #232 tracks process-residency variance in the soak without loosening its leak threshold. Carried: diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index d6410f63..32a8979a 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -20,7 +20,7 @@ audit. define host-quiescence failure semantics. - [x] C — extract live-session host/reset/binding callbacks and verify the embedded skill formula against named retail. -- [ ] D — extract retail world-environment/day/weather behavior. +- [x] D — extract retail world-environment/day/weather behavior. - [ ] E — extract two-phase raw pointer/camera input, focus, and framebuffer resize behind symmetric named subscriptions. - [ ] F — extract the sole gameplay input-action router plus focused combat and @@ -471,6 +471,21 @@ checkpoint; the final connected lifecycle gate remains Checkpoint L. - Preserve existing named-retail citations and registered sound adaptation; diagnostics call typed commands rather than owning time/weather state. +Result: `WorldEnvironmentController` is now the sole owner of the clock, +loaded sky descriptor, selected day group, Weather state, server time sync, +AdminEnvirons bridge, and time/weather debug cycles. `GameWindow` preserves its +public readonly clock/weather aliases but only composes the owner into render +and session seams. Initialization is explicitly one-shot, and missing GameTime +restores the documented fallback origin instead of inheriting process-global +state. Named-oracle review corrected the day picker to +`SkyDesc::CalcPresentDayGroup @ 0x00500E10` and AdminEnvirons to +`CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20`; TS-54/TS-55 now register +the carried centered-audio and complete fog/ambient/radar gaps. Three +corrected-diff reviews are clean; 17 focused tests, the App suite (3,059 pass / +3 intentional skips), the warning-free Release build, and the full suite +(7,419 pass / 5 intentional skips) pass. No connected gate was required for +this behavior-preserving ownership checkpoint. + ### E — pointer, focus, and framebuffer resize - Make SilkKeyboard/Mouse sources and dispatcher links reversible, then attach diff --git a/docs/research/2026-04-23-daygroup-selection.md b/docs/research/2026-04-23-daygroup-selection.md index 97f4dd4d..9307f9fc 100644 --- a/docs/research/2026-04-23-daygroup-selection.md +++ b/docs/research/2026-04-23-daygroup-selection.md @@ -4,11 +4,16 @@ **Status:** RESEARCH COMPLETE — algorithm recovered, ready for C# port. **Companion docs:** `2026-04-23-sky-retail-verbatim.md` (full sky pipeline map). +> **Named-oracle correction (2026-07-22):** the Sept 2013 PDB identifies this +> body as `SkyDesc::CalcPresentDayGroup @ 0x00500E10`. References below to +> `FUN_00501990 @ 0x00501990` describe the older fallback binary/chunk only; +> that address is inside `SkyDesc::Pack` in the named 2013 build. + --- ## 0. TL;DR -Retail's DayGroup picker is a **simple integer LCG mixed with `(YEAR, DAY_OF_YEAR)` from the live `TimeOfDay` struct**, scaled by `DayGroupCount`. It is **not** a weighted walk over `ChanceOfOccur` — it's uniform over all DayGroups. Dereth's 20 DayGroups all carry `ChanceOfOccur = 5.0f` anyway, so uniform matches the intent. The algorithm lives in `FUN_00501990` (previously mis-labeled "deterministic PES roll" in the sky-verbatim doc; it is actually **SkyDesc::PickCurrentDayGroup**). +Retail's DayGroup picker is a **simple integer LCG mixed with `(YEAR, DAY_OF_YEAR)` from the live `TimeOfDay` struct**, scaled by `DayGroupCount`. It is **not** a weighted walk over `ChanceOfOccur` — it's uniform over all DayGroups. Dereth's 20 DayGroups all carry `ChanceOfOccur = 5.0f` anyway, so uniform matches the intent. The named algorithm is **`SkyDesc::CalcPresentDayGroup @ 0x00500E10`**; `FUN_00501990` is its older-build fallback identity. --- @@ -41,7 +46,7 @@ Note: `FUN_004ff4b0` (the OTHER mapped trampoline → `FUN_00502a10`) is called ```c // chunk_00500000.c:1274 FUN_00501990 @ 0x00501990 size=138 -// SkyDesc::PickCurrentDayGroup(this) +// Older build of SkyDesc::CalcPresentDayGroup(this) void __fastcall FUN_00501990(uint *param_1) { float fVar1; @@ -127,7 +132,7 @@ Global: `DAT_008ee9c8` (type `void *`). Populated by `FUN_005a7fd0` (TimeOfDay:: | 0x00 | double | `EpochBase` (dat-declared) | `FUN_005a7fd0:6045` | | 0x08 | int | `BaseYear` (dat-declared) | `FUN_005a7fd0:6051` | | 0x0C | float | `SecondsPerDay` | `FUN_005a7fd0:6056` | -| **0x10** | int | **`SecondsPerDay` (int copy — source of `iVar6`)** | `FUN_005a7fd0:6061` | +| **0x10** | int | **`DaysPerYear` (source of `iVar6`; live-probed as 360)** | `FUN_005a7fd0:6061`; `2026-04-23-retail-memory-probe.md` | | 0x40 | double | `SecondsPerYear = DaysPerYear × SecondsPerDay` | `FUN_005a7fd0:6068` | | 0x48 | float | `DayFraction` (0..1) | `FUN_005a7800:5497` | | 0x50 | double | CurrentDay startTick | `FUN_005a7510` (via `FUN_005a75b0`) | @@ -136,24 +141,28 @@ Global: `DAT_008ee9c8` (type `void *`). Populated by `FUN_005a7fd0` (TimeOfDay:: | **0x68** | int | **`DayOfYear`** | `FUN_005a7510:5304`: `= floor(withinYearSec / secsPerDay)` | | 0x6c | int | `SeasonIndex` | `FUN_005a7510:5313` | -The `iVar6 = *(int *)(DAT_008ee9c8 + 0x10)` read in `FUN_00501990` picks up `SecondsPerDay` as a multiplier — acting as a spread-factor to guarantee different days in different years yield different hash inputs. (If both sides of the multiplication were small — Year in [0, ~200], DayOfYear in [0, 365] — and `SecondsPerDay` were 1, the seed range would be tiny. Using the full `SecondsPerDay` integer makes the seed uniform over a ~31-bit range before the LCG step.) +The `days_per_year = GameTime::current_game_time->days_per_year` read in +`SkyDesc::CalcPresentDayGroup` is the year-to-day multiplier. The live memory +probe measured 360 at the older build's corresponding `TimeOfDay+0x10` field, +so `Year × DaysPerYear + DayOfYear` is a flat total-day index. -Retail Dereth values (from dat — confirm when we next parse a Region): `SecondsPerDay = 1800` (30 real-minutes = one Dereth-day per r12 §11, times some scale factor baked into the dat). Any value works — the algorithm is agnostic. +Retail Dereth's DAT-backed `DaysPerYear` value is 360. --- ## 5. Pseudocode (ready for C# port) ```csharp -// SkyDesc.PickCurrentDayGroup — ports acclient.exe FUN_00501990 @ 0x00501990 +// SkyDesc.CalcPresentDayGroup — named retail @ 0x00500E10 +// Older-build cross-reference: FUN_00501990 @ 0x00501990 // Decompile: docs/research/decompiled/chunk_00500000.c:1276 // Must be called every frame before SkyDesc.UpdateSkyObjectTable; value is // stable across frames within one Dereth-day so repeated calls are cheap. -public static int PickCurrentDayGroup(int year, int secondsPerDay, int dayOfYear, int dayGroupCount) +public static int CalcPresentDayGroup(int year, int daysPerYear, int dayOfYear, int dayGroupCount) { // Step 1: deterministic per-day seed. // (This mirrors retail's 3-int read from TimeOfDay+0x64/0x10/0x68.) - int seed = year * secondsPerDay + dayOfYear; + int seed = year * daysPerYear + dayOfYear; // Step 2: 32-bit signed LCG (x86 wraps silently; force it in C#). int mixed = unchecked(seed * 0x6A42FDB2 + (int)0x8ABE1652); @@ -180,14 +189,15 @@ Call from the per-frame sky render hook, **before** the keyframe-bracket interpo ```csharp // once per frame, before sky object/light interp -skyDesc.CurrentDayGroupIndex = SkyDesc.PickCurrentDayGroup( +skyDesc.CurrentDayGroupIndex = SkyDesc.CalcPresentDayGroup( year: world.TimeOfDay.Year, - secondsPerDay: world.TimeOfDay.SecondsPerDayInt, // the int copy at TimeOfDay+0x10 + daysPerYear: world.TimeOfDay.DaysPerYear, dayOfYear: world.TimeOfDay.DayOfYear, dayGroupCount: skyDesc.DayGroups.Count); ``` -If ACE and acdream agree on `(Year, SecondsPerDay, DayOfYear)` (trivially true — ACE computes these server-side from the same epoch), both clients will converge to the same index every Dereth-day. This replaces the current SplitMix64 path. +Retail and acdream converge whenever their `(Year, DaysPerYear, DayOfYear)` +inputs agree. ACE does not select a day group; each client computes it locally. --- @@ -204,12 +214,10 @@ So retail's decompile IS our only source, and we have it. ## 7. Gaps -None critical. Two minor open items: +None critical. One minor open item: 1. **`_DAT_007c6f10` exact bit value.** I inferred `1.0/2^32` from usage pattern but did not find a data-section dump that prints the literal. If the live client ever shows off-by-one drift on a boundary day, re-verify by dumping the .rdata section at 0x007c6f10. (ACDREAM_DUMP_SKY already logs picked index + inputs; we can correlate against a retail client session to confirm.) -2. **`TimeOfDay+0x10` value for live Dereth.** Retail `SecondsPerDay` has been quoted as both 1800 and 3600 in various reverse-engineering docs depending on whether "seconds" means realtime seconds or Dereth-clock seconds. The algorithm itself is insensitive — whatever integer the dat provides is what both retail and acdream must use. If our Region parser stores it in a differently-named field, make sure the call site passes the dat-raw value (the int at TimeOfDay+0x10), not a re-derived one. - --- ## 8. Summary table @@ -217,8 +225,8 @@ None critical. Two minor open items: | Question | Answer | Evidence | |---|---|---| | Caller of `FUN_00502a10` | `FUN_004ff4b0` → called from `FUN_00508010:7560` | `chunk_004F0000.c:10732`, `chunk_00500000.c:7560` | -| DayGroup selection function | `FUN_00501990` | `chunk_00500000.c:1276` | -| Hash formula | `(Year × SecondsPerDay + DayOfYear) × 0x6A42FDB2 + 0x8ABE1652` | `chunk_00500000.c:1296` | +| DayGroup selection function | `SkyDesc::CalcPresentDayGroup @ 0x00500E10` | named Sept 2013 PDB/pseudo-C; older fallback `FUN_00501990`, `chunk_00500000.c:1276` | +| Hash formula | `(Year × DaysPerYear + DayOfYear) × 0x6A42FDB2 + 0x8ABE1652` | `SkyDesc::CalcPresentDayGroup @ 0x00500E10`; older `chunk_00500000.c:1296` | | Weighted by ChanceOfOccur? | **No.** Uniform over DayGroupCount. | No CDF loop in FUN_00501990 | | Selected index stored at | `SkyDesc + 0x00` (via `*param_1` write) | `chunk_00500000.c:1307,1309` | | Read back by | `FUN_00502a10:2429`, `FUN_00501600`, `FUN_00501860` | `chunk_00500000.c:2429` | diff --git a/docs/research/2026-04-23-retail-memory-probe.md b/docs/research/2026-04-23-retail-memory-probe.md index f1c71351..c7df7323 100644 --- a/docs/research/2026-04-23-retail-memory-probe.md +++ b/docs/research/2026-04-23-retail-memory-probe.md @@ -35,7 +35,8 @@ DayOfYear = 47 ✓ The decompile agent's C trail (`docs/research/2026-04-23-sky-decompile-hunt-C.md` §1 + §4) labeled `TimeOfDay+0x10` as *"SecondsPerDay (int copy — source of iVar6)"* -for the `FUN_00501990` (`SkyDesc::PickCurrentDayGroup`) LCG seed. +for the named `SkyDesc::CalcPresentDayGroup @ 0x00500E10` LCG seed +(older-build cross-reference `FUN_00501990 @ 0x00501990`). The live probe disproves that. The value is **360** — the same as `GameTime.DaysPerYear` from the dat. @@ -43,7 +44,8 @@ The live probe disproves that. The value is **360** — the same as Implication for the retail LCG seed formula: ```c -// FUN_00501990 line 1296 of chunk_00500000.c: +// SkyDesc::CalcPresentDayGroup @ 0x00500E10 +// (older build: FUN_00501990 line 1296 of chunk_00500000.c): iVar4 = (iVar3 * iVar6 + iVar4) * 0x6a42fdb2 + -0x7541e9ae; // ^Year ^x0x10 ^DayOfYear ``` diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index a6f535d4..96e288f4 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -3,9 +3,9 @@ ## Current state The behavior-preserving App ownership campaign is complete through Slice 7 and -Slice 8 checkpoints A–C. `GameWindow.cs` moved from the 2026-07-21 baseline of -15,723 lines / 278 fields / 205 methods to 4,589 lines / 196 fields / 69 -methods: 11,134 lines (70.8%) were removed without changing accepted gameplay +Slice 8 checkpoints A–D. `GameWindow.cs` moved from the 2026-07-21 baseline of +15,723 lines / 278 fields / 205 methods to 4,330 lines / 192 fields / 67 +methods: 11,393 lines (72.5%) were removed without changing accepted gameplay or rendering behavior. | Slice | Ownership moved out | Closeout size | @@ -17,6 +17,7 @@ or rendering behavior. | 5 | landblock build, publication, retirement, and shared-origin lifetime | 8,811 / 247 / 153 | | 6 | complete update-frame orchestration | 7,026 / 241 / 108 | | 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 | +| 8 A–D | shell freeze, native callbacks, live-session composition, world environment | 4,330 / 192 / 67 | Slice 8 is an ordered checkpoint campaign rather than another feature-body move. A froze/pruned the shell boundary; B gave all nine native window callback @@ -25,7 +26,13 @@ reset/selection/entry/route composition while keeping `LiveSessionController` the sole session owner. C also replaced the embedded ACE shortcut with the named-retail `SkillFormula::Calculate @ 0x00591960` port. Partial route construction and individual detach edges are retained and retried; reset and a -new generation cannot pass an incompletely detached route. +new generation cannot pass an incompletely detached route. D added +`WorldEnvironmentController` as the one-shot owner of WorldTime, loaded sky, +day-group selection, Weather, server synchronization, AdminEnvirons, and debug +cycles. `GameWindow` retains only ABI-compatible readonly aliases and typed +composition edges. The named-oracle correction is +`SkyDesc::CalcPresentDayGroup @ 0x00500E10`; the carried AdminEnvirons audio and +full fog/ambient/radar gaps are TS-54/TS-55. Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. @@ -83,6 +90,9 @@ review rule live in `docs/architecture/code-structure.md`. ## Accepted verification +- Slice 8 Checkpoint D Release suite: 7,419 passed / 5 fixture or environment + skips; App 3,059/3 skips. Focused environment/boundary tests and all three + independent corrected-diff reviews are clean. - Slice 8 Checkpoint C Release suite: 7,408 passed / 5 fixture or environment skips; App 3,048/3 skips and Core.Net 548/0. All three independent corrected- diff reviews are clean. @@ -109,8 +119,8 @@ review rule live in `docs/architecture/code-structure.md`. ## Next work -Slice 8 checkpoints D–L group the remaining environment, input, settings, -resource ownership, startup, and shutdown wiring and reduce Silk callbacks to +Slice 8 checkpoints E–L group the remaining input, settings, resource +ownership, startup, and shutdown wiring and reduce Silk callbacks to narrow calls into input, update, render, resize, focus, and shutdown owners. It removes leftover feature state only where Slices 1–7 already established a canonical owner; it does not redesign gameplay, rendering, or resource lifetime. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3e3eb48b..3e185b01 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -101,7 +101,8 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; private AcDream.App.Rendering.RenderFrameOrchestrator? _renderFrameOrchestrator; - private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState; + private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment = + new(Console.WriteLine); private ResourceShutdownTransaction? _shutdown; private readonly DisplayFramePacingController _displayFramePacing; @@ -380,13 +381,12 @@ public sealed class GameWindow : IDisposable // DevToolsEnabled reads through typed RuntimeOptions. private bool DevToolsEnabled => _options.DevTools; - // Phase G.1-G.2 world lighting/time state. - public readonly AcDream.Core.World.WorldTimeService WorldTime = - new AcDream.Core.World.WorldTimeService( - AcDream.Core.World.SkyStateProvider.Default()); + // Phase G.1-G.2 world lighting/time state. The environment owner keeps + // the clock, selected day group, and weather transitions coherent. + public readonly AcDream.Core.World.WorldTimeService WorldTime; public readonly AcDream.Core.Lighting.LightManager Lighting = new(); - public readonly AcDream.Core.World.WeatherSystem Weather = new(); + public readonly AcDream.Core.World.WeatherSystem Weather; // Wired into the hook router in OnLoad so SetLightHook fires // from the animation pipeline flip the matching LightSource.IsLit. private AcDream.Core.Lighting.LightingHookSink? _lightingSink; @@ -407,22 +407,6 @@ public sealed class GameWindow : IDisposable // sun / ambient / fog / flash data per frame. private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo; private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer; - private AcDream.Core.World.LoadedSkyDesc? _loadedSkyDesc; - - // Phase 3a — retail-faithful per-Dereth-day weather roll. The active - // DayGroup is re-picked deterministically whenever the server clock - // crosses a DayTicks boundary. long.MinValue sentinel means - // "no day rolled yet" so the first RefreshSkyForCurrentDay call - // unconditionally installs a provider. See r12 §11 for the roller - // semantics. - private long _loadedSkyDayIndex = long.MinValue; - - // F7 / F10 debug-cycle steps for time + weather. Initialized out of - // range of the real values so the first press hits index 0 of the - // cycle table cleanly. - private int _timeDebugStep = 0; - private int _weatherDebugStep = 0; - // Phase B.2: player movement mode. private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new(); private AcDream.App.Input.PlayerMovementController? _playerController @@ -587,6 +571,8 @@ public sealed class GameWindow : IDisposable AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); + WorldTime = _worldEnvironment.WorldTime; + Weather = _worldEnvironment.Weather; _retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot(); _inputCapture = new AcDream.App.Input.CompositeInputCaptureSource( new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools), @@ -685,8 +671,6 @@ public sealed class GameWindow : IDisposable private void OnLoad() { - _worldSceneSkyState ??= new AcDream.App.Rendering.WorldSceneSkyState( - WorldTime); // Task 7: wire the physics data cache into the engine so Transition can // run narrow-phase BSP tests during FindObjCollisions. _physicsEngine.DataCache = _physicsDataCache; @@ -1276,79 +1260,9 @@ public sealed class GameWindow : IDisposable if (heightTable is null || heightTable.Length < 256) throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated"); - // Phase G.1: parse the full sky descriptor (day groups, keyframes, - // celestial mesh layers) and swap WorldTime's provider over to the - // dat-backed keyframes. The stub default provider is only used if - // the Region lacks HasSkyInfo. - if (region is not null) - { - _loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region); - if (_loadedSkyDesc is not null) - { - // Phase 3d: do NOT assign WorldTime.TickSize from - // SkyDesc.TickSize. Agent C's decompile (chunk_00500000.c:6241 - // FUN_005062e0) shows SkyDesc.TickSize is the "next sky-tick - // deadline" period — a throttle — NOT a game-time - // advancement rate. ACE's server advances PortalYearTicks at - // 1.0 ticks per real-second (Timers.cs: `PortalYearTicks += - // worldTickTimer.Elapsed.TotalSeconds`). Our client - // extrapolation between TimeSyncs must match: 1.0. - // - // Previous behavior: WorldTime.TickSize = 0.8 (from the live - // SkyDesc.TickSize). Between ~20s TimeSync gaps we fell 4 - // ticks behind the server, producing a visible "acdream sky - // is behind retail" time-of-day mismatch (user-verified - // 2026-04-23). - WorldTime.TickSize = 1.0; - - // Phase 3f: adopt the dat's GameTime.ZeroTimeOfYear as the - // calendar-extraction offset. Dereth's dat value is 3600 - // (verified 2026-04-23 live dump); ACE's DerethDateTime.cs - // comment that "tick 0 = Morntide-and-Half" (3333.75 - // offset = +7/16) is WRONG by 266.25 ticks against the - // authoritative dat. The mismatch cascaded into both the - // wrong hour label AND the wrong DayOfYear at boundary - // times (different LCG seed → different DayGroup roll), - // which explained the user's observation of "acdream - // clear night, retail stormy pre-dawn" at the same - // server PortalYearTicks. - if (region.GameTime is not null) - { - AcDream.Core.World.DerethDateTime.SetOriginOffsetFromDat( - region.GameTime.ZeroTimeOfYear); - Console.WriteLine( - $"sky: GameTime ZeroTimeOfYear={region.GameTime.ZeroTimeOfYear} " + - $"(was default {AcDream.Core.World.DerethDateTime.DayFractionOriginOffsetTicks})"); - } - - Console.WriteLine( - $"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " + - $"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " + - $"LightTickSize={_loadedSkyDesc.LightTickSize}"); - - // Initial DayGroup roll using whatever WorldTime currently - // has (either the hardcoded boot seed or a pre-arrived - // server sync). RefreshSkyForCurrentDay will re-roll when - // ServerTimeUpdated delivers the real ConnectRequest tick. - RefreshSkyForCurrentDay(); - } - } - - // Seed WorldTime to noon so outdoor scenes aren't pitch-black before - // the server sends its first TimeSync packet (offline rendering in - // particular never receives one). - // - // "Noon" here means sun at zenith — dayFraction = 0.5. Because - // DerethDateTime applies a +7/16 offset (tick 0 = Morntide-and-Half, - // hour 8 of 16), we need raw ticks = 476.25 (one hour past tick 0 = - // Midsong / Hour 9, which is what retail considers noon). - // - // Using `DayTicks * 0.5 = 3810` WOULD be correct if the offset were - // zero, but with our 3333.75-tick shift it lands on dayFraction - // 0.9375 — that's Gloaming-and-Half (sunset, nearly midnight), - // producing a dim orange sky with the sun below the horizon until - // TimeSync arrives. - WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks / 16.0); // = 476.25 = Midsong (noon) + // Parse and install the DAT-backed sky, retail day-group state, and + // offline clock seed through their single environment owner. + _worldEnvironment.Initialize(region!); // N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters BEFORE // building the terrain atlas / renderer — both consume BindlessSupport @@ -2872,7 +2786,7 @@ public sealed class GameWindow : IDisposable var worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer( renderFrameResources, renderLoginState, - _worldSceneSkyState!, + _worldEnvironment, worldRenderFrameBuilder, new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState), _retailSelectionScene, @@ -3120,12 +3034,8 @@ public sealed class GameWindow : IDisposable session, _liveEntitySessionEvents.CreateSink(), new AcDream.App.Net.LiveEnvironmentSessionSink( - OnEnvironChanged, - ticks => - { - WorldTime.SyncFromServer(ticks); - RefreshSkyForCurrentDay(); - }), + _worldEnvironment.ApplyAdminEnvirons, + _worldEnvironment.SynchronizeFromServer), CreateLiveInventorySessionBindings(), CreateLiveCharacterSessionBindings(skillTable), CreateLiveSocialSessionBindings()); @@ -3251,60 +3161,6 @@ public sealed class GameWindow : IDisposable session.SendTurbineChatTo( roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - /// - /// Phase 5d — retail AdminEnvirons (0xEA60) dispatcher. - /// Routes fog presets into the weather system's sticky override - /// slot and logs the sound cues (Thunder1..6, Roar, Bell, etc) - /// for now — actual sound playback needs a lookup table from - /// EnvironChangeType → wave asset, which we don't yet - /// have dat-indexed; follow-up will wire the thunder wave ids. - /// - private void OnEnvironChanged(uint environChangeType) - { - // Fog presets — values match AcDream.Core.World.EnvironOverride - // byte-for-byte (we deliberately mirrored retail's enum). - if (environChangeType <= 0x06u) - { - Weather.Override = (AcDream.Core.World.EnvironOverride)environChangeType; - Console.WriteLine( - $"live: AdminEnvirons fog override = " + - $"{(AcDream.Core.World.EnvironOverride)environChangeType}"); - return; - } - - // Sound cues 0x65..0x7B. Log by retail name for now; audio - // binding is a separate follow-up (needs sound-table indexing - // plus a PlaySound API on OpenAlAudioEngine that takes a - // retail sound enum → wave-id mapping). - string name = environChangeType switch - { - 0x65u => "RoarSound", - 0x66u => "BellSound", - 0x67u => "Chant1Sound", - 0x68u => "Chant2Sound", - 0x69u => "DarkWhispers1Sound", - 0x6Au => "DarkWhispers2Sound", - 0x6Bu => "DarkLaughSound", - 0x6Cu => "DarkWindSound", - 0x6Du => "DarkSpeechSound", - 0x6Eu => "DrumsSound", - 0x6Fu => "GhostSpeakSound", - 0x70u => "BreathingSound", - 0x71u => "HowlSound", - 0x72u => "LostSoulsSound", - 0x75u => "SquealSound", - 0x76u => "Thunder1Sound", - 0x77u => "Thunder2Sound", - 0x78u => "Thunder3Sound", - 0x79u => "Thunder4Sound", - 0x7Au => "Thunder5Sound", - 0x7Bu => "Thunder6Sound", - _ => $"Unknown(0x{environChangeType:X2})", - }; - Console.WriteLine( - $"live: AdminEnvirons sound cue = {name} " + - $"(0x{environChangeType:X2}) — audio binding pending"); - } private void OnUpdate(double dt) { using var _updStage = _frameProfiler.BeginStage( @@ -3413,92 +3269,6 @@ public sealed class GameWindow : IDisposable // EXPECTED-DIFF: local sidestep pacing now matches how remotes have // always played (w6-cutover-map.md R3). - /// - /// Phase 3a — re-roll the active DayGroup whenever the current - /// Dereth-day index differs from what we last installed. Idempotent - /// within the same server-day. Swaps both the - /// feeding - /// (for lighting interp) and the cached - /// (for the sky-object - /// render loop). - /// - /// - /// Honors ACDREAM_DAY_GROUP=N — when set, every call picks - /// group N regardless of day index. Useful for A/B testing each - /// weather preset against retail. See - /// - /// for the roller. - /// - /// - private void RefreshSkyForCurrentDay() - { - if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0) - return; - - // Retail FUN_00501990 seeds the LCG with the triple stored in - // TimeOfDay +0x64 (Year), +0x10 (misc. int), +0x68 (DayOfYear) - // - // The decompile agent labeled +0x10 "SecondsPerDay (int copy)" - // but a live memory probe of retail's acclient.exe (2026-04-23, - // tools/RetailTimeProbe) shows the value is actually **360** — - // semantically DaysPerYear, not seconds. So the LCG seed is - // seed = Year × DaysPerYear + DayOfYear - // which is literally "total days since epoch" (a flat day index), - // confirmed against retail's Year=116, DayOfYear=47, seed=41807. - // - // Previously we passed 7620 (DayTicks), producing seed 883967 — - // a completely different LCG output → wrong DayGroup pick → - // user-observed weather mismatch (acdream clear while retail - // stormy, 2026-04-23). The live probe nailed the fix. - double ticks = WorldTime.NowTicks; - int absYear = AcDream.Core.World.DerethDateTime.AbsoluteYear(ticks); - int dayOfYear = AcDream.Core.World.DerethDateTime.DayOfYear(ticks); - int secondsPerDay = AcDream.Core.World.DerethDateTime.DaysInAMonth - * AcDream.Core.World.DerethDateTime.MonthsInAYear; // 360 - - // Composite day key for change-detection and logging only; the - // LCG seed is computed inside SelectDayGroupIndex from (absYear, - // secondsPerDay, dayOfYear). - long dayIndex = (long)absYear * 360 + dayOfYear; - - int idx = _loadedSkyDesc.SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear); - var grp = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count - ? _loadedSkyDesc.DayGroups[idx] - : null; - - bool dayChanged = dayIndex != _loadedSkyDayIndex; - bool groupChanged = !ReferenceEquals( - grp, - _worldSceneSkyState?.ActiveDayGroup); - - if (!dayChanged && !groupChanged) return; - - _loadedSkyDayIndex = dayIndex; - (_worldSceneSkyState - ??= new AcDream.App.Rendering.WorldSceneSkyState(WorldTime)) - .ActiveDayGroup = grp; - - if (grp is not null && grp.SkyTimes.Count > 0) - { - WorldTime.SetProvider( - new AcDream.Core.World.SkyStateProvider( - grp.SkyTimes.Select(s => s.Keyframe).ToList())); - - // Phase 3e: drive the atmospheric weather (rain/snow emitters, - // fog-override categories, lightning strobe) from the retail - // DayGroup name. Stops the legacy WeatherSystem.RollKind hash - // from spawning rain particles on a "Sunny" day (user-observed - // rain regression 2026-04-23 after the retail picker landed on - // DayGroup[6] "Sunny" but the internal hash picked Rain). - Weather.SetKindFromDayGroupName(grp.Name); - - Console.WriteLine( - $"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{grp.Name}\" " + - $"(Chance={grp.ChanceOfOccur:F2}, {grp.SkyObjects.Count} objects, " + - $"{grp.SkyTimes.Count} keyframes, weather={Weather.Kind})"); - } - } - // ── Phase I.2 — DebugPanel helpers ──────────────────────────────── // // The ImGui DebugPanel reads through DebugVM closures that ask @@ -3569,27 +3339,8 @@ public sealed class GameWindow : IDisposable /// private void CycleTimeOfDay() { - // none → 0.0 (midnight) → 0.25 (dawn) → 0.5 (noon) → 0.75 (dusk) → none - _timeDebugStep = (_timeDebugStep + 1) % 5; - float? pick = _timeDebugStep switch - { - 0 => (float?)null, - 1 => 0.0f, - 2 => 0.25f, - 3 => 0.5f, - 4 => 0.75f, - _ => null, - }; - if (pick.HasValue) - { - WorldTime.SetDebugTime(pick.Value); - _debugVm?.AddToast($"Time override = {pick.Value:F2}"); - } - else - { - WorldTime.ClearDebugTime(); - _debugVm?.AddToast("Time override cleared"); - } + string message = _worldEnvironment.CycleTimeOfDay(); + _debugVm?.AddToast(message); } /// @@ -3597,17 +3348,8 @@ public sealed class GameWindow : IDisposable /// private void CycleWeather() { - var kinds = new[] - { - AcDream.Core.World.WeatherKind.Clear, - AcDream.Core.World.WeatherKind.Overcast, - AcDream.Core.World.WeatherKind.Rain, - AcDream.Core.World.WeatherKind.Snow, - AcDream.Core.World.WeatherKind.Storm, - }; - _weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length; - Weather.ForceWeather(kinds[_weatherDebugStep]); - _debugVm?.AddToast($"Weather = {kinds[_weatherDebugStep]}"); + string message = _worldEnvironment.CycleWeather(); + _debugVm?.AddToast(message); } /// @@ -4380,7 +4122,6 @@ public sealed class GameWindow : IDisposable new("world frame composition", () => { _renderFrameOrchestrator = null; - _worldSceneSkyState = null; }), ]), new ResourceShutdownStage("session dependents", diff --git a/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs index bd728e53..9a0ca9a8 100644 --- a/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs +++ b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs @@ -13,25 +13,6 @@ internal interface IWorldSceneSkyStateSource float DayFraction { get; } } -/// -/// Holds the selected retail day group beside the world clock that provides -/// its current interpolation point. Day-group selection remains an update-time -/// concern; render owners receive this read-only seam. -/// -internal sealed class WorldSceneSkyState : IWorldSceneSkyStateSource -{ - private readonly WorldTimeService _worldTime; - - public WorldSceneSkyState(WorldTimeService worldTime) - { - _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); - } - - public DayGroupData? ActiveDayGroup { get; set; } - - public float DayFraction => (float)_worldTime.DayFraction; -} - internal interface IWorldSceneEntitySource { IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, diff --git a/src/AcDream.App/World/WorldEnvironmentController.cs b/src/AcDream.App/World/WorldEnvironmentController.cs new file mode 100644 index 00000000..c79a65ef --- /dev/null +++ b/src/AcDream.App/World/WorldEnvironmentController.cs @@ -0,0 +1,249 @@ +using AcDream.App.Rendering; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.World; + +/// +/// Owns the live Dereth clock, selected retail day group, weather state, and +/// server environment overrides as one coherent environment lifetime. +/// +internal sealed class WorldEnvironmentController : IWorldSceneSkyStateSource +{ + private static readonly WeatherKind[] DebugWeatherKinds = + [ + WeatherKind.Clear, + WeatherKind.Overcast, + WeatherKind.Rain, + WeatherKind.Snow, + WeatherKind.Storm, + ]; + + private readonly Action _log; + private LoadedSkyDesc? _loadedSkyDesc; + private long _loadedSkyDayIndex = long.MinValue; + private int _timeDebugStep; + private int _weatherDebugStep; + private bool _initializationClaimed; + + public WorldEnvironmentController(Action? log = null) + : this( + new WorldTimeService(SkyStateProvider.Default()), + new WeatherSystem(), + log) + { + } + + internal WorldEnvironmentController( + WorldTimeService worldTime, + WeatherSystem weather, + Action? log = null) + { + WorldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); + Weather = weather ?? throw new ArgumentNullException(nameof(weather)); + _log = log ?? (_ => { }); + } + + public WorldTimeService WorldTime { get; } + + public WeatherSystem Weather { get; } + + public DayGroupData? ActiveDayGroup { get; private set; } + + public float DayFraction => (float)WorldTime.DayFraction; + + /// + /// Loads the Region DAT environment and seeds the pre-session clock exactly + /// as the former GameWindow.OnLoad body did. + /// + public void Initialize(Region region) + { + ArgumentNullException.ThrowIfNull(region); + Initialize( + SkyDescLoader.LoadFromRegion(region), + region.GameTime?.ZeroTimeOfYear); + } + + internal void Initialize( + LoadedSkyDesc? loadedSkyDesc, + double? zeroTimeOfYear) + { + if (_initializationClaimed) + { + throw new InvalidOperationException( + "The world environment is a one-shot GameWindow lifetime owner."); + } + + _initializationClaimed = true; + _loadedSkyDesc = loadedSkyDesc; + _loadedSkyDayIndex = long.MinValue; + ActiveDayGroup = null; + + // Region GameTime is authoritative when present. A Region without it + // gets the documented offline fallback rather than inheriting mutable + // process-global state from an earlier window or test lifetime. + double origin = zeroTimeOfYear + ?? DerethDateTime.DayFractionOriginOffsetTicks; + DerethDateTime.SetOriginOffsetFromDat(origin); + + if (_loadedSkyDesc is not null) + { + // SkyDesc.TickSize is retail's next-sky-update throttle, not the + // rate of PortalYearTicks. ACE advances the server clock at one + // tick per real second, so client extrapolation must remain 1.0. + WorldTime.TickSize = 1.0; + + if (zeroTimeOfYear.HasValue) + { + _log( + $"sky: GameTime ZeroTimeOfYear={zeroTimeOfYear.Value} " + + $"(was default {DerethDateTime.DayFractionOriginOffsetTicks})"); + } + + _log( + $"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " + + $"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " + + $"LightTickSize={_loadedSkyDesc.LightTickSize}"); + + // The initial roll intentionally precedes the offline noon seed, + // preserving the accepted OnLoad order. The first server sync + // below will select the authoritative day. + RefreshSkyForCurrentDay(); + } + + WorldTime.SyncFromServer(DerethDateTime.DayTicks / 16.0); + } + + public void SynchronizeFromServer(double ticks) + { + WorldTime.SyncFromServer(ticks); + RefreshSkyForCurrentDay(); + } + + /// + /// Environment packet bridge researched from + /// CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20. The current + /// fog approximation and missing centered UI-sound playback remain + /// explicitly registered as TS-55 and TS-54 respectively. + /// + public void ApplyAdminEnvirons(uint environChangeType) + { + if (environChangeType <= 0x06u) + { + Weather.Override = (EnvironOverride)environChangeType; + _log( + $"live: AdminEnvirons fog override = " + + $"{(EnvironOverride)environChangeType}"); + return; + } + + string name = environChangeType switch + { + 0x65u => "RoarSound", + 0x66u => "BellSound", + 0x67u => "Chant1Sound", + 0x68u => "Chant2Sound", + 0x69u => "DarkWhispers1Sound", + 0x6Au => "DarkWhispers2Sound", + 0x6Bu => "DarkLaughSound", + 0x6Cu => "DarkWindSound", + 0x6Du => "DarkSpeechSound", + 0x6Eu => "DrumsSound", + 0x6Fu => "GhostSpeakSound", + 0x70u => "BreathingSound", + 0x71u => "HowlSound", + 0x72u => "LostSoulsSound", + 0x75u => "SquealSound", + 0x76u => "Thunder1Sound", + 0x77u => "Thunder2Sound", + 0x78u => "Thunder3Sound", + 0x79u => "Thunder4Sound", + 0x7Au => "Thunder5Sound", + 0x7Bu => "Thunder6Sound", + _ => $"Unknown(0x{environChangeType:X2})", + }; + + _log( + $"live: AdminEnvirons sound cue = {name} " + + $"(0x{environChangeType:X2}) — audio binding pending"); + } + + /// + /// Selects the active DAT day group when the authoritative Dereth day + /// changes. The picker itself is the retail-verbatim + /// SkyDesc::CalcPresentDayGroup @ 0x00500E10 port (older-build + /// cross-reference FUN_00501990). + /// + public void RefreshSkyForCurrentDay() + { + if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0) + return; + + double ticks = WorldTime.NowTicks; + int absYear = DerethDateTime.AbsoluteYear(ticks); + int dayOfYear = DerethDateTime.DayOfYear(ticks); + int daysPerYear = DerethDateTime.DaysInAMonth + * DerethDateTime.MonthsInAYear; + long dayIndex = (long)absYear * 360 + dayOfYear; + + int idx = _loadedSkyDesc.SelectDayGroupIndex( + absYear, + daysPerYear, + dayOfYear); + DayGroupData? group = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count + ? _loadedSkyDesc.DayGroups[idx] + : null; + + bool dayChanged = dayIndex != _loadedSkyDayIndex; + bool groupChanged = !ReferenceEquals(group, ActiveDayGroup); + if (!dayChanged && !groupChanged) + return; + + _loadedSkyDayIndex = dayIndex; + ActiveDayGroup = group; + + if (group is null || group.SkyTimes.Count == 0) + return; + + WorldTime.SetProvider( + new SkyStateProvider( + group.SkyTimes.Select(s => s.Keyframe).ToList())); + Weather.SetKindFromDayGroupName(group.Name); + + _log( + $"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{group.Name}\" " + + $"(Chance={group.ChanceOfOccur:F2}, {group.SkyObjects.Count} objects, " + + $"{group.SkyTimes.Count} keyframes, weather={Weather.Kind})"); + } + + public string CycleTimeOfDay() + { + _timeDebugStep = (_timeDebugStep + 1) % 5; + float? selection = _timeDebugStep switch + { + 0 => null, + 1 => 0.0f, + 2 => 0.25f, + 3 => 0.5f, + 4 => 0.75f, + _ => null, + }; + + if (selection.HasValue) + { + WorldTime.SetDebugTime(selection.Value); + return $"Time override = {selection.Value:F2}"; + } + + WorldTime.ClearDebugTime(); + return "Time override cleared"; + } + + public string CycleWeather() + { + _weatherDebugStep = (_weatherDebugStep + 1) % DebugWeatherKinds.Length; + WeatherKind kind = DebugWeatherKinds[_weatherDebugStep]; + Weather.ForceWeather(kind); + return $"Weather = {kind}"; + } +} diff --git a/src/AcDream.Core/World/DerethDateTime.cs b/src/AcDream.Core/World/DerethDateTime.cs index a2e843d0..5e65b67a 100644 --- a/src/AcDream.Core/World/DerethDateTime.cs +++ b/src/AcDream.Core/World/DerethDateTime.cs @@ -61,8 +61,9 @@ public static class DerethDateTime /// Base/anchor year for the Portal Year calendar (retail /// GameTime.ZeroYear = 10). Tick 0 corresponds to PY 10, /// Morningthaw 1, Morntide-and-Half. Retail's TimeOfDay+0x64 - /// ("absolute year") includes this offset, and SkyDesc::PickCurrentDayGroup - /// (FUN_00501990) feeds the absolute year into its LCG seed. + /// ("absolute year") includes this offset, and + /// SkyDesc::CalcPresentDayGroup @ 0x00500E10 feeds the absolute year + /// into its LCG seed (older-build cross-reference FUN_00501990). /// public const int ZeroYear = 10; @@ -244,7 +245,8 @@ public static class DerethDateTime /// Matches retail's TimeOfDay + 0x64 field /// (FUN_005a7510:5300: /// floor((worldTime+base)/secsPerYear) + baseYear). This is - /// the value the retail DayGroup picker (FUN_00501990) feeds + /// the value the retail DayGroup picker + /// (SkyDesc::CalcPresentDayGroup @ 0x00500E10) feeds /// into its LCG seed, so acdream must match for identical weather /// picks vs retail. /// @@ -256,7 +258,7 @@ public static class DerethDateTime /// (dat's ZeroTimeOfYear) to match retail's TimeOfDay + 0x68 /// field (FUN_005a7510:5304: /// floor(withinYearSec / secsPerDay)). Consumed by - /// SkyDesc.PickCurrentDayGroup as part of the per-day seed. + /// SkyDesc::CalcPresentDayGroup as part of the per-day seed. /// public static int DayOfYear(double ticks) { diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index b3380f3b..f6f61d15 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -186,8 +186,9 @@ public sealed class LoadedSkyDesc /// /// Pick a deterministically for the given /// Derethian (year, dayOfYear) pair. Retail-verbatim port of - /// SkyDesc::PickCurrentDayGroup (FUN_00501990 at - /// chunk_00500000.c:1276) — the per-frame weather roller. + /// SkyDesc::CalcPresentDayGroup @ 0x00500E10. The older-build + /// fallback is FUN_00501990 at + /// chunk_00500000.c:1276. /// /// /// Algorithm (from the retail decompile): @@ -206,14 +207,10 @@ public sealed class LoadedSkyDesc /// /// /// - /// should be the dat-declared - /// "seconds per Derethian day" integer (retail reads it from - /// TimeOfDay + 0x10). acdream's callers pass - /// as an int (7620); ACE - /// computes the same value server-side so retail and acdream - /// converge on identical picks whenever their (Year, DayOfYear) - /// agree — which they do, because both derive from the server's - /// PortalYearTicks. + /// Despite its historical parameter name, + /// is the integer at retail TimeOfDay + 0x10. A live retail probe + /// established that this is Dereth's days-per-year value (360), which + /// WorldEnvironmentController supplies from the calendar constants. /// /// /// @@ -237,7 +234,7 @@ public sealed class LoadedSkyDesc if (DayGroups.Count == 1) return 0; - // --- Retail FUN_00501990 line-by-line port --- + // --- Retail SkyDesc::CalcPresentDayGroup @ 0x00500E10 --- // Step 1: deterministic per-day seed. int seed = unchecked(year * secondsPerDay + dayOfYear); @@ -280,8 +277,8 @@ public sealed class LoadedSkyDesc int dayOfYear = DerethDateTime.DayOfYear(serverTicks); // Retail's TimeOfDay+0x10 is actually DaysPerYear (= 360 for Dereth, // live probe 2026-04-23), NOT SecondsPerDay as the decompile agent - // mis-labeled. See GameWindow.RefreshSkyForCurrentDay for the full - // citation. + // mis-labeled. See WorldEnvironmentController.RefreshSkyForCurrentDay + // for the owning call site. int secondsPerDay = DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear; // 360 int idx = SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear); return idx < DayGroups.Count ? DayGroups[idx] : null; diff --git a/src/AcDream.Core/World/WeatherState.cs b/src/AcDream.Core/World/WeatherState.cs index 51219fd9..4117ef1d 100644 --- a/src/AcDream.Core/World/WeatherState.cs +++ b/src/AcDream.Core/World/WeatherState.cs @@ -157,7 +157,7 @@ public sealed class WeatherSystem /// /// Drive the weather kind from the active retail DayGroup name - /// (see SkyDesc::PickCurrentDayGroup port at + /// (see SkyDesc::CalcPresentDayGroup @ 0x00500E10 port at /// LoadedSkyDesc.SelectDayGroupIndex). Retail has ONE source /// of truth for weather — the DayGroup roll — so this replaces the /// internal hash once the real DayGroup picker diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index e2e915b1..66428e24 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -88,6 +88,41 @@ public sealed class GameWindowSlice8BoundaryTests Assert.DoesNotContain("PrepareResources(", postStart, StringComparison.Ordinal); } + [Fact] + public void WorldEnvironment_IsOwnedAndGameWindowOnlyComposesItsTypedEdges() + { + string source = GameWindowSource(); + string load = MethodBody( + "private void OnLoad()", + "private AcDream.App.Net.LiveSessionHost"); + string sessionFactory = MethodBody( + "private AcDream.App.Net.LiveSessionEventRouter CreateLiveSessionEventRouter(", + "private AcDream.App.Net.LiveInventorySessionBindings"); + + Assert.Contains( + "private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =", + source, + StringComparison.Ordinal); + Assert.Contains("_worldEnvironment.Initialize(region!);", load, StringComparison.Ordinal); + AssertAppearsInOrder( + sessionFactory, + "new AcDream.App.Net.LiveEnvironmentSessionSink(", + "_worldEnvironment.ApplyAdminEnvirons,", + "_worldEnvironment.SynchronizeFromServer)"); + Assert.DoesNotContain("_loadedSkyDesc", source, StringComparison.Ordinal); + Assert.DoesNotContain("_loadedSkyDayIndex", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void RefreshSkyForCurrentDay()", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void OnEnvironChanged(", source, StringComparison.Ordinal); + + string cycleTime = MethodBody( + "private void CycleTimeOfDay()", + "private void CycleWeather()"); + AssertAppearsInOrder( + cycleTime, + "_worldEnvironment.CycleTimeOfDay();", + "_debugVm?.AddToast(message);"); + } + [Fact] public void InputAction_PreservesRetailAcceptedPriority() { diff --git a/tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs b/tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs new file mode 100644 index 00000000..e78d9e11 --- /dev/null +++ b/tests/AcDream.App.Tests/World/WorldEnvironmentControllerTests.cs @@ -0,0 +1,187 @@ +using System.Numerics; +using AcDream.App.World; +using AcDream.Core.World; + +namespace AcDream.App.Tests.World; + +[Collection(WorldEnvironmentControllerCollection.Name)] +public sealed class WorldEnvironmentControllerTests +{ + [Fact] + public void Initialize_InstallsDatDayGroupAndSeedsOfflineNoon() + { + List log = []; + var controller = CreateController(log); + LoadedSkyDesc sky = SingleGroupSky("Sunny", begin: 0f); + + controller.Initialize(sky, zeroTimeOfYear: null); + + Assert.Same(sky.DayGroups[0], controller.ActiveDayGroup); + Assert.Equal(WeatherKind.Clear, controller.Weather.Kind); + Assert.Equal(1.0, controller.WorldTime.TickSize); + Assert.InRange(controller.DayFraction, 0.499f, 0.501f); + Assert.Contains(log, line => line.Contains("loaded Region 0x13000000", StringComparison.Ordinal)); + Assert.Contains(log, line => line.Contains("DayGroup[0] \"Sunny\"", StringComparison.Ordinal)); + } + + [Fact] + public void SynchronizeFromServer_RefreshesAtMostOnceWithinSameDay() + { + List log = []; + var controller = CreateController(log); + controller.Initialize(SingleGroupSky("Cloudy", begin: 0f), zeroTimeOfYear: null); + + controller.SynchronizeFromServer(100_000.0); + int selectedAfterFirstSync = log.Count(IsDayGroupSelection); + controller.SynchronizeFromServer(100_000.0); + + Assert.Equal(selectedAfterFirstSync, log.Count(IsDayGroupSelection)); + Assert.Equal(WeatherKind.Overcast, controller.Weather.Kind); + } + + [Fact] + public void Initialize_IsOneShotAndRetainsFirstPublishedEnvironment() + { + var controller = CreateController([]); + LoadedSkyDesc first = SingleGroupSky("Sunny", begin: 0f); + controller.Initialize(first, zeroTimeOfYear: null); + + InvalidOperationException error = Assert.Throws( + () => controller.Initialize( + SingleGroupSky("Cloudy", begin: 0f), + zeroTimeOfYear: 3600.0)); + + Assert.Contains("one-shot", error.Message, StringComparison.Ordinal); + Assert.Same(first.DayGroups[0], controller.ActiveDayGroup); + Assert.Equal(WeatherKind.Clear, controller.Weather.Kind); + } + + [Fact] + public void Initialize_WithoutGameTimeRestoresDocumentedFallbackOrigin() + { + double previous = DerethDateTime.OriginOffsetTicks; + try + { + DerethDateTime.SetOriginOffsetFromDat(3600.0); + var controller = CreateController([]); + + controller.Initialize(SingleGroupSky("Sunny", begin: 0f), zeroTimeOfYear: null); + + Assert.Equal( + DerethDateTime.DayFractionOriginOffsetTicks, + DerethDateTime.OriginOffsetTicks); + Assert.InRange(controller.DayFraction, 0.499f, 0.501f); + } + finally + { + DerethDateTime.SetOriginOffsetFromDat(previous); + } + } + + [Theory] + [InlineData(0x00u, EnvironOverride.None)] + [InlineData(0x01u, EnvironOverride.RedFog)] + [InlineData(0x06u, EnvironOverride.BlackFog2)] + public void ApplyAdminEnvirons_FogValuesSetStickyOverride( + uint raw, + EnvironOverride expected) + { + List log = []; + var controller = CreateController(log); + + controller.ApplyAdminEnvirons(raw); + + Assert.Equal(expected, controller.Weather.Override); + Assert.Contains(log, line => line.Contains(expected.ToString(), StringComparison.Ordinal)); + } + + [Fact] + public void ApplyAdminEnvirons_SoundValuePreservesWeatherAndLogsRetailName() + { + List log = []; + var controller = CreateController(log); + controller.Weather.Override = EnvironOverride.GreenFog; + + controller.ApplyAdminEnvirons(0x78u); + + Assert.Equal(EnvironOverride.GreenFog, controller.Weather.Override); + Assert.Contains(log, line => line.Contains("Thunder3Sound", StringComparison.Ordinal)); + } + + [Fact] + public void CycleTimeOfDay_PreservesAcceptedFiveStepSequence() + { + var controller = CreateController([]); + + Assert.Equal("Time override = 0.00", controller.CycleTimeOfDay()); + Assert.Equal(0f, controller.DayFraction); + Assert.Equal("Time override = 0.25", controller.CycleTimeOfDay()); + Assert.Equal(0.25f, controller.DayFraction); + Assert.Equal("Time override = 0.50", controller.CycleTimeOfDay()); + Assert.Equal(0.5f, controller.DayFraction); + Assert.Equal("Time override = 0.75", controller.CycleTimeOfDay()); + Assert.Equal(0.75f, controller.DayFraction); + Assert.Equal("Time override cleared", controller.CycleTimeOfDay()); + } + + [Fact] + public void CycleWeather_PreservesAcceptedOrder() + { + var controller = CreateController([]); + + Assert.Equal("Weather = Overcast", controller.CycleWeather()); + Assert.Equal(WeatherKind.Overcast, controller.Weather.Kind); + Assert.Equal("Weather = Rain", controller.CycleWeather()); + Assert.Equal(WeatherKind.Rain, controller.Weather.Kind); + Assert.Equal("Weather = Snow", controller.CycleWeather()); + Assert.Equal(WeatherKind.Snow, controller.Weather.Kind); + Assert.Equal("Weather = Storm", controller.CycleWeather()); + Assert.Equal(WeatherKind.Storm, controller.Weather.Kind); + Assert.Equal("Weather = Clear", controller.CycleWeather()); + Assert.Equal(WeatherKind.Clear, controller.Weather.Kind); + } + + private static WorldEnvironmentController CreateController(List log) => + new( + new WorldTimeService(SkyStateProvider.Default()), + new WeatherSystem(), + log.Add); + + private static LoadedSkyDesc SingleGroupSky(string name, float begin) + { + var keyframe = new SkyKeyframe( + Begin: begin, + SunHeadingDeg: 0f, + SunPitchDeg: 90f, + DirColor: Vector3.One, + DirBright: 1f, + AmbColor: Vector3.One, + AmbBright: 1f, + FogColor: Vector3.Zero, + FogDensity: 0f); + var group = new DayGroupData + { + ChanceOfOccur = 100f, + Name = name, + SkyTimes = + [ + new DatSkyKeyframeData { Keyframe = keyframe }, + ], + }; + return new LoadedSkyDesc + { + TickSize = 0.8, + LightTickSize = 0.2, + DayGroups = [group], + }; + } + + private static bool IsDayGroupSelection(string line) => + line.Contains("→ DayGroup[", StringComparison.Ordinal); +} + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class WorldEnvironmentControllerCollection +{ + public const string Name = "World environment process-global calendar"; +} From 8b8afeefa3e0688b5fc06cbd58273be84ceb335a Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 11:59:33 +0200 Subject: [PATCH 95/98] refactor(input): own pointer and callback lifetime Move camera pointer, framebuffer resize, and retained/devtools input edges behind focused reversible owners. Preserve input priority while making shutdown deactivate callbacks before live-session retirement and retry physical detach without stranding transport teardown. --- AGENTS.md | 18 +- CLAUDE.md | 18 +- docs/ISSUES.md | 9 +- docs/architecture/acdream-architecture.md | 10 +- docs/architecture/code-structure.md | 10 + .../retail-divergence-register.md | 3 +- docs/plans/2026-04-11-roadmap.md | 15 +- docs/plans/2026-05-12-milestones.md | 11 +- ...amewindow-slice-8-composition-lifecycle.md | 23 +- memory/project_gamewindow_decomposition.md | 22 +- .../Input/CameraPointerInputController.cs | 449 ++++++++++++++++ .../Input/DispatcherCameraInputSource.cs | 7 + .../Input/DispatcherMovementInputSource.cs | 7 + src/AcDream.App/Input/InputCaptureSources.cs | 20 - .../Input/QuiescentInputContext.cs | 486 ++++++++++++++++++ src/AcDream.App/Input/SilkKeyboardSource.cs | 222 +++++++- src/AcDream.App/Input/SilkMouseSource.cs | 325 +++++++++--- .../Rendering/FramebufferResizeController.cs | 118 +++++ src/AcDream.App/Rendering/GameWindow.cs | 394 ++++++-------- src/AcDream.App/UI/RetainedUiInputBinding.cs | 420 +++++++++++++++ src/AcDream.App/UI/UiHost.cs | 116 +++-- .../Input/InputDispatcher.cs | 140 ++++- .../CameraPointerInputControllerTests.cs | 287 +++++++++++ .../Input/DispatcherCameraInputSourceTests.cs | 53 ++ .../DispatcherMovementInputSourceTests.cs | 24 +- .../GameplayInputFrameControllerTests.cs | 3 +- .../Input/QuiescentInputContextTests.cs | 344 +++++++++++++ .../Input/SilkInputSourceLifetimeTests.cs | 284 ++++++++++ .../FramebufferResizeControllerTests.cs | 76 +++ .../GameWindowRenderLeafCompositionTests.cs | 8 +- .../GameWindowSlice8BoundaryTests.cs | 66 ++- .../UI/RetainedUiInputBindingTests.cs | 262 ++++++++++ .../World/LiveObjectFrameControllerTests.cs | 3 +- .../World/UpdateFrameOrchestratorTests.cs | 20 +- .../DispatcherToMovementIntegrationTests.cs | 12 +- .../Input/InputDispatcherCaptureTests.cs | 3 +- .../Input/InputDispatcherDoubleClickTests.cs | 3 +- .../Input/InputDispatcherIsActionHeldTests.cs | 3 +- .../Input/InputDispatcherLifetimeTests.cs | 187 +++++++ .../Input/InputDispatcherTests.cs | 3 +- .../Panels/Settings/SettingsPanelTests.cs | 3 +- .../Panels/Settings/SettingsVMTests.cs | 3 +- 42 files changed, 4029 insertions(+), 461 deletions(-) create mode 100644 src/AcDream.App/Input/CameraPointerInputController.cs create mode 100644 src/AcDream.App/Input/QuiescentInputContext.cs create mode 100644 src/AcDream.App/Rendering/FramebufferResizeController.cs create mode 100644 src/AcDream.App/UI/RetainedUiInputBinding.cs create mode 100644 tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Input/DispatcherCameraInputSourceTests.cs create mode 100644 tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs create mode 100644 tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs create mode 100644 tests/AcDream.App.Tests/UI/RetainedUiInputBindingTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherLifetimeTests.cs diff --git a/AGENTS.md b/AGENTS.md index 1c2198b6..f3e7514e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,341 tests pass / 5 skip. +Release builds; 7,477 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -127,15 +127,19 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–D are also complete: dead host residue is removed, +Slice 8 checkpoints A–E are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT -sky/day group, weather, synchronization, and AdminEnvirons state. `GameWindow` -is 4,330 raw lines / 192 fields / 67 methods, down 11,393 lines (72.5%) from -the 15,723-line campaign baseline. The 7,419-test -Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second -synchronized nine-stop soak pass. Slice 8 checkpoints E–L—input/resize, action +sky/day group, weather, synchronization, and AdminEnvirons state. +`CameraPointerInputController` and `FramebufferResizeController` now own raw +camera input/focus/scroll and physical framebuffer publication. Silk, +dispatcher, retained-UI, and devtools device edges are reversible, +transactional, and terminal after disposal; live-session retirement precedes +physical callback removal. `GameWindow` is 4,266 raw lines / 194 fields / 65 +methods, down 11,457 lines (72.9%) from the 15,723-line campaign baseline. The +7,477-test Release suite, 315.6-second lifecycle/reconnect gate, and +395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints F–L—action routing, settings, resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and diff --git a/CLAUDE.md b/CLAUDE.md index 1a38cd9c..4274735b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,341 tests pass / 5 skip. +Release builds; 7,477 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -125,15 +125,19 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–D are also complete: dead host residue is removed, +Slice 8 checkpoints A–E are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT -sky/day group, weather, synchronization, and AdminEnvirons state. `GameWindow` -is 4,330 raw lines / 192 fields / 67 methods, down 11,393 lines (72.5%) from -the 15,723-line campaign baseline. The 7,419-test -Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second -synchronized nine-stop soak pass. Slice 8 checkpoints E–L—input/resize, action +sky/day group, weather, synchronization, and AdminEnvirons state. +`CameraPointerInputController` and `FramebufferResizeController` now own raw +camera input/focus/scroll and physical framebuffer publication. Silk, +dispatcher, retained-UI, and devtools device edges are reversible, +transactional, and terminal after disposal; live-session retirement precedes +physical callback removal. `GameWindow` is 4,266 raw lines / 194 fields / 65 +methods, down 11,457 lines (72.9%) from the 15,723-line campaign baseline. The +7,477-test Release suite, 315.6-second lifecycle/reconnect gate, and +395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints F–L—action routing, settings, resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 4ccdb45a..38412aca 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -27,11 +27,12 @@ What does NOT go here: ## Current queue — 2026-07-22 - **Active structural work:** `GameWindow` decomposition. Slices 1–7 are - complete; Slice 8 checkpoints A–D now own native callbacks, live-session - composition, and world-environment state outside the shell. + complete; Slice 8 checkpoints A–E now own native callbacks, live-session + composition, world-environment state, reversible device input/raw pointer, + and physical framebuffer publication outside the shell. `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration - handoffs and the class is 4,330 lines / 192 fields / 67 methods. Remaining - Slice 8 input/settings/composition/shutdown + handoffs and the class is 4,266 lines / 194 fields / 65 methods. Remaining + Slice 8 action-routing/settings/composition/shutdown cleanup is active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 96431340..ea8971d5 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -827,9 +827,13 @@ Current movement/collision ownership: session generation into `LiveSessionController` plus its focused host and routers; Slice 4 moved hydration, teardown, inbound authority, network updates, and reusable motion-runtime policy over canonical - `LiveEntityRuntime`. Slice 5 landblock presentation is next. The reconciled - ledger and sequence live in `docs/architecture/code-structure.md`. This decision - does not reclassify the remaining L.2 conformance work or Track LH. + `LiveEntityRuntime`. Slices 5–7 complete landblock presentation and the + update/render frame graphs. Slice 8 checkpoints A–E now own the native + callback shell, live-session composition, world environment, reversible + input/pointer lifetime, and physical framebuffer publication. Gameplay action + routing is the next checkpoint. The reconciled ledger and sequence live in + `docs/architecture/code-structure.md`. This decision does not reclassify the + remaining L.2 conformance work or Track LH. Full `GameEntity` type aggregation remains a separate, higher-risk target after the thin-`GameWindow` campaign. The current campaign reuses canonical diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index e5e4e6b3..c53770e1 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -690,6 +690,16 @@ slices. `OnClosing` delegates to the existing retryable shutdown transaction. Silk callbacks become narrow calls into the input, update, render, resize, focus, and shutdown owners. +Checkpoint E is complete. `CameraPointerInputController` owns raw pointer, +camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController` +alone publishes physical framebuffer changes. All Silk, dispatcher, retained +UI, and optional devtools device edges are reversible, transactional, and +terminal after disposal. Logical cutoff precedes live-session retirement; +physical detach follows it, so neither copied callbacks nor a failed event +remove can interfere with graceful transport teardown. `GameWindow` is now +4,266 raw lines / 194 fields / 65 methods. Checkpoints F–L remain active, with +gameplay action routing next. + ### 4.4 Exit criteria The campaign is complete when: diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 30eb2abc..29c0347f 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -218,7 +218,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | | AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` | -## 4. Temporary stopgap (TS) — 37 active rows + 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) — 38 active rows + 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 | |---|---|---|---|---|---| @@ -263,6 +263,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | | TS-54 | AdminEnvirons sound values `0x65..0x7B` are diagnosed by retail enum name but do not play audio. Retail checks that the local player physics object and UI sound table exist, then calls `SoundManager::PlaySoundFromCenter(Sound_UI_*, table)` for Roar through Thunder6. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`) | The current audio owner has no typed retail UI-sound-table binding; logging preserves the inbound evidence without inventing wave DIDs or routing the sounds through positional world audio. | Server-authored ambience/thunder packets are silent in acdream while retail plays the centered UI sound. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055E07F..0x0055E2C7`); `SoundManager::PlaySoundFromCenter @ 0x00550950` | | TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) | +| 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` | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index fec0c3dc..92dedd0f 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-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–D are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–E are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. 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. --- @@ -79,12 +79,17 @@ second synchronized soak pass with graceful exits, and the six stable PNG checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse process-residency gate's diagnostic blind spot; no tolerance was loosened. -Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–D now own +Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–E now own the frozen shell boundary, all nine native callback edges, live-session reset/selection/entry/route composition, and world clock/sky/day/weather/ -AdminEnvirons state. `GameWindow` is 4,330 lines / 192 fields / 67 methods; -the Checkpoint D Release suite passes 7,419 tests / 5 skips. Input, settings, -startup/resource transfer, and shutdown checkpoints E–L remain. +AdminEnvirons state. Checkpoint E adds reversible, transactional Silk, +dispatcher, retained-UI, and devtools input edges; a focused raw pointer owner; +and one physical framebuffer-size publisher. Logical callback cutoff and +live-session retirement precede physical removal, so failed device detach +cannot block graceful transport close. `GameWindow` is 4,266 lines / 194 +fields / 65 methods; the Checkpoint E Release suite passes 7,477 tests / 5 +skips. Gameplay routing, settings, startup/resource transfer, shutdown, soak, +and closeout checkpoints F–L remain. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 1c92eb8d..e733d96a 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -39,12 +39,15 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized nine-destination soak pass with graceful exits, and six stable PNG checkpoints -preserve Slice 6 presentation. Final Slice 8 checkpoints A–D are complete: +preserve Slice 6 presentation. Final Slice 8 checkpoints A–E are complete: native callbacks and live-session composition now have explicit retryable owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky, -day group, weather, and AdminEnvirons state. `GameWindow` is 4,330 lines / 192 -fields / 67 methods; 7,419 Release tests pass / 5 skip. Checkpoints E–L remain -active. Issue #232 tracks process-residency variance in the soak without +day group, weather, and AdminEnvirons state. Checkpoint E adds terminal, +transactional ownership for Silk/dispatcher/retained/devtools input, the raw +camera pointer, focus, and physical framebuffer size. Graceful live-session +retirement now precedes physical callback removal. `GameWindow` is 4,266 lines +/ 194 fields / 65 methods; 7,477 Release tests pass / 5 skip. Checkpoints F–L +remain active. Issue #232 tracks process-residency variance in the soak without loosening its leak threshold. Carried: diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index 32a8979a..79532b69 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -21,7 +21,7 @@ audit. - [x] C — extract live-session host/reset/binding callbacks and verify the embedded skill formula against named retail. - [x] D — extract retail world-environment/day/weather behavior. -- [ ] E — extract two-phase raw pointer/camera input, focus, and framebuffer +- [x] E — extract two-phase raw pointer/camera input, focus, and framebuffer resize behind symmetric named subscriptions. - [ ] F — extract the sole gameplay input-action router plus focused combat and diagnostic command owners. @@ -497,6 +497,27 @@ this behavior-preserving ownership checkpoint. - Require rollback after every Nth event add and silence between every shutdown stage for raw/retained/devtools device events. +Result: `CameraPointerInputController` now owns raw move, camera-mode cursor, +focus-loss, scroll, and per-mode sensitivity policy; its gameplay-frame edge is +late-bound without resubscription. Silk keyboard/mouse sources, +`InputDispatcher`, retained UI bindings, and the devtools +`QuiescentInputContext` are publish-before-attach, terminal after disposal, +transactional on every side-effecting event add, and retry only the physical +edges that remain pending. Mouse-only hosts retain camera/cursor behavior. +`FramebufferResizeController` is the sole physical framebuffer publisher and +seeds viewport/aspect/camera state from `FramebufferSize`, while logical UI and +frame dimensions remain `Window.Size`. Shutdown now performs no-throw logical +input cutoff, retires the live session, then removes physical callbacks; the +post-session mouse-look release therefore cannot send a final movement packet, +and a bad Silk remove accessor cannot block F653/transport teardown. TS-56 +records the carried non-retail camera input scalars. Three corrected-diff review +loops are clean; 66 focused App tests, 9 dispatcher-lifetime tests, 5 Core input +integration tests, the warning-free production Release build, and the complete +Release suite (7,477 pass / 5 intentional skips) pass. `GameWindow` is 4,266 +raw lines / 194 fields / 65 methods. No connected gate was required for this +behavior-preserving ownership checkpoint; Checkpoint L retains the final +connected lifecycle and visual gates. + ### F — gameplay action and command routing - Move the frozen action-priority graph into the sole Fired subscriber. diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index 96e288f4..156689b9 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -3,9 +3,9 @@ ## Current state The behavior-preserving App ownership campaign is complete through Slice 7 and -Slice 8 checkpoints A–D. `GameWindow.cs` moved from the 2026-07-21 baseline of -15,723 lines / 278 fields / 205 methods to 4,330 lines / 192 fields / 67 -methods: 11,393 lines (72.5%) were removed without changing accepted gameplay +Slice 8 checkpoints A–E. `GameWindow.cs` moved from the 2026-07-21 baseline of +15,723 lines / 278 fields / 205 methods to 4,266 lines / 194 fields / 65 +methods: 11,457 lines (72.9%) were removed without changing accepted gameplay or rendering behavior. | Slice | Ownership moved out | Closeout size | @@ -17,7 +17,7 @@ or rendering behavior. | 5 | landblock build, publication, retirement, and shared-origin lifetime | 8,811 / 247 / 153 | | 6 | complete update-frame orchestration | 7,026 / 241 / 108 | | 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 | -| 8 A–D | shell freeze, native callbacks, live-session composition, world environment | 4,330 / 192 / 67 | +| 8 A–E | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 | Slice 8 is an ordered checkpoint campaign rather than another feature-body move. A froze/pruned the shell boundary; B gave all nine native window callback @@ -34,6 +34,15 @@ composition edges. The named-oracle correction is `SkyDesc::CalcPresentDayGroup @ 0x00500E10`; the carried AdminEnvirons audio and full fog/ambient/radar gaps are TS-54/TS-55. +E adds `CameraPointerInputController`, `FramebufferResizeController`, terminal +publish-before-attach Silk/dispatcher/retained input owners, and a quiescent +non-owning input-context view for optional devtools. Mouse-only hosts still +receive raw camera/cursor behavior. Logical callback deactivation runs before +the graceful session close; physical removal and the local mouse-look release +run only after session retirement. This prevents both copied callback reentry +and a failed event accessor from blocking F653/transport teardown. The carried +non-retail post-filter camera scalars are explicitly registered as TS-56. + Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`, @@ -90,6 +99,9 @@ review rule live in `docs/architecture/code-structure.md`. ## Accepted verification +- Slice 8 Checkpoint E Release suite: 7,477 passed / 5 fixture or environment + skips; App 3,108/3 skips, UI Abstractions 533/0, and all three independent + corrected-diff reviews are clean. - Slice 8 Checkpoint D Release suite: 7,419 passed / 5 fixture or environment skips; App 3,059/3 skips. Focused environment/boundary tests and all three independent corrected-diff reviews are clean. @@ -119,7 +131,7 @@ review rule live in `docs/architecture/code-structure.md`. ## Next work -Slice 8 checkpoints E–L group the remaining input, settings, resource +Slice 8 checkpoints F–L group the remaining action routing, settings, resource ownership, startup, and shutdown wiring and reduce Silk callbacks to narrow calls into input, update, render, resize, focus, and shutdown owners. It removes leftover feature state only where Slices 1–7 already established a diff --git a/src/AcDream.App/Input/CameraPointerInputController.cs b/src/AcDream.App/Input/CameraPointerInputController.cs new file mode 100644 index 00000000..22d43807 --- /dev/null +++ b/src/AcDream.App/Input/CameraPointerInputController.cs @@ -0,0 +1,449 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.Rendering; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Input; + +/// +/// Narrow raw-pointer surface. The production adapter keeps the Silk event +/// accessor out of the camera policy and lets failure tests exercise an event +/// add/remove without implementing Silk's complete mouse contract. +/// +internal interface IRawPointerSurface +{ + void AddMouseMove(Action callback); + void RemoveMouseMove(Action callback); +} + +internal sealed class SilkRawPointerSurface : IRawPointerSurface +{ + private readonly IMouse _mouse; + private Action? _callback; + private readonly Action _mouseMove; + + public SilkRawPointerSurface(IMouse mouse) + { + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _mouseMove = OnMouseMove; + } + + public void AddMouseMove(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); + if (_callback is not null) + throw new InvalidOperationException("The raw mouse surface is already attached."); + + // Publish before invoking the custom event accessor: an accessor may + // perform its side effect and then throw, in which case rollback still + // has the exact delegate needed to remove it. + _callback = callback; + _mouse.MouseMove += _mouseMove; + } + + public void RemoveMouseMove(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); + if (!ReferenceEquals(_callback, callback)) + return; + + _mouse.MouseMove -= _mouseMove; + _callback = null; + } + + private void OnMouseMove(IMouse _, Vector2 position) => + _callback?.Invoke(position); +} + +internal interface IPointerCursorModeTarget +{ + CursorMode CursorMode { get; set; } +} + +internal sealed class SilkPointerCursorModeTarget(IMouse mouse) + : IPointerCursorModeTarget +{ + private readonly IMouse _mouse = mouse + ?? throw new ArgumentNullException(nameof(mouse)); + + public CursorMode CursorMode + { + get => _mouse.Cursor.CursorMode; + set => _mouse.Cursor.CursorMode = value; + } +} + +/// +/// Owns raw pointer subscriptions and the camera-facing pointer policy that +/// previously lived in GameWindow. Physical event removal is retriable; logical +/// deactivation is immediate, so a copied or stubborn Silk delegate cannot +/// re-enter gameplay while shutdown is closing the live session. +/// +internal sealed class CameraPointerInputController : IDisposable +{ + private readonly IReadOnlyList _surfaces; + private readonly IPointerCursorModeTarget _cursor; + private readonly HostQuiescenceGate _quiescence; + private readonly IInputCaptureSource _capture; + private readonly LocalPlayerModeState _playerMode; + private readonly CameraController _camera; + private readonly ChaseCameraInputState _chase; + private readonly IMouseSource _mouse; + private readonly PointerPositionState _pointer; + private readonly IInputMonotonicClock _clock; + private readonly Action _mouseMoved; + private readonly Action _cameraModeChanged; + private readonly bool[] _surfaceAttached; + private ResourceShutdownTransaction? _detach; + private GameplayInputFrameController? _gameplayFrame; + private bool _cameraAttached; + private bool _attachStarted; + private int _disposeRequested; + private int _active; + private float _flySensitivity = 1f; + private float _orbitSensitivity = 1f; + + public CameraPointerInputController( + IReadOnlyList surfaces, + IPointerCursorModeTarget cursor, + HostQuiescenceGate quiescence, + IInputCaptureSource capture, + LocalPlayerModeState playerMode, + CameraController camera, + ChaseCameraInputState chase, + IMouseSource mouse, + PointerPositionState pointer, + IInputMonotonicClock clock) + { + _surfaces = surfaces ?? throw new ArgumentNullException(nameof(surfaces)); + if (_surfaces.Any(static surface => surface is null)) + throw new ArgumentException("Raw pointer surfaces cannot contain null.", nameof(surfaces)); + _cursor = cursor ?? throw new ArgumentNullException(nameof(cursor)); + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); + _capture = capture ?? throw new ArgumentNullException(nameof(capture)); + _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _chase = chase ?? throw new ArgumentNullException(nameof(chase)); + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _pointer = pointer ?? throw new ArgumentNullException(nameof(pointer)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _surfaceAttached = new bool[_surfaces.Count]; + _mouseMoved = OnMouseMoved; + _cameraModeChanged = OnCameraModeChanged; + } + + public static CameraPointerInputController Create( + IReadOnlyList mice, + HostQuiescenceGate quiescence, + IInputCaptureSource capture, + LocalPlayerModeState playerMode, + CameraController camera, + ChaseCameraInputState chase, + IMouseSource mouse, + PointerPositionState pointer, + IInputMonotonicClock clock) + { + ArgumentNullException.ThrowIfNull(mice); + if (mice.Count == 0) + throw new ArgumentException("At least one mouse is required.", nameof(mice)); + + return new CameraPointerInputController( + mice.Select(static candidate => + (IRawPointerSurface)new SilkRawPointerSurface(candidate)).ToArray(), + new SilkPointerCursorModeTarget(mice[0]), + quiescence, + capture, + playerMode, + camera, + chase, + mouse, + pointer, + clock); + } + + public bool IsDisposalComplete => + !_cameraAttached && _surfaceAttached.All(static attached => !attached); + + public float ActiveSensitivity + { + get + { + if (_playerMode.IsPlayerMode && _camera.IsChaseMode) + return _chase.Sensitivity; + if (_camera.IsFlyMode) + return _flySensitivity; + return _orbitSensitivity; + } + } + + public bool RmbOrbitHeld => _chase.RmbOrbitHeld; + + public void AttachRaw() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + if (_attachStarted) + throw new InvalidOperationException("Raw pointer attachment has already started."); + _attachStarted = true; + + try + { + for (int i = 0; i < _surfaces.Count; i++) + { + _surfaceAttached[i] = true; + _surfaces[i].AddMouseMove(_mouseMoved); + } + + _cameraAttached = true; + _camera.ModeChanged += _cameraModeChanged; + Volatile.Write(ref _active, 1); + } + catch (Exception attachError) + { + Deactivate(); + try + { + EnsureDetachTransaction().CompleteOrThrow(); + } + catch (Exception rollbackError) + { + throw new AggregateException( + "Raw pointer registration and rollback both failed.", + new InvalidOperationException( + "Raw pointer registration failed.", attachError), + rollbackError); + } + + throw new InvalidOperationException( + "Raw pointer registration failed and was rolled back.", + attachError); + } + } + + /// + /// Completes the deliberate two-phase composition edge. Binding the frame + /// owner never resubscribes or changes event priority. + /// + public void BindGameplayFrame(GameplayInputFrameController gameplayFrame) + { + ArgumentNullException.ThrowIfNull(gameplayFrame); + if (_gameplayFrame is not null + && !ReferenceEquals(_gameplayFrame, gameplayFrame)) + { + throw new InvalidOperationException( + "The raw pointer owner is already bound to another gameplay frame."); + } + + _gameplayFrame = gameplayFrame; + } + + public void UnbindGameplayFrame(GameplayInputFrameController gameplayFrame) + { + ArgumentNullException.ThrowIfNull(gameplayFrame); + if (ReferenceEquals(_gameplayFrame, gameplayFrame)) + _gameplayFrame = null; + } + + public void HandleFocusChanged(bool focused) + { + if (!focused) + _gameplayFrame?.EndMouseLook(); + } + + public void HandleScroll(InputAction action) + { + float direction = action switch + { + InputAction.ScrollUp => 1f, + InputAction.ScrollDown => -1f, + _ => throw new ArgumentOutOfRangeException(nameof(action)), + }; + + if (_playerMode.IsPlayerMode && _camera.IsChaseMode) + { + if (CameraDiagnostics.UseRetailChaseCamera && _chase.Retail is not null) + _chase.Retail.AdjustDistance(-direction * 0.8f); + else + _chase.Legacy?.AdjustDistance(-direction * 0.8f); + } + else if (!_camera.IsFlyMode) + { + _camera.Orbit.Distance = Math.Clamp( + _camera.Orbit.Distance - direction * 20f, + 50f, + 2000f); + } + } + + public string AdjustSensitivity(float factor) + { + string mode; + float current; + if (_playerMode.IsPlayerMode && _camera.IsChaseMode) + { + mode = "Chase"; + current = _chase.Sensitivity; + } + else if (_camera.IsFlyMode) + { + mode = "Fly"; + current = _flySensitivity; + } + else + { + mode = "Orbit"; + current = _orbitSensitivity; + } + + float next = MathF.Min(3f, MathF.Max(0.005f, current * factor)); + if (mode == "Chase") + _chase.Sensitivity = next; + else if (mode == "Fly") + _flySensitivity = next; + else + _orbitSensitivity = next; + + return $"{mode} sens {next:F3}x"; + } + + /// + /// Immediate callback cutoff used before the live-session close. The + /// gameplay mouse-look transition is retained until the session has + /// retired so it cannot publish one last movement packet. + /// + public void Deactivate() + { + Interlocked.Exchange(ref _active, 0); + _chase.RmbOrbitHeld = false; + } + + /// Release presentation state only after live-session retirement. + public void ReleaseMouseLookAfterSessionRetirement() => + _gameplayFrame?.EndMouseLook(); + + public void Dispose() + { + Interlocked.Exchange(ref _disposeRequested, 1); + Deactivate(); + EnsureDetachTransaction().CompleteOrThrow(); + } + + private void OnMouseMoved(Vector2 position) => + _quiescence.Invoke(() => ProcessMouseMove(position)); + + private void ProcessMouseMove(Vector2 position) + { + if (Volatile.Read(ref _active) == 0) + return; + + if (_capture.WantCaptureMouse) + { + _pointer.X = position.X; + _pointer.Y = position.Y; + return; + } + + float dx = position.X - _pointer.X; + float dy = position.Y - _pointer.Y; + + if (_playerMode.IsPlayerMode && _camera.IsChaseMode + && _chase.Legacy is not null) + { + float sensitivity = _chase.Sensitivity; + if (_gameplayFrame?.MouseLookActive == true) + { + _gameplayFrame.QueueRawMouseDelta(dx, dy); + } + else if (_chase.RmbOrbitHeld) + { + if (CameraDiagnostics.UseRetailChaseCamera + && _chase.Retail is not null) + { + var (filteredDx, filteredDy) = _chase.Retail.FilterMouseDelta( + rawX: dx, + rawY: dy, + weight: 0.5f, + nowSec: _clock.NowSeconds); + _chase.Retail.YawOffset -= filteredDx * 0.004f * sensitivity; + _chase.Retail.AdjustPitch(filteredDy * 0.003f * sensitivity); + } + else + { + _chase.Legacy.YawOffset -= dx * 0.004f * sensitivity; + _chase.Legacy.AdjustPitch(dy * 0.003f * sensitivity); + } + } + } + else if (_camera.IsFlyMode) + { + _camera.Fly.Look(dx * _flySensitivity, dy * _flySensitivity); + } + else if (_mouse.IsHeld(MouseButton.Left)) + { + _camera.Orbit.Yaw -= dx * 0.005f * _orbitSensitivity; + _camera.Orbit.Pitch = Math.Clamp( + _camera.Orbit.Pitch + dy * 0.005f * _orbitSensitivity, + 0.1f, + 1.5f); + } + + _pointer.X = position.X; + _pointer.Y = position.Y; + } + + private void OnCameraModeChanged(bool _) => + _quiescence.Invoke(ApplyCursorForCameraMode); + + private void ApplyCursorForCameraMode() + { + if (Volatile.Read(ref _active) == 0) + return; + + if (_gameplayFrame?.MouseLookActive == true && !_camera.IsChaseMode) + _gameplayFrame.EndMouseLook(); + + _cursor.CursorMode = _camera.IsFlyMode + ? CursorMode.Raw + : CursorMode.Normal; + } + + private ResourceShutdownTransaction EnsureDetachTransaction() + { + if (_detach is not null) + return _detach; + + var operations = new List + { + new("camera mode", RemoveCameraMode), + }; + for (int i = _surfaces.Count - 1; i >= 0; i--) + { + int index = i; + operations.Add(new ResourceShutdownOperation( + $"raw mouse {index}", + () => RemoveSurface(index))); + } + + _detach = new ResourceShutdownTransaction( + new ResourceShutdownStage("raw pointer callbacks", operations.ToArray())); + return _detach; + } + + private void RemoveCameraMode() + { + if (!_cameraAttached) + return; + _camera.ModeChanged -= _cameraModeChanged; + _cameraAttached = false; + } + + private void RemoveSurface(int index) + { + if (!_surfaceAttached[index]) + return; + _surfaces[index].RemoveMouseMove(_mouseMoved); + _surfaceAttached[index] = false; + } +} diff --git a/src/AcDream.App/Input/DispatcherCameraInputSource.cs b/src/AcDream.App/Input/DispatcherCameraInputSource.cs index 4b4e982d..dae50088 100644 --- a/src/AcDream.App/Input/DispatcherCameraInputSource.cs +++ b/src/AcDream.App/Input/DispatcherCameraInputSource.cs @@ -43,6 +43,13 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource _dispatcher = dispatcher; } + public void Unbind(InputDispatcher dispatcher) + { + ArgumentNullException.ThrowIfNull(dispatcher); + if (ReferenceEquals(_dispatcher, dispatcher)) + _dispatcher = null; + } + public FlyCameraInput CaptureFly() { if (_dispatcher is not { } dispatcher) diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs index b8a2e20f..2d536f02 100644 --- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs +++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs @@ -31,6 +31,13 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource _dispatcher = dispatcher; } + public void Unbind(InputDispatcher dispatcher) + { + ArgumentNullException.ThrowIfNull(dispatcher); + if (ReferenceEquals(_dispatcher, dispatcher)) + _dispatcher = null; + } + public MovementInput Capture() { // Devtools owns the whole gameplay keyboard while active, including diff --git a/src/AcDream.App/Input/InputCaptureSources.cs b/src/AcDream.App/Input/InputCaptureSources.cs index 7ec77fe5..0c3ff8a3 100644 --- a/src/AcDream.App/Input/InputCaptureSources.cs +++ b/src/AcDream.App/Input/InputCaptureSources.cs @@ -52,23 +52,3 @@ internal sealed class CompositeInputCaptureSource : IInputCaptureSource public bool DevToolsWantCaptureKeyboard => _devTools.WantCaptureKeyboard; } - -internal sealed class DelegateInputCaptureSource : IInputCaptureSource -{ - private readonly Func _wantCaptureMouse; - private readonly Func _wantCaptureKeyboard; - - public DelegateInputCaptureSource( - Func wantCaptureMouse, - Func wantCaptureKeyboard) - { - _wantCaptureMouse = wantCaptureMouse - ?? throw new ArgumentNullException(nameof(wantCaptureMouse)); - _wantCaptureKeyboard = wantCaptureKeyboard - ?? throw new ArgumentNullException(nameof(wantCaptureKeyboard)); - } - - public bool WantCaptureMouse => _wantCaptureMouse(); - public bool WantCaptureKeyboard => _wantCaptureKeyboard(); - public bool DevToolsWantCaptureKeyboard => false; -} diff --git a/src/AcDream.App/Input/QuiescentInputContext.cs b/src/AcDream.App/Input/QuiescentInputContext.cs new file mode 100644 index 00000000..a4d1c6ea --- /dev/null +++ b/src/AcDream.App/Input/QuiescentInputContext.cs @@ -0,0 +1,486 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Silk.NET.Input; + +namespace AcDream.App.Input; + +/// +/// Non-owning input-context view for third-party frontends such as Silk's +/// ImGuiController. It interposes named device relays so frontend callbacks can +/// be silenced before a long live-session close and physically detached later, +/// without disposing the canonical Silk input context. +/// +internal sealed class QuiescentInputContext : IInputContext +{ + private readonly IInputContext _inner; + private readonly HostQuiescenceGate _quiescence; + private readonly QuiescentKeyboard[] _keyboards; + private readonly QuiescentMouse[] _mice; + private readonly Action _connectionChanged; + private Action? _connectionSubscribers; + private ResourceShutdownTransaction? _detach; + private bool _connectionAttached; + private int _active; + private bool _activationStarted; + private int _disposeRequested; + + public QuiescentInputContext( + IInputContext inner, + HostQuiescenceGate quiescence) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); + _keyboards = inner.Keyboards + .Select(keyboard => new QuiescentKeyboard(keyboard, quiescence)) + .ToArray(); + _mice = inner.Mice + .Select(mouse => new QuiescentMouse(mouse, quiescence)) + .ToArray(); + _connectionChanged = OnConnectionChanged; + } + + public nint Handle => _inner.Handle; + public IReadOnlyList Gamepads => _inner.Gamepads; + public IReadOnlyList Joysticks => _inner.Joysticks; + public IReadOnlyList Keyboards => _keyboards; + public IReadOnlyList Mice => _mice; + public IReadOnlyList OtherDevices => _inner.OtherDevices; + + public event Action? ConnectionChanged + { + add + { + if (value is null) return; + ThrowIfDisposed(); + _connectionSubscribers += value; + if (_connectionAttached) return; + _connectionAttached = true; + _inner.ConnectionChanged += _connectionChanged; + } + remove + { + if (value is null) return; + _connectionSubscribers -= value; + if (_connectionSubscribers is not null || !_connectionAttached) return; + _inner.ConnectionChanged -= _connectionChanged; + _connectionAttached = false; + } + } + + public bool IsDisposalComplete => + !_connectionAttached + && _keyboards.All(static keyboard => keyboard.IsDisposalComplete) + && _mice.All(static mouse => mouse.IsDisposalComplete); + + /// + /// Opens delivery only after the third-party frontend constructor has + /// finished installing every callback. An event raised reentrantly from an + /// add accessor therefore cannot enter a partially constructed frontend. + /// + public void Activate() + { + ThrowIfDisposed(); + if (_activationStarted) + throw new InvalidOperationException( + "Frontend input activation has already been attempted."); + _activationStarted = true; + foreach (QuiescentKeyboard keyboard in _keyboards) + keyboard.Activate(); + foreach (QuiescentMouse mouse in _mice) + mouse.Activate(); + Volatile.Write(ref _active, 1); + } + + public void Deactivate() + { + Interlocked.Exchange(ref _active, 0); + foreach (QuiescentKeyboard keyboard in _keyboards) + keyboard.Deactivate(); + foreach (QuiescentMouse mouse in _mice) + mouse.Deactivate(); + } + + public void Dispose() + { + Interlocked.Exchange(ref _disposeRequested, 1); + foreach (QuiescentKeyboard keyboard in _keyboards) + keyboard.RequestDisposal(); + foreach (QuiescentMouse mouse in _mice) + mouse.RequestDisposal(); + Deactivate(); + EnsureDetachTransaction().CompleteOrThrow(); + } + + private void ThrowIfDisposed() => + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + + private void OnConnectionChanged(IInputDevice device, bool connected) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + _connectionSubscribers?.Invoke(device, connected); + }); + + private ResourceShutdownTransaction EnsureDetachTransaction() + { + if (_detach is not null) + return _detach; + + var operations = new List(); + for (int i = _mice.Length - 1; i >= 0; i--) + { + int index = i; + operations.Add(new ResourceShutdownOperation( + $"frontend mouse {index}", + _mice[index].Dispose)); + } + for (int i = _keyboards.Length - 1; i >= 0; i--) + { + int index = i; + operations.Add(new ResourceShutdownOperation( + $"frontend keyboard {index}", + _keyboards[index].Dispose)); + } + operations.Add(new ResourceShutdownOperation( + "frontend connection event", + RemoveConnectionChanged)); + _detach = new ResourceShutdownTransaction( + new ResourceShutdownStage("frontend input callbacks", operations.ToArray())); + return _detach; + } + + private void RemoveConnectionChanged() + { + if (!_connectionAttached) return; + _inner.ConnectionChanged -= _connectionChanged; + _connectionAttached = false; + _connectionSubscribers = null; + } + + private sealed class QuiescentKeyboard : IKeyboard, IDisposable + { + private readonly IKeyboard _inner; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _downRelay; + private readonly Action _upRelay; + private readonly Action _charRelay; + private Action? _downSubscribers; + private Action? _upSubscribers; + private Action? _charSubscribers; + private readonly bool[] _attached = new bool[3]; + private ResourceShutdownTransaction? _detach; + private int _disposeRequested; + private int _active; + + public QuiescentKeyboard(IKeyboard inner, HostQuiescenceGate quiescence) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _quiescence = quiescence; + _downRelay = OnDown; + _upRelay = OnUp; + _charRelay = OnChar; + } + + public string Name => _inner.Name; + public int Index => _inner.Index; + public bool IsConnected => _inner.IsConnected; + public IReadOnlyList SupportedKeys => _inner.SupportedKeys; + public string ClipboardText + { + get => _inner.ClipboardText; + set => _inner.ClipboardText = value; + } + + public event Action? KeyDown + { + add + { + if (value is null) return; + ThrowIfDisposed(); + _downSubscribers += value; + if (_attached[0]) return; + _attached[0] = true; + _inner.KeyDown += _downRelay; + } + remove + { + if (value is null) return; + _downSubscribers -= value; + if (_downSubscribers is not null || !_attached[0]) return; + _inner.KeyDown -= _downRelay; + _attached[0] = false; + } + } + + public event Action? KeyUp + { + add + { + if (value is null) return; + ThrowIfDisposed(); + _upSubscribers += value; + if (_attached[1]) return; + _attached[1] = true; + _inner.KeyUp += _upRelay; + } + remove + { + if (value is null) return; + _upSubscribers -= value; + if (_upSubscribers is not null || !_attached[1]) return; + _inner.KeyUp -= _upRelay; + _attached[1] = false; + } + } + + public event Action? KeyChar + { + add + { + if (value is null) return; + ThrowIfDisposed(); + _charSubscribers += value; + if (_attached[2]) return; + _attached[2] = true; + _inner.KeyChar += _charRelay; + } + remove + { + if (value is null) return; + _charSubscribers -= value; + if (_charSubscribers is not null || !_attached[2]) return; + _inner.KeyChar -= _charRelay; + _attached[2] = false; + } + } + + public bool IsDisposalComplete => _attached.All(static value => !value); + public bool IsKeyPressed(Key key) => _inner.IsKeyPressed(key); + public bool IsScancodePressed(int scancode) => _inner.IsScancodePressed(scancode); + public void BeginInput() => _inner.BeginInput(); + public void EndInput() => _inner.EndInput(); + public void Activate() + { + ThrowIfDisposed(); + Volatile.Write(ref _active, 1); + } + public void Deactivate() => Interlocked.Exchange(ref _active, 0); + + public void RequestDisposal() => + Interlocked.Exchange(ref _disposeRequested, 1); + + public void Dispose() + { + RequestDisposal(); + Deactivate(); + _detach ??= new ResourceShutdownTransaction( + new ResourceShutdownStage("frontend keyboard callbacks", + [ + new("character", () => Remove(2)), + new("up", () => Remove(1)), + new("down", () => Remove(0)), + ])); + _detach.CompleteOrThrow(); + } + + private void ThrowIfDisposed() => + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + + private void OnDown(IKeyboard _, Key key, int scanCode) => + Invoke(() => _downSubscribers?.Invoke(this, key, scanCode)); + private void OnUp(IKeyboard _, Key key, int scanCode) => + Invoke(() => _upSubscribers?.Invoke(this, key, scanCode)); + private void OnChar(IKeyboard _, char value) => + Invoke(() => _charSubscribers?.Invoke(this, value)); + private void Invoke(Action callback) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + callback(); + }); + + private void Remove(int index) + { + if (!_attached[index]) return; + switch (index) + { + case 0: _inner.KeyDown -= _downRelay; _downSubscribers = null; break; + case 1: _inner.KeyUp -= _upRelay; _upSubscribers = null; break; + case 2: _inner.KeyChar -= _charRelay; _charSubscribers = null; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + _attached[index] = false; + } + } + + private sealed class QuiescentMouse : IMouse, IDisposable + { + private readonly IMouse _inner; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _downRelay; + private readonly Action _upRelay; + private readonly Action _clickRelay; + private readonly Action _doubleClickRelay; + private readonly Action _moveRelay; + private readonly Action _scrollRelay; + private Action? _downSubscribers; + private Action? _upSubscribers; + private Action? _clickSubscribers; + private Action? _doubleClickSubscribers; + private Action? _moveSubscribers; + private Action? _scrollSubscribers; + private readonly bool[] _attached = new bool[6]; + private ResourceShutdownTransaction? _detach; + private int _disposeRequested; + private int _active; + + public QuiescentMouse(IMouse inner, HostQuiescenceGate quiescence) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _quiescence = quiescence; + _downRelay = OnDown; + _upRelay = OnUp; + _clickRelay = OnClick; + _doubleClickRelay = OnDoubleClick; + _moveRelay = OnMove; + _scrollRelay = OnScroll; + } + + public string Name => _inner.Name; + public int Index => _inner.Index; + public bool IsConnected => _inner.IsConnected; + public IReadOnlyList SupportedButtons => _inner.SupportedButtons; + public IReadOnlyList ScrollWheels => _inner.ScrollWheels; + public Vector2 Position { get => _inner.Position; set => _inner.Position = value; } + public ICursor Cursor => _inner.Cursor; + public int DoubleClickTime { get => _inner.DoubleClickTime; set => _inner.DoubleClickTime = value; } + public int DoubleClickRange { get => _inner.DoubleClickRange; set => _inner.DoubleClickRange = value; } + + public event Action MouseDown + { + add => Add(ref _downSubscribers, value, 0, () => _inner.MouseDown += _downRelay); + remove => RemoveSubscriber(ref _downSubscribers, value, 0, () => _inner.MouseDown -= _downRelay); + } + public event Action MouseUp + { + add => Add(ref _upSubscribers, value, 1, () => _inner.MouseUp += _upRelay); + remove => RemoveSubscriber(ref _upSubscribers, value, 1, () => _inner.MouseUp -= _upRelay); + } + public event Action Click + { + add => Add(ref _clickSubscribers, value, 2, () => _inner.Click += _clickRelay); + remove => RemoveSubscriber(ref _clickSubscribers, value, 2, () => _inner.Click -= _clickRelay); + } + public event Action DoubleClick + { + add => Add(ref _doubleClickSubscribers, value, 3, () => _inner.DoubleClick += _doubleClickRelay); + remove => RemoveSubscriber(ref _doubleClickSubscribers, value, 3, () => _inner.DoubleClick -= _doubleClickRelay); + } + public event Action MouseMove + { + add => Add(ref _moveSubscribers, value, 4, () => _inner.MouseMove += _moveRelay); + remove => RemoveSubscriber(ref _moveSubscribers, value, 4, () => _inner.MouseMove -= _moveRelay); + } + public event Action Scroll + { + add => Add(ref _scrollSubscribers, value, 5, () => _inner.Scroll += _scrollRelay); + remove => RemoveSubscriber(ref _scrollSubscribers, value, 5, () => _inner.Scroll -= _scrollRelay); + } + + public bool IsDisposalComplete => _attached.All(static value => !value); + public bool IsButtonPressed(MouseButton button) => _inner.IsButtonPressed(button); + public void Activate() + { + ThrowIfDisposed(); + Volatile.Write(ref _active, 1); + } + public void Deactivate() => Interlocked.Exchange(ref _active, 0); + + public void RequestDisposal() => + Interlocked.Exchange(ref _disposeRequested, 1); + + public void Dispose() + { + RequestDisposal(); + Deactivate(); + _detach ??= new ResourceShutdownTransaction( + new ResourceShutdownStage("frontend mouse callbacks", + [ + new("scroll", () => RemovePhysical(5)), + new("move", () => RemovePhysical(4)), + new("double click", () => RemovePhysical(3)), + new("click", () => RemovePhysical(2)), + new("up", () => RemovePhysical(1)), + new("down", () => RemovePhysical(0)), + ])); + _detach.CompleteOrThrow(); + } + + private void ThrowIfDisposed() => + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + + private void OnDown(IMouse _, MouseButton button) => + Invoke(() => _downSubscribers?.Invoke(this, button)); + private void OnUp(IMouse _, MouseButton button) => + Invoke(() => _upSubscribers?.Invoke(this, button)); + private void OnClick(IMouse _, MouseButton button, Vector2 position) => + Invoke(() => _clickSubscribers?.Invoke(this, button, position)); + private void OnDoubleClick(IMouse _, MouseButton button, Vector2 position) => + Invoke(() => _doubleClickSubscribers?.Invoke(this, button, position)); + private void OnMove(IMouse _, Vector2 position) => + Invoke(() => _moveSubscribers?.Invoke(this, position)); + private void OnScroll(IMouse _, ScrollWheel scroll) => + Invoke(() => _scrollSubscribers?.Invoke(this, scroll)); + private void Invoke(Action callback) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + callback(); + }); + + private void Add(ref T? subscribers, T value, int index, Action attach) + where T : Delegate + { + ThrowIfDisposed(); + subscribers = (T?)Delegate.Combine(subscribers, value); + if (_attached[index]) return; + _attached[index] = true; + attach(); + } + + private void RemoveSubscriber( + ref T? subscribers, + T value, + int index, + Action detach) + where T : Delegate + { + subscribers = (T?)Delegate.Remove(subscribers, value); + if (subscribers is not null || !_attached[index]) return; + detach(); + _attached[index] = false; + } + + private void RemovePhysical(int index) + { + if (!_attached[index]) return; + switch (index) + { + case 0: _inner.MouseDown -= _downRelay; _downSubscribers = null; break; + case 1: _inner.MouseUp -= _upRelay; _upSubscribers = null; break; + case 2: _inner.Click -= _clickRelay; _clickSubscribers = null; break; + case 3: _inner.DoubleClick -= _doubleClickRelay; _doubleClickSubscribers = null; break; + case 4: _inner.MouseMove -= _moveRelay; _moveSubscribers = null; break; + case 5: _inner.Scroll -= _scrollRelay; _scrollSubscribers = null; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + _attached[index] = false; + } + } +} diff --git a/src/AcDream.App/Input/SilkKeyboardSource.cs b/src/AcDream.App/Input/SilkKeyboardSource.cs index 65c1d1dd..bf1c17d1 100644 --- a/src/AcDream.App/Input/SilkKeyboardSource.cs +++ b/src/AcDream.App/Input/SilkKeyboardSource.cs @@ -1,46 +1,218 @@ -using System; +using AcDream.App.Rendering; using AcDream.UI.Abstractions.Input; using Silk.NET.Input; namespace AcDream.App.Input; -/// -/// Bridges a Silk.NET to the test-fakeable -/// interface used by -/// . K.1a wiring: GameWindow constructs -/// one of these wrapping the first keyboard from -/// IInputContext.Keyboards; the dispatcher fans events out to -/// subscribers without ever touching Silk types directly. -/// -public sealed class SilkKeyboardSource : IKeyboardSource +internal interface IKeyboardEventSurface +{ + void AddKeyDown(Action callback); + void RemoveKeyDown(Action callback); + void AddKeyUp(Action callback); + void RemoveKeyUp(Action callback); + bool IsKeyPressed(Key key); +} + +internal sealed class SilkKeyboardEventSurface : IKeyboardEventSurface { private readonly IKeyboard _keyboard; + private Action? _keyDownCallback; + private Action? _keyUpCallback; + private readonly Action _keyDown; + private readonly Action _keyUp; + + public SilkKeyboardEventSurface(IKeyboard keyboard) + { + _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); + _keyDown = OnKeyDown; + _keyUp = OnKeyUp; + } + + public void AddKeyDown(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); + _keyDownCallback = callback; + _keyboard.KeyDown += _keyDown; + } + + public void RemoveKeyDown(Action callback) + { + if (!ReferenceEquals(_keyDownCallback, callback)) + return; + _keyboard.KeyDown -= _keyDown; + _keyDownCallback = null; + } + + public void AddKeyUp(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); + _keyUpCallback = callback; + _keyboard.KeyUp += _keyUp; + } + + public void RemoveKeyUp(Action callback) + { + if (!ReferenceEquals(_keyUpCallback, callback)) + return; + _keyboard.KeyUp -= _keyUp; + _keyUpCallback = null; + } + + public bool IsKeyPressed(Key key) => _keyboard.IsKeyPressed(key); + + private void OnKeyDown(IKeyboard _, Key key, int __) => + _keyDownCallback?.Invoke(key); + + private void OnKeyUp(IKeyboard _, Key key, int __) => + _keyUpCallback?.Invoke(key); +} + +/// +/// Reversible Silk keyboard bridge. Production publishes the detached owner +/// before Attach so a side-effecting event accessor can never orphan a +/// subscription. Logical deactivation makes copied delegates inert while +/// physical removal is retried. +/// +public sealed class SilkKeyboardSource : IKeyboardSource, IDisposable +{ + private readonly IKeyboardEventSurface _surface; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _keyDown; + private readonly Action _keyUp; + private readonly bool[] _attached = new bool[2]; + private ResourceShutdownTransaction? _detach; + private bool _attachStarted; + private int _disposeRequested; + private int _active; public event Action? KeyDown; public event Action? KeyUp; - public SilkKeyboardSource(IKeyboard keyboard) + private SilkKeyboardSource( + IKeyboardEventSurface surface, + HostQuiescenceGate quiescence) { - _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); - _keyboard.KeyDown += (_, key, _) => KeyDown?.Invoke(key, ReadModifiers()); - _keyboard.KeyUp += (_, key, _) => KeyUp?.Invoke(key, ReadModifiers()); + _surface = surface ?? throw new ArgumentNullException(nameof(surface)); + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); + _keyDown = OnKeyDown; + _keyUp = OnKeyUp; } - public bool IsHeld(Key key) => _keyboard.IsKeyPressed(key); + internal static SilkKeyboardSource CreateDetached( + IKeyboard keyboard, + HostQuiescenceGate quiescence) => + new( + new SilkKeyboardEventSurface(keyboard), + quiescence); + + internal static SilkKeyboardSource CreateDetached( + IKeyboardEventSurface surface, + HostQuiescenceGate quiescence) => + new(surface, quiescence); + + public bool IsDisposalComplete => _attached.All(static value => !value); + + public void Attach() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + if (_attachStarted) + throw new InvalidOperationException("Keyboard source attachment has already started."); + _attachStarted = true; + + try + { + _attached[0] = true; + _surface.AddKeyDown(_keyDown); + _attached[1] = true; + _surface.AddKeyUp(_keyUp); + Volatile.Write(ref _active, 1); + } + catch (Exception attachError) + { + Deactivate(); + RollBackOrThrow(attachError); + } + } + + public bool IsHeld(Key key) => _surface.IsKeyPressed(key); public ModifierMask CurrentModifiers => ReadModifiers(); + public void Deactivate() => Interlocked.Exchange(ref _active, 0); + + public void Dispose() + { + Interlocked.Exchange(ref _disposeRequested, 1); + Deactivate(); + EnsureDetachTransaction().CompleteOrThrow(); + } + + private void OnKeyDown(Key key) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + KeyDown?.Invoke(key, ReadModifiers()); + }); + + private void OnKeyUp(Key key) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + KeyUp?.Invoke(key, ReadModifiers()); + }); + private ModifierMask ReadModifiers() { - ModifierMask m = ModifierMask.None; - if (_keyboard.IsKeyPressed(Key.ShiftLeft) || _keyboard.IsKeyPressed(Key.ShiftRight)) - m |= ModifierMask.Shift; - if (_keyboard.IsKeyPressed(Key.ControlLeft) || _keyboard.IsKeyPressed(Key.ControlRight)) - m |= ModifierMask.Ctrl; - if (_keyboard.IsKeyPressed(Key.AltLeft) || _keyboard.IsKeyPressed(Key.AltRight)) - m |= ModifierMask.Alt; - if (_keyboard.IsKeyPressed(Key.SuperLeft) || _keyboard.IsKeyPressed(Key.SuperRight)) - m |= ModifierMask.Win; - return m; + ModifierMask modifiers = ModifierMask.None; + if (_surface.IsKeyPressed(Key.ShiftLeft) || _surface.IsKeyPressed(Key.ShiftRight)) + modifiers |= ModifierMask.Shift; + if (_surface.IsKeyPressed(Key.ControlLeft) || _surface.IsKeyPressed(Key.ControlRight)) + modifiers |= ModifierMask.Ctrl; + if (_surface.IsKeyPressed(Key.AltLeft) || _surface.IsKeyPressed(Key.AltRight)) + modifiers |= ModifierMask.Alt; + if (_surface.IsKeyPressed(Key.SuperLeft) || _surface.IsKeyPressed(Key.SuperRight)) + modifiers |= ModifierMask.Win; + return modifiers; + } + + private ResourceShutdownTransaction EnsureDetachTransaction() => + _detach ??= new ResourceShutdownTransaction( + new ResourceShutdownStage("keyboard source callbacks", + [ + new("key up", () => Remove(1)), + new("key down", () => Remove(0)), + ])); + + private void Remove(int index) + { + if (!_attached[index]) + return; + if (index == 1) + _surface.RemoveKeyUp(_keyUp); + else + _surface.RemoveKeyDown(_keyDown); + _attached[index] = false; + } + + private void RollBackOrThrow(Exception attachError) + { + try + { + EnsureDetachTransaction().CompleteOrThrow(); + } + catch (Exception rollbackError) + { + throw new AggregateException( + "Keyboard source registration and rollback both failed.", + new InvalidOperationException( + "Keyboard source registration failed.", attachError), + rollbackError); + } + + throw new InvalidOperationException( + "Keyboard source registration failed and was rolled back.", + attachError); } } diff --git a/src/AcDream.App/Input/SilkMouseSource.cs b/src/AcDream.App/Input/SilkMouseSource.cs index 39e29487..a8530a64 100644 --- a/src/AcDream.App/Input/SilkMouseSource.cs +++ b/src/AcDream.App/Input/SilkMouseSource.cs @@ -1,92 +1,295 @@ -using System; +using System.Numerics; +using AcDream.App.Rendering; using AcDream.UI.Abstractions.Input; using Silk.NET.Input; namespace AcDream.App.Input; -/// -/// Bridges a Silk.NET + the active ImGui IO -/// (for / ) -/// to the test-fakeable used by -/// . -/// -/// -/// Production receives a typed capture source, keeping retained-UI and -/// developer-UI ownership outside the Silk adapter. The public delegate -/// overload remains as a compatibility seam for standalone consumers. -/// -/// -public sealed class SilkMouseSource : IMouseSource +internal interface IMouseEventSurface +{ + void AddMouseDown(Action callback); + void RemoveMouseDown(Action callback); + void AddMouseUp(Action callback); + void RemoveMouseUp(Action callback); + void AddMouseMove(Action callback); + void RemoveMouseMove(Action callback); + void AddScroll(Action callback); + void RemoveScroll(Action callback); + bool IsButtonPressed(MouseButton button); +} + +internal sealed class SilkMouseEventSurface : IMouseEventSurface { private readonly IMouse _mouse; + private Action? _downCallback; + private Action? _upCallback; + private Action? _moveCallback; + private Action? _scrollCallback; + private readonly Action _down; + private readonly Action _up; + private readonly Action _move; + private readonly Action _scroll; + + public SilkMouseEventSurface(IMouse mouse) + { + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _down = OnDown; + _up = OnUp; + _move = OnMove; + _scroll = OnScroll; + } + + public void AddMouseDown(Action callback) + { + _downCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.MouseDown += _down; + } + + public void RemoveMouseDown(Action callback) + { + if (!ReferenceEquals(_downCallback, callback)) return; + _mouse.MouseDown -= _down; + _downCallback = null; + } + + public void AddMouseUp(Action callback) + { + _upCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.MouseUp += _up; + } + + public void RemoveMouseUp(Action callback) + { + if (!ReferenceEquals(_upCallback, callback)) return; + _mouse.MouseUp -= _up; + _upCallback = null; + } + + public void AddMouseMove(Action callback) + { + _moveCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.MouseMove += _move; + } + + public void RemoveMouseMove(Action callback) + { + if (!ReferenceEquals(_moveCallback, callback)) return; + _mouse.MouseMove -= _move; + _moveCallback = null; + } + + public void AddScroll(Action callback) + { + _scrollCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.Scroll += _scroll; + } + + public void RemoveScroll(Action callback) + { + if (!ReferenceEquals(_scrollCallback, callback)) return; + _mouse.Scroll -= _scroll; + _scrollCallback = null; + } + + public bool IsButtonPressed(MouseButton button) => _mouse.IsButtonPressed(button); + + private void OnDown(IMouse _, MouseButton button) => _downCallback?.Invoke(button); + private void OnUp(IMouse _, MouseButton button) => _upCallback?.Invoke(button); + private void OnMove(IMouse _, Vector2 position) => _moveCallback?.Invoke(position); + private void OnScroll(IMouse _, ScrollWheel scroll) => _scrollCallback?.Invoke(scroll.Y); +} + +/// Reversible Silk mouse bridge with immediate logical cutoff. +public sealed class SilkMouseSource : IMouseSource, IDisposable +{ + private readonly IMouseEventSurface _surface; private readonly IInputCaptureSource _capture; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _mouseDown; + private readonly Action _mouseUp; + private readonly Action _mouseMove; + private readonly Action _scroll; + private readonly bool[] _attached = new bool[4]; + private ResourceShutdownTransaction? _detach; + private bool _attachStarted; + private int _disposeRequested; + private int _active; private float _lastX; private float _lastY; - private bool _haveLastPos; + private bool _haveLastPosition; public event Action? MouseDown; public event Action? MouseUp; public event Action? MouseMove; public event Action? Scroll; - /// The keyboard source that owns the current modifier mask, so - /// mouse events and keyboard events resolve identical chords. public IKeyboardSource? ModifierSource { get; set; } - internal SilkMouseSource( + private SilkMouseSource( + IMouseEventSurface surface, + IInputCaptureSource capture, + IKeyboardSource? modifierSource, + HostQuiescenceGate quiescence) + { + _surface = surface ?? throw new ArgumentNullException(nameof(surface)); + _capture = capture ?? throw new ArgumentNullException(nameof(capture)); + ModifierSource = modifierSource; + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); + _mouseDown = OnMouseDown; + _mouseUp = OnMouseUp; + _mouseMove = OnMouseMove; + _scroll = OnScroll; + } + + internal static SilkMouseSource CreateDetached( IMouse mouse, IInputCaptureSource capture, - IKeyboardSource modifierSource) - { - _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); - _capture = capture ?? throw new ArgumentNullException(nameof(capture)); - ModifierSource = modifierSource - ?? throw new ArgumentNullException(nameof(modifierSource)); - Subscribe(); - } + IKeyboardSource? modifierSource, + HostQuiescenceGate quiescence) => + new( + new SilkMouseEventSurface(mouse), + capture, + modifierSource, + quiescence); - public SilkMouseSource( - IMouse mouse, - Func wantCaptureMouse, - Func wantCaptureKeyboard) - { - _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); - _capture = new DelegateInputCaptureSource( - wantCaptureMouse, - wantCaptureKeyboard); - Subscribe(); - } + internal static SilkMouseSource CreateDetached( + IMouseEventSurface surface, + IInputCaptureSource capture, + IKeyboardSource? modifierSource, + HostQuiescenceGate quiescence) => + new(surface, capture, modifierSource, quiescence); - private void Subscribe() + public bool IsDisposalComplete => _attached.All(static value => !value); + + public void Attach() { - _mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ReadModifiers()); - _mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ReadModifiers()); - _mouse.MouseMove += (_, pos) => + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + if (_attachStarted) + throw new InvalidOperationException("Mouse source attachment has already started."); + _attachStarted = true; + try { - float dx, dy; - if (_haveLastPos) - { - dx = pos.X - _lastX; - dy = pos.Y - _lastY; - } - else - { - dx = 0f; - dy = 0f; - _haveLastPos = true; - } - _lastX = pos.X; - _lastY = pos.Y; - MouseMove?.Invoke(dx, dy); - }; - _mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y); + _attached[0] = true; + _surface.AddMouseDown(_mouseDown); + _attached[1] = true; + _surface.AddMouseUp(_mouseUp); + _attached[2] = true; + _surface.AddMouseMove(_mouseMove); + _attached[3] = true; + _surface.AddScroll(_scroll); + Volatile.Write(ref _active, 1); + } + catch (Exception attachError) + { + Deactivate(); + RollBackOrThrow(attachError); + } + } + + public bool IsHeld(MouseButton button) => _surface.IsButtonPressed(button); + public bool WantCaptureMouse => _capture.WantCaptureMouse; + public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard; + + public void Deactivate() => Interlocked.Exchange(ref _active, 0); + + public void Dispose() + { + Interlocked.Exchange(ref _disposeRequested, 1); + Deactivate(); + EnsureDetachTransaction().CompleteOrThrow(); } private ModifierMask ReadModifiers() => ModifierSource?.CurrentModifiers ?? ModifierMask.None; - public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button); + private void OnMouseDown(MouseButton button) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + MouseDown?.Invoke(button, ReadModifiers()); + }); - public bool WantCaptureMouse => _capture.WantCaptureMouse; - public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard; + private void OnMouseUp(MouseButton button) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + MouseUp?.Invoke(button, ReadModifiers()); + }); + + private void OnMouseMove(Vector2 position) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) == 0) + return; + + float dx = 0f; + float dy = 0f; + if (_haveLastPosition) + { + dx = position.X - _lastX; + dy = position.Y - _lastY; + } + else + { + _haveLastPosition = true; + } + + _lastX = position.X; + _lastY = position.Y; + MouseMove?.Invoke(dx, dy); + }); + + private void OnScroll(float delta) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + Scroll?.Invoke(delta); + }); + + private ResourceShutdownTransaction EnsureDetachTransaction() => + _detach ??= new ResourceShutdownTransaction( + new ResourceShutdownStage("mouse source callbacks", + [ + new("scroll", () => Remove(3)), + new("move", () => Remove(2)), + new("up", () => Remove(1)), + new("down", () => Remove(0)), + ])); + + private void Remove(int index) + { + if (!_attached[index]) + return; + switch (index) + { + case 0: _surface.RemoveMouseDown(_mouseDown); break; + case 1: _surface.RemoveMouseUp(_mouseUp); break; + case 2: _surface.RemoveMouseMove(_mouseMove); break; + case 3: _surface.RemoveScroll(_scroll); break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + _attached[index] = false; + } + + private void RollBackOrThrow(Exception attachError) + { + try + { + EnsureDetachTransaction().CompleteOrThrow(); + } + catch (Exception rollbackError) + { + throw new AggregateException( + "Mouse source registration and rollback both failed.", + new InvalidOperationException( + "Mouse source registration failed.", attachError), + rollbackError); + } + + throw new InvalidOperationException( + "Mouse source registration failed and was rolled back.", + attachError); + } } diff --git a/src/AcDream.App/Rendering/FramebufferResizeController.cs b/src/AcDream.App/Rendering/FramebufferResizeController.cs new file mode 100644 index 00000000..411aba92 --- /dev/null +++ b/src/AcDream.App/Rendering/FramebufferResizeController.cs @@ -0,0 +1,118 @@ +using AcDream.App.Input; +using Silk.NET.Maths; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +internal interface IFramebufferViewportTarget +{ + void ResizeViewport(int width, int height); +} + +internal sealed class SilkFramebufferViewportTarget(GL gl) + : IFramebufferViewportTarget +{ + private readonly GL _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + + public void ResizeViewport(int width, int height) => + _gl.Viewport(0, 0, (uint)width, (uint)height); +} + +internal interface IFramebufferCameraTarget +{ + void SetAspect(float aspect); +} + +internal sealed class CameraFramebufferTarget(CameraController camera) + : IFramebufferCameraTarget +{ + private readonly CameraController _camera = camera + ?? throw new ArgumentNullException(nameof(camera)); + + public void SetAspect(float aspect) => _camera.SetAspect(aspect); +} + +internal interface IFramebufferDevToolsTarget +{ + void ResetLayout(int width, int height); +} + +internal sealed class DevToolsFramebufferTarget(DevToolsFramePresenter presenter) + : IFramebufferDevToolsTarget +{ + private readonly DevToolsFramePresenter _presenter = presenter + ?? throw new ArgumentNullException(nameof(presenter)); + + public void ResetLayout(int width, int height) => + _presenter.ResetLayout( + width, + height, + DevToolsPanelLayoutCondition.Always); +} + +/// +/// The one framebuffer-size target. Logical gameplay/UI dimensions continue +/// to come from Window.Size; this owner only applies physical framebuffer +/// viewport/aspect changes in the frozen acdream host order. +/// +internal sealed class FramebufferResizeController +{ + private readonly ViewportAspectState _viewportAspect; + private IFramebufferViewportTarget? _viewport; + private IFramebufferCameraTarget? _camera; + private IFramebufferDevToolsTarget? _devTools; + + public FramebufferResizeController(ViewportAspectState viewportAspect) => + _viewportAspect = viewportAspect + ?? throw new ArgumentNullException(nameof(viewportAspect)); + + public void BindViewport(IFramebufferViewportTarget viewport) + { + ArgumentNullException.ThrowIfNull(viewport); + BindOnce(ref _viewport, viewport, "viewport"); + } + + public void BindCamera(IFramebufferCameraTarget camera) + { + ArgumentNullException.ThrowIfNull(camera); + BindOnce(ref _camera, camera, "camera"); + } + + public void BindDevTools(IFramebufferDevToolsTarget devTools) + { + ArgumentNullException.ThrowIfNull(devTools); + BindOnce(ref _devTools, devTools, "developer tools"); + } + + public void UnbindDevTools(IFramebufferDevToolsTarget devTools) + { + ArgumentNullException.ThrowIfNull(devTools); + if (ReferenceEquals(_devTools, devTools)) + _devTools = null; + } + + public void Resize(Vector2D newSize) => Resize(newSize.X, newSize.Y); + + public void Resize(int width, int height) + { + if (width <= 0 || height <= 0) + return; + + // Frozen order: GL viewport, shared aspect publication, camera, then + // optional ImGui layout reset. Late binding never replays an earlier + // resize transition. + _viewport?.ResizeViewport(width, height); + _viewportAspect.Update(width, height); + _camera?.SetAspect(width / (float)height); + _devTools?.ResetLayout(width, height); + } + + private static void BindOnce(ref T? slot, T value, string name) + where T : class + { + if (slot is not null && !ReferenceEquals(slot, value)) + throw new InvalidOperationException( + $"The framebuffer {name} target is already bound."); + slot = value; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3e185b01..17e01e66 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -27,6 +27,7 @@ public sealed class GameWindow : IDisposable private SilkWindowCallbackBinding? _windowCallbacks; private GL? _gl; private IInputContext? _input; + private AcDream.App.Input.QuiescentInputContext? _devToolsInputContext; private TerrainModernRenderer? _terrain; /// Phase N.5b: terrain_modern.vert/.frag program. Owned by /// at draw time but allocated + disposed here. @@ -34,16 +35,7 @@ public sealed class GameWindow : IDisposable private CameraController? _cameraController; private IDatReaderWriter? _dats; private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new(); - private float _lastMouseX - { - get => _pointerPosition.X; - set => _pointerPosition.X = value; - } - private float _lastMouseY - { - get => _pointerPosition.Y; - set => _pointerPosition.Y = value; - } + private AcDream.App.Input.CameraPointerInputController? _cameraPointerInput; private Shader? _meshShader; private TextureCache? _textureCache; /// Phase N.4+: WB-backed rendering pipeline adapter. Always non-null @@ -434,6 +426,8 @@ public sealed class GameWindow : IDisposable private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new(); private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new(); + private readonly FramebufferResizeController _framebufferResize; + private IFramebufferDevToolsTarget? _framebufferDevToolsTarget; private AcDream.App.Input.PlayerModeController? _playerModeController; private readonly AcDream.App.Interaction.PlayerApproachCompletionState _playerApproachCompletions = new(); @@ -444,26 +438,6 @@ public sealed class GameWindow : IDisposable // Phase D.2b-C — live character-sheet assembly + raise flow (extracted // feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1. private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider; - // Mouse sensitivity multipliers — one per camera mode because the visual - // feel is very different. Adjust via F8 / F9 for whichever mode is - // currently active. Chase default is low because the character + camera - // rotating together is overwhelming at fly speeds. - private float _sensChase - { - get => _chaseCameraInput.Sensitivity; - set => _chaseCameraInput.Sensitivity = value; - } - private float _sensFly = 1.0f; - private float _sensOrbit = 1.0f; - - // Right-mouse-button held → free-orbit the chase camera around the - // player without turning the character. Release leaves the camera at - // the orbited position (no snap back). - private bool _rmbHeld - { - get => _chaseCameraInput.RmbOrbitHeld; - } - // Phase K.2 — auto-enter player mode after a successful login. Armed // by LiveSessionHost's entered-world transition; ticked from // OnUpdate; disarmed if the user manually enters fly mode (or any @@ -579,6 +553,7 @@ public sealed class GameWindow : IDisposable _retainedInputCapture); _movementInput = new AcDream.App.Input.DispatcherMovementInputSource( _inputCapture); + _framebufferResize = new FramebufferResizeController(_viewportAspect); _datDir = options.DatDir; _worldGameState = worldGameState; _worldEvents = worldEvents; @@ -676,6 +651,8 @@ public sealed class GameWindow : IDisposable _physicsEngine.DataCache = _physicsDataCache; _gl = GL.GetApi(_window!); + _framebufferResize.BindViewport( + new SilkFramebufferViewportTarget(_gl)); _gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl); var worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics( new AcDream.App.Rendering.SilkRenderGlStateReader(_gl), @@ -692,113 +669,36 @@ public sealed class GameWindow : IDisposable // per K.1b plan §D). var firstKb = _input.Keyboards.FirstOrDefault(); var firstMouse = _input.Mice.FirstOrDefault(); - if (firstKb is not null && firstMouse is not null) + if (firstKb is not null) { - _kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb); - _mouseSource = new AcDream.App.Input.SilkMouseSource( + _kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached( + firstKb, + _hostQuiescence); + _kbSource.Attach(); + } + if (firstMouse is not null) + { + _mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached( firstMouse, _inputCapture, - _kbSource); - _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( + _kbSource, + _hostQuiescence); + _mouseSource.Attach(); + _mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse); + } + if (_kbSource is not null && _mouseSource is not null) + { + _inputDispatcher = AcDream.UI.Abstractions.Input.InputDispatcher.CreateDetached( _kbSource, _mouseSource, _keyBindings); + _inputDispatcher.Attach(); _movementInput.Bind(_inputDispatcher); _cameraInput.Bind(_inputDispatcher); - _mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse); _inputDispatcher.Fired += OnInputAction; Combat.CombatModeChanged += SetInputCombatScope; SetInputCombatScope(Combat.CurrentMode); } - // Mouse delta handler — kept direct because Silk.NET delivers mouse - // moves as continuous (x, y) positions, not chord events. We gate - // on WantCaptureMouse so panel hover never drives camera. The - // previous "_playerMouseDeltaX += dx * sens" branch is GONE — mouse - // never drives character yaw in K.1b. RMB-held orbit is wired via - // the dispatcher's AcdreamRmbOrbitHold action (subscriber sets - // _rmbHeld below). - foreach (var mouse in _input.Mice) - { - mouse.MouseMove += (m, pos) => - { - // K.1b §E: explicit WantCaptureMouse defense-in-depth on the - // surviving direct-mouse handler. Suppresses RMB orbit / - // FlyCamera look while ImGui has the mouse focus. - if (_inputCapture.WantCaptureMouse) - { - _lastMouseX = pos.X; - _lastMouseY = pos.Y; - return; - } - if (_cameraController is null) return; - - float dx = pos.X - _lastMouseX; - float dy = pos.Y - _lastMouseY; - - if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null) - { - float sens = _sensChase; - if (_gameplayInputFrame?.MouseLookActive == true) - { - // DirectInput may deliver several device records before - // one retail GetInput pass. Accumulate raw motion here; - // the update loop filters the aggregate exactly once. - _gameplayInputFrame.QueueRawMouseDelta(dx, dy); - } - else if (_rmbHeld) - { - // Hold-RMB orbit: player stays the central point, camera - // free-orbits around. X rotates around, Y pitches. On release - // the camera STAYS at the new angle (no snap back). - // K.1b: this is the ONLY mouse-delta path that affects - // ANYTHING when in player mode — character yaw is - // dispatcher-only (A/D keys). K.2: MMB mouse-look path - // above takes precedence when active. - if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null) - { - float nowSec = (float)(Environment.TickCount64 / 1000.0); - var (filteredDx, filteredDy) = _retailChaseCamera.FilterMouseDelta( - rawX: dx, rawY: dy, weight: 0.5f, nowSec: nowSec); - _retailChaseCamera.YawOffset -= filteredDx * 0.004f * sens; - _retailChaseCamera.AdjustPitch(filteredDy * 0.003f * sens); - } - else - { - _chaseCamera.YawOffset -= dx * 0.004f * sens; - _chaseCamera.AdjustPitch(dy * 0.003f * sens); - } - } - // K-fix1 (2026-04-26): no default-pitch path. With - // neither MMB nor RMB held, mouse moves the cursor - // freely so the user can interact with panels + - // (eventually) click selectables in the world. Pitch - // is gated on a deliberate hold input. - } - else if (_cameraController.IsFlyMode) - { - float sens = _sensFly; - _cameraController.Fly.Look(dx * sens, dy * sens); - } - else - { - // Orbit-camera mode (offline / pre-login): hold LMB to - // drag-rotate. K.1b reads the mouse-button state via - // IMouseSource so all "is button held" queries go - // through the same abstraction the dispatcher uses. - float sens = _sensOrbit; - if (_mouseSource is not null && _mouseSource.IsHeld(MouseButton.Left)) - { - _cameraController.Orbit.Yaw -= dx * 0.005f * sens; - _cameraController.Orbit.Pitch = Math.Clamp( - _cameraController.Orbit.Pitch + dy * 0.005f * sens, - 0.1f, 1.5f); - } - } - _lastMouseX = pos.X; - _lastMouseY = pos.Y; - }; - } - _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); _gl.Enable(EnableCap.DepthTest); @@ -849,10 +749,26 @@ public sealed class GameWindow : IDisposable Console.WriteLine("world-hud font: no system monospace font found"); } - var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y }; - var fly = new FlyCamera { Aspect = _window.Size.X / (float)_window.Size.Y }; + var orbit = new OrbitCamera(); + var fly = new FlyCamera(); _cameraController = new CameraController(orbit, fly); - _cameraController.ModeChanged += OnCameraModeChanged; + _framebufferResize.BindCamera( + new CameraFramebufferTarget(_cameraController)); + _framebufferResize.Resize(_window!.FramebufferSize); + if (_mouseSource is not null && firstMouse is not null) + { + _cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create( + _input.Mice, + _hostQuiescence, + _inputCapture, + _localPlayerMode, + _cameraController, + _chaseCameraInput, + _mouseSource, + _pointerPosition, + new AcDream.App.Input.EnvironmentInputMonotonicClock()); + _cameraPointerInput.AttachRaw(); + } _dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir); _magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats); @@ -959,8 +875,15 @@ public sealed class GameWindow : IDisposable AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null; try { + _devToolsInputContext = new AcDream.App.Input.QuiescentInputContext( + _input!, + _hostQuiescence); imguiBootstrap = - new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!); + new AcDream.UI.ImGui.ImGuiBootstrapper( + _gl!, + _window!, + _devToolsInputContext); + _devToolsInputContext.Activate(); var panelHost = new AcDream.UI.ImGui.ImGuiPanelHost(); // VitalsVM: GUID=0 at construction; set later at EnterWorld @@ -1021,9 +944,11 @@ public sealed class GameWindow : IDisposable _worldSceneDebugState.CollisionWireframesVisible, getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier - getMouseSensitivity: () => GetActiveSensitivity(), + getMouseSensitivity: () => + _cameraPointerInput?.ActiveSensitivity ?? 1f, getChaseDistance: () => _chaseCamera?.Distance ?? 0f, - getRmbOrbit: () => _rmbHeld, + getRmbOrbit: () => + _cameraPointerInput?.RmbOrbitHeld ?? false, getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(), getDayFraction: () => (float)WorldTime.DayFraction, getWeather: () => Weather.Kind.ToString(), @@ -1229,6 +1154,9 @@ public sealed class GameWindow : IDisposable debugPanel, _debugVm, settingsPanel)); + _framebufferDevToolsTarget = new DevToolsFramebufferTarget( + _devToolsFramePresenter); + _framebufferResize.BindDevTools(_framebufferDevToolsTarget); _devToolsFramePresenter.ResetLayout( _window!.Size.X, _window.Size.Y, @@ -1241,6 +1169,13 @@ public sealed class GameWindow : IDisposable // operation. Construction failure still owns this local and // must release it before abandoning the optional frontend. imguiBootstrap?.Dispose(); + if (_framebufferDevToolsTarget is { } framebufferTarget) + { + _framebufferResize.UnbindDevTools(framebufferTarget); + _framebufferDevToolsTarget = null; + } + _devToolsInputContext?.Dispose(); + _devToolsInputContext = null; _devToolsBackend = null; _devToolsFramePresenter = null; _devToolsCameraMenu = null; @@ -1429,7 +1364,11 @@ public sealed class GameWindow : IDisposable if (_options.RetailUi) { _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); - _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); + _uiHost = new AcDream.App.UI.UiHost( + _gl, + shadersDir, + _debugFont, + _hostQuiescence); _retainedInputCapture.Root = _uiHost.Root; _uiHost.Root.UiLocked = _persistedGameplay.LockUI; var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( @@ -2031,7 +1970,9 @@ public sealed class GameWindow : IDisposable camera.Projection, camera.Viewport); }, - () => new System.Numerics.Vector2(_lastMouseX, _lastMouseY), + () => new System.Numerics.Vector2( + _pointerPosition.X, + _pointerPosition.Y), () => _playerController is { } player ? new AcDream.App.Interaction.PlayerInteractionPose( player.CellId, @@ -2472,6 +2413,7 @@ public sealed class GameWindow : IDisposable mouseLookController, new AcDream.App.Input.CombatAttackInputFrameAdapter( _combatAttackController!)); + _cameraPointerInput?.BindGameplayFrame(_gameplayInputFrame); var streamingFrame = new AcDream.App.Streaming.StreamingFrameController( _options.LiveMode, _localPlayerMode, @@ -2559,7 +2501,6 @@ public sealed class GameWindow : IDisposable _animatedEntities, _equippedChildRenderer!, liveEffectFrame); - _viewportAspect.Update(_window!.Size.X, _window.Size.Y); _playerModeController = new AcDream.App.Input.PlayerModeController( _localPlayerMode, _playerControllerSlot, @@ -3169,31 +3110,6 @@ public sealed class GameWindow : IDisposable new AcDream.App.Update.UpdateFrameInput(dt)); } - private void OnCameraModeChanged(bool _modeBool) - { - if (_gameplayInputFrame?.MouseLookActive == true - && _cameraController?.IsChaseMode != true) - { - _gameplayInputFrame?.EndMouseLook(); - } - - if (_input is null) return; - var mouse = _input.Mice.FirstOrDefault(); - if (mouse is null) return; - - // K-fix2 (2026-04-26): the bool passed to ModeChanged is NOT - // reliably "isFlyMode" — CameraController.EnterChaseMode invokes - // it with IsChaseMode (true), CameraController.ToggleFly invokes - // it with IsFlyMode, and CameraController.ExitChaseMode invokes - // it with IsFlyMode. Reading the controller state directly is - // the only correct gate. Cursor visible by default in chase / - // orbit modes; Raw cursor only in fly mode (continuous - // look-and-fly affordance). Mouse-look (raw mode) when MMB is - // held is handled separately by the gameplay input owner. - bool needsRawCursor = _cameraController?.IsFlyMode == true; - mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal; - } - // Performance overlay state — updated every ~0.5s and written to the // window title so there's zero rendering cost (no font/overlay needed). private void OnRender(double deltaSeconds) @@ -3325,13 +3241,6 @@ public sealed class GameWindow : IDisposable private bool GetDebugPlayerOnGround() => _playerMode && _playerController is not null && !_playerController.IsAirborne; - private float GetActiveSensitivity() - { - if (_playerMode && _cameraController?.IsChaseMode == true) return _sensChase; - if (_cameraController?.IsFlyMode == true) return _sensFly; - return _sensOrbit; - } - /// /// Cycle the time-of-day debug override. Same body as the old F7 /// keybind handler; called by both the keybind AND the DebugPanel @@ -3611,21 +3520,7 @@ public sealed class GameWindow : IDisposable /// previously off the new viewport snap back to default positions. /// private void OnFramebufferResize(Silk.NET.Maths.Vector2D newSize) - { - if (newSize.X <= 0 || newSize.Y <= 0) return; - _gl?.Viewport(0, 0, (uint)newSize.X, (uint)newSize.Y); - _viewportAspect.Update(newSize.X, newSize.Y); - _cameraController?.SetAspect(newSize.X / (float)newSize.Y); - // Resize is always a force-reset — the alternative ("clamp - // existing positions") would require tracking each panel's - // current pos+size, which ImGuiNET doesn't expose by name. - // Force-reset is acceptable UX because resizing happens rarely - // and the user can always drag panels back where they want. - _devToolsFramePresenter?.ResetLayout( - newSize.X, - newSize.Y, - AcDream.App.Rendering.DevToolsPanelLayoutCondition.Always); - } + => _framebufferResize.Resize(newSize); /// /// L.0 Display tab: apply the window-state-dependent settings @@ -3701,11 +3596,8 @@ public sealed class GameWindow : IDisposable // Diagnostic — kept from K.1a; helpful for K.1c verification. Console.WriteLine($"[input] {action} {activation}"); - // RMB-orbit hold: track press/release transitions explicitly so - // _rmbHeld is true exactly while the chord is held. Hold-type - // chords also fire Press on key-down + Release on key-up; we - // ignore the in-between Hold ticks here (the mouse-move handler - // checks _rmbHeld each frame anyway). + // RMB orbit and instant mouse-look remain the first accepted input + // transition. Their state is owned by the gameplay/pointer owners. if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true) return; @@ -3958,20 +3850,10 @@ public sealed class GameWindow : IDisposable /// private void AdjustActiveSensitivity(float factor) { - string modeLabel; - float current; - if (_playerMode && _cameraController?.IsChaseMode == true) - { modeLabel = "Chase"; current = _sensChase; } - else if (_cameraController?.IsFlyMode == true) - { modeLabel = "Fly"; current = _sensFly; } - else - { modeLabel = "Orbit"; current = _sensOrbit; } - - float next = MathF.Min(3.0f, MathF.Max(0.005f, current * factor)); - if (modeLabel == "Chase") _sensChase = next; - else if (modeLabel == "Fly") _sensFly = next; - else _sensOrbit = next; - _debugVm?.AddToast($"{modeLabel} sens {next:F3}x"); + if (_cameraPointerInput is not { } pointer) + return; + string message = pointer.AdjustSensitivity(factor); + _debugVm?.AddToast(message); } /// @@ -4040,28 +3922,7 @@ public sealed class GameWindow : IDisposable /// retail wheel feel). /// private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action) - { - if (_cameraController is null) return; - float dir = (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp) ? 1f : -1f; - - if (_playerMode && _cameraController.IsChaseMode) - { - // Chase mode: zoom (closer on ScrollUp). - if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null) - _retailChaseCamera.AdjustDistance(-dir * 0.8f); - else if (_chaseCamera is not null) - _chaseCamera.AdjustDistance(-dir * 0.8f); - } - else if (_cameraController.IsFlyMode) - { - // Fly mode: no-op (could adjust move speed later). - } - else - { - _cameraController.Orbit.Distance = Math.Clamp( - _cameraController.Orbit.Distance - dir * 20f, 50f, 2000f); - } - } + => _cameraPointerInput?.HandleScroll(action); private void OnClosing() { @@ -4088,6 +3949,20 @@ public sealed class GameWindow : IDisposable } private ResourceShutdownTransaction CreateShutdownTransaction() => new( + // Logical cutoff precedes the live session's potentially long graceful + // close. Physical event removal follows session retirement, so a bad + // Silk accessor cannot prevent F653/transport teardown. The input + // context and UI owners remain alive for reset, while copied callbacks + // are already inert. + new ResourceShutdownStage("input callback deactivation", + [ + new("camera pointer", () => _cameraPointerInput?.Deactivate()), + new("dispatcher", () => _inputDispatcher?.Deactivate()), + new("mouse source", () => _mouseSource?.Deactivate()), + new("keyboard source", () => _kbSource?.Deactivate()), + new("retained UI input", () => _uiHost?.QuiesceInput()), + new("developer tools input", () => _devToolsInputContext?.Deactivate()), + ]), // Live-session reset retires the complete streaming window. Every // reset callback owner, especially LandblockStreamer, must remain live // until that transaction converges. @@ -4114,6 +3989,57 @@ public sealed class GameWindow : IDisposable } }), ]), + new ResourceShutdownStage("input callback detach", + [ + new("retained UI input", () => _uiHost?.DeactivateInput()), + new("developer tools input", () => _devToolsInputContext?.Dispose()), + new("camera pointer", () => + { + AcDream.App.Input.CameraPointerInputController? pointer = + _cameraPointerInput; + if (pointer is null) return; + pointer.Dispose(); + if (!pointer.IsDisposalComplete) + throw new InvalidOperationException( + "Camera pointer callback removal remains pending."); + }), + new("combat input subscription", () => + Combat.CombatModeChanged -= SetInputCombatScope), + new("dispatcher", () => + { + AcDream.UI.Abstractions.Input.InputDispatcher? dispatcher = + _inputDispatcher; + if (dispatcher is null) return; + dispatcher.Fired -= OnInputAction; + _movementInput.Unbind(dispatcher); + _cameraInput.Unbind(dispatcher); + dispatcher.Dispose(); + if (!dispatcher.IsDisposalComplete) + throw new InvalidOperationException( + "Input dispatcher source removal remains pending."); + _inputDispatcher = null; + }), + new("mouse source", () => + { + AcDream.App.Input.SilkMouseSource? source = _mouseSource; + if (source is null) return; + source.Dispose(); + if (!source.IsDisposalComplete) + throw new InvalidOperationException( + "Mouse source callback removal remains pending."); + _mouseSource = null; + }), + new("keyboard source", () => + { + AcDream.App.Input.SilkKeyboardSource? source = _kbSource; + if (source is null) return; + source.Dispose(); + if (!source.IsDisposalComplete) + throw new InvalidOperationException( + "Keyboard source callback removal remains pending."); + _kbSource = null; + }), + ]), // Frame composition borrows equipped, effect, audio, and render // owners. Withdraw the graph before the first borrowed owner closes; // the later render-frontend stage still disposes the GL owners. @@ -4126,7 +4052,16 @@ public sealed class GameWindow : IDisposable ]), new ResourceShutdownStage("session dependents", [ - new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()), + new("mouse capture", () => + { + AcDream.App.Input.CameraPointerInputController? pointer = + _cameraPointerInput; + if (pointer is null) return; + pointer.ReleaseMouseLookAfterSessionRetirement(); + if (_gameplayInputFrame is { } gameplayFrame) + pointer.UnbindGameplayFrame(gameplayFrame); + _cameraPointerInput = null; + }), new("retail UI", () => { _retailUiRuntime?.Dispose(); @@ -4196,11 +4131,17 @@ public sealed class GameWindow : IDisposable [ new("developer tools", () => { + if (_framebufferDevToolsTarget is { } framebufferTarget) + { + _framebufferResize.UnbindDevTools(framebufferTarget); + _framebufferDevToolsTarget = null; + } _devToolsFramePresenter = null; _devToolsCameraMenu = null; _devToolsCommandBus = null; _devToolsBackend?.Dispose(); _devToolsBackend = null; + _devToolsInputContext = null; }), new("portal tunnel", () => { @@ -4281,8 +4222,6 @@ public sealed class GameWindow : IDisposable if (ReferenceEquals(_windowCallbacks, binding)) _windowCallbacks = null; }), - new("combat input subscription", () - => Combat.CombatModeChanged -= SetInputCombatScope), new("input context", () => { _input?.Dispose(); @@ -4299,10 +4238,7 @@ public sealed class GameWindow : IDisposable ])); private void OnFocusChanged(bool focused) - { - if (!focused) - _gameplayInputFrame?.EndMouseLook(); - } + => _cameraPointerInput?.HandleFocusChanged(focused); public void Dispose() { diff --git a/src/AcDream.App/UI/RetainedUiInputBinding.cs b/src/AcDream.App/UI/RetainedUiInputBinding.cs new file mode 100644 index 00000000..0cce58e9 --- /dev/null +++ b/src/AcDream.App/UI/RetainedUiInputBinding.cs @@ -0,0 +1,420 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Silk.NET.Input; + +namespace AcDream.App.UI; + +internal interface IRetainedUiInputBinding : IDisposable +{ + bool IsDisposalComplete { get; } + void Attach(); + void Deactivate(); +} + +internal interface IRetainedMouseSurface +{ + void AddMouseDown(Action callback); + void RemoveMouseDown(Action callback); + void AddMouseUp(Action callback); + void RemoveMouseUp(Action callback); + void AddMouseMove(Action callback); + void RemoveMouseMove(Action callback); + void AddScroll(Action callback); + void RemoveScroll(Action callback); +} + +internal sealed class SilkRetainedMouseSurface : IRetainedMouseSurface +{ + private readonly IMouse _mouse; + private Action? _downCallback; + private Action? _upCallback; + private Action? _moveCallback; + private Action? _scrollCallback; + private readonly Action _down; + private readonly Action _up; + private readonly Action _move; + private readonly Action _scroll; + + public SilkRetainedMouseSurface(IMouse mouse) + { + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _down = OnDown; + _up = OnUp; + _move = OnMove; + _scroll = OnScroll; + } + + public void AddMouseDown(Action callback) + { + _downCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.MouseDown += _down; + } + + public void RemoveMouseDown(Action callback) + { + if (!ReferenceEquals(_downCallback, callback)) return; + _mouse.MouseDown -= _down; + _downCallback = null; + } + + public void AddMouseUp(Action callback) + { + _upCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.MouseUp += _up; + } + + public void RemoveMouseUp(Action callback) + { + if (!ReferenceEquals(_upCallback, callback)) return; + _mouse.MouseUp -= _up; + _upCallback = null; + } + + public void AddMouseMove(Action callback) + { + _moveCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.MouseMove += _move; + } + + public void RemoveMouseMove(Action callback) + { + if (!ReferenceEquals(_moveCallback, callback)) return; + _mouse.MouseMove -= _move; + _moveCallback = null; + } + + public void AddScroll(Action callback) + { + _scrollCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.Scroll += _scroll; + } + + public void RemoveScroll(Action callback) + { + if (!ReferenceEquals(_scrollCallback, callback)) return; + _mouse.Scroll -= _scroll; + _scrollCallback = null; + } + + private void OnDown(IMouse sender, MouseButton button) => + _downCallback?.Invoke(button, (int)sender.Position.X, (int)sender.Position.Y); + private void OnUp(IMouse sender, MouseButton button) => + _upCallback?.Invoke(button, (int)sender.Position.X, (int)sender.Position.Y); + private void OnMove(IMouse _, Vector2 position) => + _moveCallback?.Invoke((int)position.X, (int)position.Y); + private void OnScroll(IMouse _, ScrollWheel scroll) => + _scrollCallback?.Invoke((int)scroll.Y); +} + +internal sealed class RetainedMouseInputBinding : IRetainedUiInputBinding +{ + private readonly IRetainedMouseSurface _surface; + private readonly UiRoot _root; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _down; + private readonly Action _up; + private readonly Action _move; + private readonly Action _scroll; + private readonly bool[] _attached = new bool[4]; + private ResourceShutdownTransaction? _detach; + private bool _attachStarted; + private int _disposeRequested; + private int _active; + + public RetainedMouseInputBinding( + IRetainedMouseSurface surface, + UiRoot root, + HostQuiescenceGate quiescence) + { + _surface = surface ?? throw new ArgumentNullException(nameof(surface)); + _root = root ?? throw new ArgumentNullException(nameof(root)); + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); + _down = OnDown; + _up = OnUp; + _move = OnMove; + _scroll = OnScroll; + } + + public bool IsDisposalComplete => _attached.All(static value => !value); + + public void Attach() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + if (_attachStarted) + throw new InvalidOperationException("Retained mouse attachment has already started."); + _attachStarted = true; + try + { + _attached[0] = true; + _surface.AddMouseDown(_down); + _attached[1] = true; + _surface.AddMouseUp(_up); + _attached[2] = true; + _surface.AddMouseMove(_move); + _attached[3] = true; + _surface.AddScroll(_scroll); + Volatile.Write(ref _active, 1); + } + catch (Exception attachError) + { + Deactivate(); + RollBackOrThrow(attachError); + } + } + + public void Deactivate() => Interlocked.Exchange(ref _active, 0); + + public void Dispose() + { + Interlocked.Exchange(ref _disposeRequested, 1); + Deactivate(); + EnsureDetachTransaction().CompleteOrThrow(); + } + + private void OnDown(MouseButton button, int x, int y) => + Invoke(() => _root.OnMouseDown(MapButton(button), x, y)); + private void OnUp(MouseButton button, int x, int y) => + Invoke(() => _root.OnMouseUp(MapButton(button), x, y)); + private void OnMove(int x, int y) => Invoke(() => _root.OnMouseMove(x, y)); + private void OnScroll(int amount) => Invoke(() => _root.OnScroll(amount)); + + private void Invoke(Action callback) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + callback(); + }); + + private ResourceShutdownTransaction EnsureDetachTransaction() => + _detach ??= new ResourceShutdownTransaction( + new ResourceShutdownStage("retained mouse callbacks", + [ + new("scroll", () => Remove(3)), + new("move", () => Remove(2)), + new("up", () => Remove(1)), + new("down", () => Remove(0)), + ])); + + private void Remove(int index) + { + if (!_attached[index]) return; + switch (index) + { + case 0: _surface.RemoveMouseDown(_down); break; + case 1: _surface.RemoveMouseUp(_up); break; + case 2: _surface.RemoveMouseMove(_move); break; + case 3: _surface.RemoveScroll(_scroll); break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + _attached[index] = false; + } + + private void RollBackOrThrow(Exception attachError) + { + try + { + EnsureDetachTransaction().CompleteOrThrow(); + } + catch (Exception rollbackError) + { + throw new AggregateException( + "Retained mouse registration and rollback both failed.", + new InvalidOperationException("Retained mouse registration failed.", attachError), + rollbackError); + } + throw new InvalidOperationException( + "Retained mouse registration failed and was rolled back.", attachError); + } + + private static UiMouseButton MapButton(MouseButton button) => button switch + { + MouseButton.Left => UiMouseButton.Left, + MouseButton.Right => UiMouseButton.Right, + MouseButton.Middle => UiMouseButton.Middle, + _ => UiMouseButton.Left, + }; +} + +internal interface IRetainedKeyboardSurface +{ + void AddKeyDown(Action callback); + void RemoveKeyDown(Action callback); + void AddKeyUp(Action callback); + void RemoveKeyUp(Action callback); + void AddKeyChar(Action callback); + void RemoveKeyChar(Action callback); +} + +internal sealed class SilkRetainedKeyboardSurface : IRetainedKeyboardSurface +{ + private readonly IKeyboard _keyboard; + private Action? _downCallback; + private Action? _upCallback; + private Action? _charCallback; + private readonly Action _down; + private readonly Action _up; + private readonly Action _char; + + public SilkRetainedKeyboardSurface(IKeyboard keyboard) + { + _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); + _down = OnDown; + _up = OnUp; + _char = OnChar; + } + + public void AddKeyDown(Action callback) + { + _downCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _keyboard.KeyDown += _down; + } + + public void RemoveKeyDown(Action callback) + { + if (!ReferenceEquals(_downCallback, callback)) return; + _keyboard.KeyDown -= _down; + _downCallback = null; + } + + public void AddKeyUp(Action callback) + { + _upCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _keyboard.KeyUp += _up; + } + + public void RemoveKeyUp(Action callback) + { + if (!ReferenceEquals(_upCallback, callback)) return; + _keyboard.KeyUp -= _up; + _upCallback = null; + } + + public void AddKeyChar(Action callback) + { + _charCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _keyboard.KeyChar += _char; + } + + public void RemoveKeyChar(Action callback) + { + if (!ReferenceEquals(_charCallback, callback)) return; + _keyboard.KeyChar -= _char; + _charCallback = null; + } + + private void OnDown(IKeyboard _, Key key, int __) => _downCallback?.Invoke(key); + private void OnUp(IKeyboard _, Key key, int __) => _upCallback?.Invoke(key); + private void OnChar(IKeyboard _, char value) => _charCallback?.Invoke(value); +} + +internal sealed class RetainedKeyboardInputBinding : IRetainedUiInputBinding +{ + private readonly IRetainedKeyboardSurface _surface; + private readonly UiRoot _root; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _down; + private readonly Action _up; + private readonly Action _char; + private readonly bool[] _attached = new bool[3]; + private ResourceShutdownTransaction? _detach; + private bool _attachStarted; + private int _disposeRequested; + private int _active; + + public RetainedKeyboardInputBinding( + IRetainedKeyboardSurface surface, + UiRoot root, + HostQuiescenceGate quiescence) + { + _surface = surface ?? throw new ArgumentNullException(nameof(surface)); + _root = root ?? throw new ArgumentNullException(nameof(root)); + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); + _down = key => Invoke(() => _root.OnKeyDown((int)key)); + _up = key => Invoke(() => _root.OnKeyUp((int)key)); + _char = value => Invoke(() => _root.OnChar(value)); + } + + public bool IsDisposalComplete => _attached.All(static value => !value); + + public void Attach() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + if (_attachStarted) + throw new InvalidOperationException("Retained keyboard attachment has already started."); + _attachStarted = true; + try + { + _attached[0] = true; + _surface.AddKeyDown(_down); + _attached[1] = true; + _surface.AddKeyUp(_up); + _attached[2] = true; + _surface.AddKeyChar(_char); + Volatile.Write(ref _active, 1); + } + catch (Exception attachError) + { + Deactivate(); + RollBackOrThrow(attachError); + } + } + + public void Deactivate() => Interlocked.Exchange(ref _active, 0); + + public void Dispose() + { + Interlocked.Exchange(ref _disposeRequested, 1); + Deactivate(); + EnsureDetachTransaction().CompleteOrThrow(); + } + + private void Invoke(Action callback) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + callback(); + }); + + private ResourceShutdownTransaction EnsureDetachTransaction() => + _detach ??= new ResourceShutdownTransaction( + new ResourceShutdownStage("retained keyboard callbacks", + [ + new("character", () => Remove(2)), + new("up", () => Remove(1)), + new("down", () => Remove(0)), + ])); + + private void Remove(int index) + { + if (!_attached[index]) return; + switch (index) + { + case 0: _surface.RemoveKeyDown(_down); break; + case 1: _surface.RemoveKeyUp(_up); break; + case 2: _surface.RemoveKeyChar(_char); break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + _attached[index] = false; + } + + private void RollBackOrThrow(Exception attachError) + { + try + { + EnsureDetachTransaction().CompleteOrThrow(); + } + catch (Exception rollbackError) + { + throw new AggregateException( + "Retained keyboard registration and rollback both failed.", + new InvalidOperationException("Retained keyboard registration failed.", attachError), + rollbackError); + } + throw new InvalidOperationException( + "Retained keyboard registration failed and was rolled back.", attachError); + } +} diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index dcd233c4..ae48b966 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -49,15 +49,27 @@ public sealed class UiHost : System.IDisposable public IKeyboard? Keyboard { get; private set; } private long _startTicks = System.Environment.TickCount64; - private readonly List _inputUnsubscribers = new(); + private readonly HostQuiescenceGate _quiescence; + private readonly List _inputBindings = new(); + private ResourceShutdownTransaction? _inputShutdown; private ResourceShutdownTransaction? _shutdown; private bool _disposeRequested; private bool _disposed; public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null) + : this(gl, shaderDir, defaultFont, new HostQuiescenceGate()) + { + } + + internal UiHost( + GL gl, + string shaderDir, + BitmapFont? defaultFont, + HostQuiescenceGate quiescence) { TextRenderer = new TextRenderer(gl, shaderDir); DefaultFont = defaultFont; + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); } // ── Per-frame ────────────────────────────────────────────────────── @@ -86,25 +98,21 @@ public sealed class UiHost : System.IDisposable System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); System.ArgumentNullException.ThrowIfNull(mouse); - void OnMouseDown(IMouse sender, MouseButton button) => - Root.OnMouseDown(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y); - void OnMouseUp(IMouse sender, MouseButton button) => - Root.OnMouseUp(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y); - void OnMouseMove(IMouse sender, Vector2 position) => - Root.OnMouseMove((int)position.X, (int)position.Y); - void OnScroll(IMouse sender, ScrollWheel scroll) => Root.OnScroll((int)scroll.Y); - - mouse.MouseDown += OnMouseDown; - mouse.MouseUp += OnMouseUp; - mouse.MouseMove += OnMouseMove; - mouse.Scroll += OnScroll; - _inputUnsubscribers.Add(() => + var binding = new RetainedMouseInputBinding( + new SilkRetainedMouseSurface(mouse), + Root, + _quiescence); + _inputBindings.Add(binding); + try { - mouse.MouseDown -= OnMouseDown; - mouse.MouseUp -= OnMouseUp; - mouse.MouseMove -= OnMouseMove; - mouse.Scroll -= OnScroll; - }); + binding.Attach(); + } + catch + { + if (binding.IsDisposalComplete) + _inputBindings.Remove(binding); + throw; + } } public void WireKeyboard(IKeyboard kb) @@ -112,28 +120,54 @@ public sealed class UiHost : System.IDisposable System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); System.ArgumentNullException.ThrowIfNull(kb); Keyboard = kb; // last wired keyboard wins (one-keyboard desktop) - void OnKeyDown(IKeyboard sender, Key key, int scanCode) => Root.OnKeyDown((int)key); - void OnKeyUp(IKeyboard sender, Key key, int scanCode) => Root.OnKeyUp((int)key); - void OnKeyChar(IKeyboard sender, char value) => Root.OnChar(value); - - kb.KeyDown += OnKeyDown; - kb.KeyUp += OnKeyUp; - kb.KeyChar += OnKeyChar; - _inputUnsubscribers.Add(() => + var binding = new RetainedKeyboardInputBinding( + new SilkRetainedKeyboardSurface(kb), + Root, + _quiescence); + _inputBindings.Add(binding); + try { - kb.KeyDown -= OnKeyDown; - kb.KeyUp -= OnKeyUp; - kb.KeyChar -= OnKeyChar; - }); + binding.Attach(); + } + catch + { + if (binding.IsDisposalComplete) + _inputBindings.Remove(binding); + throw; + } } - private static UiMouseButton MapButton(MouseButton b) => b switch + /// + /// Stops retained-device delivery without retiring the window tree or GL + /// renderer. Safe to call before a potentially long live-session close. + /// + public void QuiesceInput() { - MouseButton.Left => UiMouseButton.Left, - MouseButton.Right => UiMouseButton.Right, - MouseButton.Middle => UiMouseButton.Middle, - _ => UiMouseButton.Left, - }; + _disposeRequested = true; + foreach (IRetainedUiInputBinding binding in _inputBindings) + binding.Deactivate(); + Keyboard = null; + } + + /// Physically removes every retained input edge after quiescence. + public void DeactivateInput() + { + QuiesceInput(); + + _inputShutdown ??= new ResourceShutdownTransaction( + new ResourceShutdownStage( + "retained UI input subscriptions", + _inputBindings + .AsEnumerable() + .Reverse() + .Select((binding, index) => new ResourceShutdownOperation( + $"input binding {index}", + binding.Dispose)) + .ToArray())); + _inputShutdown.CompleteOrThrow(); + if (_inputShutdown.IsComplete) + _inputBindings.Clear(); + } // ── Window manager forwarders (delegate to UiRoot) ───────────────── @@ -169,12 +203,8 @@ public sealed class UiHost : System.IDisposable _disposeRequested = true; _shutdown ??= CreateShutdownTransaction( - _inputUnsubscribers.AsEnumerable().Reverse().ToArray(), - () => - { - _inputUnsubscribers.Clear(); - Keyboard = null; - }, + [DeactivateInput], + () => { }, WindowManager.Dispose, TextRenderer.Dispose); _shutdown.CompleteOrThrow(); diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index 2c5bd267..fd729585 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -30,7 +30,7 @@ namespace AcDream.UI.Abstractions.Input; /// action stream. /// /// -public sealed class InputDispatcher +public sealed class InputDispatcher : IDisposable { private readonly IKeyboardSource _keyboard; private readonly IMouseSource _mouse; @@ -39,6 +39,10 @@ public sealed class InputDispatcher private InputScope? _combatScope; private readonly HashSet _heldHoldChords = new(); private readonly HashSet _automationHeldActions = new(); + private readonly bool[] _sourceAttached = new bool[5]; + private bool _attachStarted; + private int _disposeRequested; + private int _active; // Double-click detection. _lastMouseDownButton == null means no recent press. // _lastMouseDownTickMs is Environment.TickCount64 at the time of that press. @@ -57,7 +61,10 @@ public sealed class InputDispatcher /// Multicast — every subscriber gets every event in subscription order. public event Action? Fired; - public InputDispatcher(IKeyboardSource keyboard, IMouseSource mouse, KeyBindings bindings) + private InputDispatcher( + IKeyboardSource keyboard, + IMouseSource mouse, + KeyBindings bindings) { _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); @@ -66,11 +73,84 @@ public sealed class InputDispatcher _scopes.Push(InputScope.Always); // bottom of the stack _scopes.Push(InputScope.Game); // default top for normal play - _keyboard.KeyDown += OnKeyDown; - _keyboard.KeyUp += OnKeyUp; - _mouse.MouseDown += OnMouseDown; - _mouse.MouseUp += OnMouseUp; - _mouse.Scroll += OnScroll; + } + + /// + /// Production two-phase construction seam. The host publishes the owner + /// before so failed custom event accessors cannot + /// orphan a source subscription. + /// + public static InputDispatcher CreateDetached( + IKeyboardSource keyboard, + IMouseSource mouse, + KeyBindings bindings) => + new(keyboard, mouse, bindings); + + public bool IsDisposalComplete => + _sourceAttached.All(static attached => !attached); + + public void Attach() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + if (_attachStarted) + throw new InvalidOperationException( + "Input dispatcher attachment has already started."); + _attachStarted = true; + + try + { + _sourceAttached[0] = true; + _keyboard.KeyDown += OnKeyDown; + _sourceAttached[1] = true; + _keyboard.KeyUp += OnKeyUp; + _sourceAttached[2] = true; + _mouse.MouseDown += OnMouseDown; + _sourceAttached[3] = true; + _mouse.MouseUp += OnMouseUp; + _sourceAttached[4] = true; + _mouse.Scroll += OnScroll; + Volatile.Write(ref _active, 1); + } + catch (Exception attachError) + { + Deactivate(); + List rollbackErrors = DetachSources(); + if (rollbackErrors.Count != 0) + { + rollbackErrors.Insert(0, new InvalidOperationException( + "Input dispatcher source registration failed.", + attachError)); + throw new AggregateException( + "Input dispatcher registration and rollback both failed.", + rollbackErrors); + } + + throw new InvalidOperationException( + "Input dispatcher registration failed and was rolled back.", + attachError); + } + } + + /// Immediate logical cutoff; physical detach remains retriable. + public void Deactivate() + { + Interlocked.Exchange(ref _active, 0); + _captureCallback = null; + _heldHoldChords.Clear(); + _automationHeldActions.Clear(); + } + + public void Dispose() + { + Interlocked.Exchange(ref _disposeRequested, 1); + Deactivate(); + List failures = DetachSources(); + if (failures.Count != 0) + throw new AggregateException( + "One or more input dispatcher source callbacks could not be detached.", + failures); } /// @@ -85,7 +165,9 @@ public sealed class InputDispatcher { if (action == InputAction.None || !Enum.IsDefined(action)) throw new ArgumentOutOfRangeException(nameof(action)); - if (_captureCallback is not null || _mouse.WantCaptureKeyboard) + if (Volatile.Read(ref _active) == 0 + || _captureCallback is not null + || _mouse.WantCaptureKeyboard) return false; Fired?.Invoke(action, ActivationType.Press); @@ -105,6 +187,9 @@ public sealed class InputDispatcher if (action == InputAction.None || !Enum.IsDefined(action)) throw new ArgumentOutOfRangeException(nameof(action)); + if (Volatile.Read(ref _active) == 0) + return false; + if (held) { if (_captureCallback is not null || _mouse.WantCaptureKeyboard) @@ -217,7 +302,7 @@ public sealed class InputDispatcher /// public bool IsActionHeld(InputAction action) { - if (action == InputAction.None) return false; + if (Volatile.Read(ref _active) == 0 || action == InputAction.None) return false; // While a text field owns the keyboard ("write mode"), held game actions read as // released: typing "swd" must not move the character. This is the polling-path twin // of the WantCaptureKeyboard gate on Fired actions. This suppresses physical keys; @@ -341,7 +426,7 @@ public sealed class InputDispatcher /// public void Tick() { - if (_mouse.WantCaptureKeyboard) return; + if (Volatile.Read(ref _active) == 0 || _mouse.WantCaptureKeyboard) return; // Snapshot to avoid issues if a subscriber mutates _heldHoldChords. if (_heldHoldChords.Count == 0) return; @@ -363,6 +448,7 @@ public sealed class InputDispatcher private void OnKeyDown(Key key, ModifierMask mods) { + if (Volatile.Read(ref _active) == 0) return; // K.3 modal capture (used by Settings panel's "Rebind" UX) takes // precedence over both WantCaptureKeyboard gating AND normal // binding lookup. Esc cancels capture; modifier-only keys don't @@ -417,6 +503,7 @@ public sealed class InputDispatcher private void OnKeyUp(Key key, ModifierMask mods) { + if (Volatile.Read(ref _active) == 0) return; // Release fires regardless of WantCaptureKeyboard so we don't // strand a Hold subscriber in the "held" state if the UI captured // mid-press. @@ -444,6 +531,7 @@ public sealed class InputDispatcher private void OnMouseDown(MouseButton button, ModifierMask mods) { + if (Volatile.Read(ref _active) == 0) return; if (_mouse.WantCaptureMouse) return; var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); @@ -478,6 +566,7 @@ public sealed class InputDispatcher private void OnMouseUp(MouseButton button, ModifierMask mods) { + if (Volatile.Read(ref _active) == 0) return; var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); var release = FindActive(chord, ActivationType.Release); @@ -500,6 +589,7 @@ public sealed class InputDispatcher private void OnScroll(float delta) { + if (Volatile.Read(ref _active) == 0) return; if (_mouse.WantCaptureMouse) return; // K.1b: wheel ticks emit ScrollUp / ScrollDown depending on the // sign of the delta. Magnitude is dropped — the action is a @@ -526,4 +616,34 @@ public sealed class InputDispatcher MouseButton.Button5 => (Key)(-1005), _ => (Key)(-1000 - (int)button), }; + + private List DetachSources() + { + var failures = new List(); + for (int index = _sourceAttached.Length - 1; index >= 0; index--) + { + if (!_sourceAttached[index]) + continue; + try + { + switch (index) + { + case 0: _keyboard.KeyDown -= OnKeyDown; break; + case 1: _keyboard.KeyUp -= OnKeyUp; break; + case 2: _mouse.MouseDown -= OnMouseDown; break; + case 3: _mouse.MouseUp -= OnMouseUp; break; + case 4: _mouse.Scroll -= OnScroll; break; + default: throw new ArgumentOutOfRangeException(nameof(index)); + } + _sourceAttached[index] = false; + } + catch (Exception error) + { + failures.Add(new InvalidOperationException( + $"Input dispatcher source callback {index} could not be detached.", + error)); + } + } + return failures; + } } diff --git a/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs new file mode 100644 index 00000000..6517273b --- /dev/null +++ b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs @@ -0,0 +1,287 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class CameraPointerInputControllerTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void AttachFailureRollsBackRawPrefixAndLeavesCameraUnsubscribed(int failureIndex) + { + var surfaces = new[] { new RawSurface(), new RawSurface(), new RawSurface() }; + surfaces[failureIndex].FailAdd = true; + CameraPointerInputController owner = Create(surfaces).Owner; + + Assert.ThrowsAny(owner.AttachRaw); + + Assert.True(owner.IsDisposalComplete); + Assert.All(surfaces, surface => Assert.Equal(0, surface.LiveEdges)); + Assert.Equal(failureIndex + 1, surfaces.Sum(surface => surface.AddCalls)); + Assert.Equal(failureIndex + 1, surfaces.Sum(surface => surface.RemoveCalls)); + } + + [Fact] + public void FlyPointerUsesAbsoluteDeltaAndCaptureAdvancesPointerWithoutLooking() + { + var surface = new RawSurface(); + var fixture = Create([surface]); + fixture.Camera.ToggleFly(); + fixture.Owner.AttachRaw(); + float originalYaw = fixture.Camera.Fly.Yaw; + float originalPitch = fixture.Camera.Fly.Pitch; + + surface.Raise(new Vector2(10f, 5f)); + Assert.Equal(originalYaw - 10f * 0.003f, fixture.Camera.Fly.Yaw, 5); + Assert.Equal(originalPitch - 5f * 0.003f, fixture.Camera.Fly.Pitch, 5); + + fixture.Capture.Mouse = true; + surface.Raise(new Vector2(100f, 100f)); + float capturedYaw = fixture.Camera.Fly.Yaw; + float capturedPitch = fixture.Camera.Fly.Pitch; + fixture.Capture.Mouse = false; + surface.Raise(new Vector2(102f, 97f)); + + Assert.Equal(capturedYaw - 2f * 0.003f, fixture.Camera.Fly.Yaw, 5); + Assert.Equal(capturedPitch + 3f * 0.003f, fixture.Camera.Fly.Pitch, 5); + } + + [Fact] + public void RawMoveRaisedReentrantlyDuringAttachCannotEnterPartialOwner() + { + var surface = new RawSurface { RaiseDuringAdd = true }; + var fixture = Create([surface]); + fixture.Camera.ToggleFly(); + float yaw = fixture.Camera.Fly.Yaw; + + fixture.Owner.AttachRaw(); + Assert.Equal(yaw, fixture.Camera.Fly.Yaw); + surface.Raise(new Vector2(3, 0)); + + Assert.NotEqual(yaw, fixture.Camera.Fly.Yaw); + } + + [Fact] + public void LateGameplayBindDoesNotResubscribeAndFocusLossEndsMouseLook() + { + var surface = new RawSurface(); + var fixture = Create([surface]); + fixture.Owner.AttachRaw(); + int adds = surface.AddCalls; + var mouseLook = new MouseLook(active: true); + var frame = new GameplayInputFrameController( + dispatcher: null, + new DispatcherMovementInputSource(), + mouseLook, + new Combat()); + + fixture.Owner.BindGameplayFrame(frame); + fixture.Owner.HandleFocusChanged(focused: false); + + Assert.Equal(adds, surface.AddCalls); + Assert.Equal(1, mouseLook.LifecycleEndCalls); + } + + [Fact] + public void DeactivateSilencesCopiedRawAndCameraDelegatesBeforeDetach() + { + var surface = new RawSurface(); + var fixture = Create([surface]); + fixture.Camera.ToggleFly(); + fixture.Owner.AttachRaw(); + Action copied = surface.Callback!; + float yaw = fixture.Camera.Fly.Yaw; + + fixture.Owner.Deactivate(); + copied(new Vector2(100, 0)); + fixture.Camera.ToggleFly(); + fixture.Owner.Dispose(); + + Assert.Equal(yaw, fixture.Camera.Fly.Yaw); + Assert.True(fixture.Owner.IsDisposalComplete); + } + + [Fact] + public void ProcessCloseCutoffDoesNotEndMouseLookUntilSessionRetirement() + { + var fixture = Create([new RawSurface()]); + var mouseLook = new MouseLook(active: true); + var frame = new GameplayInputFrameController( + dispatcher: null, + new DispatcherMovementInputSource(), + mouseLook, + new Combat()); + fixture.Owner.BindGameplayFrame(frame); + fixture.Owner.AttachRaw(); + + fixture.Owner.Deactivate(); + fixture.Owner.Dispose(); + Assert.Equal(0, mouseLook.LifecycleEndCalls); + + fixture.Owner.ReleaseMouseLookAfterSessionRetirement(); + Assert.Equal(1, mouseLook.LifecycleEndCalls); + } + + [Fact] + public void FailedRawDetachRetriesOnlyPendingEdge() + { + var surface = new RawSurface(); + var fixture = Create([surface]); + fixture.Owner.AttachRaw(); + surface.FailRemoveOnce = true; + + fixture.Owner.Dispose(); + int removes = surface.RemoveCalls; + fixture.Owner.Dispose(); + + Assert.Equal(2, surface.RemoveCalls); + Assert.Equal(removes, surface.RemoveCalls); + Assert.True(fixture.Owner.IsDisposalComplete); + } + + [Fact] + public void DisposeBeforeAttachMakesPointerOwnerTerminal() + { + var surface = new RawSurface(); + var fixture = Create([surface]); + + fixture.Owner.Dispose(); + + Assert.Throws(fixture.Owner.AttachRaw); + fixture.Owner.Dispose(); + Assert.Equal(0, surface.LiveEdges); + } + + [Fact] + public void ScrollAndSensitivityPreserveModeSpecificPolicy() + { + var fixture = Create([new RawSurface()]); + float orbitDistance = fixture.Camera.Orbit.Distance; + + fixture.Owner.HandleScroll(InputAction.ScrollUp); + string orbit = fixture.Owner.AdjustSensitivity(1.2f); + fixture.Camera.ToggleFly(); + float flyBefore = fixture.Owner.ActiveSensitivity; + string fly = fixture.Owner.AdjustSensitivity(1.2f); + + Assert.Equal(Math.Clamp(orbitDistance - 20f, 50f, 2000f), fixture.Camera.Orbit.Distance); + Assert.Equal("Orbit sens 1.200x", orbit); + Assert.Equal("Fly sens 1.200x", fly); + Assert.Equal(flyBefore * 1.2f, fixture.Owner.ActiveSensitivity, 5); + } + + private static Fixture Create(IReadOnlyList surfaces) + { + var camera = new CameraController(new OrbitCamera(), new FlyCamera()); + var capture = new Capture(); + var mouse = new Mouse(); + var cursor = new Cursor(); + var owner = new CameraPointerInputController( + surfaces, + cursor, + new HostQuiescenceGate(), + capture, + new LocalPlayerModeState(), + camera, + new ChaseCameraInputState(), + mouse, + new PointerPositionState(), + new Clock()); + return new Fixture(owner, camera, capture, cursor); + } + + private sealed record Fixture( + CameraPointerInputController Owner, + CameraController Camera, + Capture Capture, + Cursor Cursor); + + private sealed class RawSurface : IRawPointerSurface + { + public bool FailAdd { get; set; } + public bool FailRemoveOnce { get; set; } + public bool RaiseDuringAdd { get; set; } + public int AddCalls { get; private set; } + public int RemoveCalls { get; private set; } + public int LiveEdges { get; private set; } + public Action? Callback { get; private set; } + public void AddMouseMove(Action callback) + { + AddCalls++; + Callback = callback; + LiveEdges++; + if (RaiseDuringAdd) callback(new Vector2(50, 25)); + if (FailAdd) throw new InvalidOperationException("add"); + } + public void RemoveMouseMove(Action callback) + { + RemoveCalls++; + if (FailRemoveOnce) + { + FailRemoveOnce = false; + throw new InvalidOperationException("remove"); + } + if (ReferenceEquals(Callback, callback)) + { + Callback = null; + LiveEdges--; + } + } + public void Raise(Vector2 position) => Callback?.Invoke(position); + } + + private sealed class Cursor : IPointerCursorModeTarget + { + public CursorMode CursorMode { get; set; } + } + + private sealed class Capture : IInputCaptureSource + { + public bool Mouse { get; set; } + public bool WantCaptureMouse => Mouse; + public bool WantCaptureKeyboard => false; + public bool DevToolsWantCaptureKeyboard => false; + } + + private sealed class Mouse : IMouseSource + { +#pragma warning disable CS0067 + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool IsHeld(MouseButton button) => false; + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => false; + } + + private sealed class Clock : IInputMonotonicClock + { + public float NowSeconds => 1f; + } + + private sealed class MouseLook(bool active) : IMouseLookInputFrameController + { + public bool Active { get; } = active; + public int LifecycleEndCalls { get; private set; } + public bool HandlePointerAction(InputAction action, ActivationType activation) => false; + public void QueueRawDelta(float dx, float dy) { } + public void Tick() { } + public void EndAndRestoreCursor() { } + public void EndForLifecycle() => LifecycleEndCalls++; + public void ResetSession() { } + } + + private sealed class Combat : ICombatInputFrameController + { + public void Tick() { } + public void HandleMovementInput(InputAction action, ActivationType activation) { } + public bool HandleInputAction(InputAction action, ActivationType activation) => false; + } +} diff --git a/tests/AcDream.App.Tests/Input/DispatcherCameraInputSourceTests.cs b/tests/AcDream.App.Tests/Input/DispatcherCameraInputSourceTests.cs new file mode 100644 index 00000000..4884e3e9 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/DispatcherCameraInputSourceTests.cs @@ -0,0 +1,53 @@ +using AcDream.App.Input; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class DispatcherCameraInputSourceTests +{ + [Fact] + public void ExactUnbindRestoresNeutralCameraSampling() + { + var keyboard = new Keyboard(); + var mouse = new Mouse(); + var dispatcher = InputDispatcher.CreateDetached(keyboard, mouse, new KeyBindings()); + dispatcher.Attach(); + var other = InputDispatcher.CreateDetached(new Keyboard(), new Mouse(), new KeyBindings()); + other.Attach(); + var source = new DispatcherCameraInputSource(); + source.Bind(dispatcher); + dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, held: true); + + source.Unbind(other); + Assert.True(source.CaptureFly().Forward); + source.Unbind(dispatcher); + + Assert.False(source.IsAvailable); + Assert.Equal(default, source.CaptureFly()); + Assert.Equal(default, source.CaptureChaseAdjustment()); + } + + private sealed class Keyboard : IKeyboardSource + { +#pragma warning disable CS0067 + public event Action? KeyDown; + public event Action? KeyUp; +#pragma warning restore CS0067 + public bool IsHeld(Key key) => false; + public ModifierMask CurrentModifiers => ModifierMask.None; + } + + private sealed class Mouse : IMouseSource + { +#pragma warning disable CS0067 + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool IsHeld(MouseButton button) => false; + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => false; + } +} diff --git a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs index ceee9f13..0cdcf87a 100644 --- a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs +++ b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs @@ -112,12 +112,34 @@ public sealed class DispatcherMovementInputSourceTests Assert.Throws(() => source.Bind(second)); } + [Fact] + public void UnbindRequiresExactDispatcherAndRestoresNeutralCapture() + { + var source = new DispatcherMovementInputSource(); + var (first, _, _) = CreateDispatcher(); + var (other, _, _) = CreateDispatcher(); + source.Bind(first); + first.TrySetAutomationActionHeld(InputAction.MovementForward, held: true); + + source.Unbind(other); + Assert.True(source.Capture().Forward); + source.Unbind(first); + + Assert.False(source.IsAvailable); + Assert.Equal(default, source.Capture()); + } + private static (InputDispatcher Dispatcher, FakeKeyboard Keyboard, FakeMouse Mouse) CreateDispatcher() { var keyboard = new FakeKeyboard(); var mouse = new FakeMouse(); - return (new InputDispatcher(keyboard, mouse, new KeyBindings()), keyboard, mouse); + var dispatcher = InputDispatcher.CreateDetached( + keyboard, + mouse, + new KeyBindings()); + dispatcher.Attach(); + return (dispatcher, keyboard, mouse); } private sealed class FakeKeyboard : IKeyboardSource diff --git a/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs index ee4e984c..5c591a18 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs @@ -18,7 +18,8 @@ public sealed class GameplayInputFrameControllerTests new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward, ActivationType.Hold)); - var dispatcher = new InputDispatcher(keyboard, mouseSource, bindings); + var dispatcher = InputDispatcher.CreateDetached(keyboard, mouseSource, bindings); + dispatcher.Attach(); keyboard.Press(Key.W); dispatcher.Fired += (_, activation) => { diff --git a/tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs b/tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs new file mode 100644 index 00000000..90086067 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs @@ -0,0 +1,344 @@ +using System.Reflection; +using AcDream.App.Input; +using AcDream.App.Rendering; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class QuiescentInputContextTests +{ + [Fact] + public void EveryFrontendAddFailureRetainsTheCompletePossiblyAttachedPrefix() + { + for (int failAdd = 1; failAdd <= 10; failAdd++) + { + var fixture = new Fixture { FailAdd = failAdd }; + var context = fixture.CreateContext(); + + Assert.Throws(() => SubscribeAll(context, () => { })); + context.Dispose(); + + Assert.Equal(failAdd, fixture.AddCalls); + Assert.Equal(failAdd, fixture.RemoveCalls); + Assert.Equal(0, fixture.LiveEdges); + Assert.True(context.IsDisposalComplete); + Assert.False(fixture.InnerDisposed); + } + } + + [Fact] + public void EveryFrontendRemoveFailureRetriesOnlyThePendingEdge() + { + for (int failRemoveEdge = 1; failRemoveEdge <= 10; failRemoveEdge++) + { + var fixture = new Fixture(); + var context = fixture.CreateContext(); + SubscribeAll(context, () => { }); + context.Activate(); + fixture.FailRemoveEdgeOnce = failRemoveEdge; + + context.Dispose(); + int[] completedCounts = fixture.RemoveCounts.ToArray(); + context.Dispose(); + + for (int edge = 1; edge <= 10; edge++) + { + Assert.Equal( + edge == failRemoveEdge ? 2 : 1, + completedCounts[edge]); + Assert.Equal(completedCounts[edge], fixture.RemoveCounts[edge]); + } + Assert.Equal(0, fixture.LiveEdges); + Assert.True(context.IsDisposalComplete); + Assert.False(fixture.InnerDisposed); + } + } + + [Fact] + public void DeactivateSilencesKeyboardMouseAndConnectionRelaysBeforePhysicalDetach() + { + var fixture = new Fixture(); + var context = fixture.CreateContext(); + int calls = 0; + SubscribeAll(context, () => calls++); + context.Activate(); + + fixture.RaiseAll(); + Assert.Equal(10, calls); + + context.Deactivate(); + fixture.RaiseAll(); + context.Dispose(); + + Assert.Equal(10, calls); + Assert.Equal(0, fixture.LiveEdges); + Assert.True(context.IsDisposalComplete); + } + + [Fact] + public void EventRaisedFromAddAccessorCannotEnterPartialFrontend() + { + var fixture = new Fixture { RaiseKeyboardDownDuringAdd = true }; + var context = fixture.CreateContext(); + int calls = 0; + + context.Keyboards[0].KeyDown += (_, _, _) => calls++; + Assert.Equal(0, calls); + context.Activate(); + fixture.Keyboard.RaiseDown(); + + Assert.Equal(1, calls); + } + + [Fact] + public void DisposeBeforeFrontendConstructionMakesContextAndDeviceViewsTerminal() + { + var fixture = new Fixture(); + var context = fixture.CreateContext(); + IKeyboard keyboard = context.Keyboards[0]; + IMouse mouse = context.Mice[0]; + context.Dispose(); + + Assert.Throws(context.Activate); + Assert.Throws( + () => keyboard.KeyDown += static (_, _, _) => { }); + Assert.Throws( + () => mouse.MouseDown += static (_, _) => { }); + Assert.Throws( + () => context.ConnectionChanged += static (_, _) => { }); + + context.Dispose(); + Assert.Equal(0, fixture.LiveEdges); + Assert.True(context.IsDisposalComplete); + } + + private static void SubscribeAll(QuiescentInputContext context, Action called) + { + IKeyboard keyboard = context.Keyboards[0]; + IMouse mouse = context.Mice[0]; + keyboard.KeyDown += (_, _, _) => called(); + keyboard.KeyUp += (_, _, _) => called(); + keyboard.KeyChar += (_, _) => called(); + mouse.MouseDown += (_, _) => called(); + mouse.MouseUp += (_, _) => called(); + mouse.Click += (_, _, _) => called(); + mouse.DoubleClick += (_, _, _) => called(); + mouse.MouseMove += (_, _) => called(); + mouse.Scroll += (_, _) => called(); + context.ConnectionChanged += (_, _) => called(); + } + + private sealed class Fixture + { + private readonly FaultPlan _faults = new(); + private Context? _inner; + private IMouse? _mouse; + private MouseProxy? _mouseProxy; + + public Keyboard Keyboard { get; private set; } = null!; + public int FailAdd { set => _faults.FailAdd = value; } + public int FailRemoveEdgeOnce { set => _faults.FailRemoveEdgeOnce = value; } + public bool RaiseKeyboardDownDuringAdd { get; set; } + public int AddCalls => _faults.AddCalls; + public int RemoveCalls => _faults.RemoveCalls; + public int[] RemoveCounts => _faults.RemoveCounts; + public int LiveEdges => _faults.LiveEdges; + public bool InnerDisposed => _inner?.Disposed == true; + + public QuiescentInputContext CreateContext() + { + Keyboard = new Keyboard(_faults) + { + RaiseDuringAdd = RaiseKeyboardDownDuringAdd, + }; + _mouse = DispatchProxy.Create(); + _mouseProxy = (MouseProxy)(object)_mouse; + _mouseProxy.Initialize(_faults, _mouse); + _inner = new Context(_faults, Keyboard, _mouse); + return new QuiescentInputContext(_inner, new HostQuiescenceGate()); + } + + public void RaiseAll() + { + Keyboard.RaiseAll(); + _mouseProxy!.RaiseAll(); + _inner!.RaiseConnection(Keyboard); + } + } + + public sealed class FaultPlan + { + public int FailAdd { get; set; } + public int FailRemoveEdgeOnce { get; set; } + public int AddCalls { get; private set; } + public int RemoveCalls { get; private set; } + public int LiveEdges { get; private set; } + public int[] RemoveCounts { get; } = new int[11]; + + public void Add(int edge, Action sideEffect) + { + AddCalls++; + sideEffect(); + LiveEdges++; + if (AddCalls == FailAdd) + throw new InvalidOperationException($"add {edge}"); + } + + public void Remove(int edge, Action sideEffect) + { + RemoveCalls++; + RemoveCounts[edge]++; + if (FailRemoveEdgeOnce == edge) + { + FailRemoveEdgeOnce = 0; + throw new InvalidOperationException($"remove {edge}"); + } + + sideEffect(); + LiveEdges--; + } + } + + private sealed class Context( + FaultPlan faults, + IKeyboard keyboard, + IMouse mouse) : IInputContext + { + private Action? _connectionChanged; + public bool Disposed { get; private set; } + public nint Handle => 1; + public IReadOnlyList Gamepads { get; } = []; + public IReadOnlyList Joysticks { get; } = []; + public IReadOnlyList Keyboards { get; } = [keyboard]; + public IReadOnlyList Mice { get; } = [mouse]; + public IReadOnlyList OtherDevices { get; } = []; + + public event Action? ConnectionChanged + { + add => faults.Add(10, () => _connectionChanged += value); + remove => faults.Remove(10, () => _connectionChanged -= value); + } + + public void Dispose() => Disposed = true; + public void RaiseConnection(IInputDevice device) => + _connectionChanged?.Invoke(device, true); + } + + private sealed class Keyboard(FaultPlan faults) : IKeyboard + { + private Action? _down; + private Action? _up; + private Action? _char; + public bool RaiseDuringAdd { get; init; } + public string Name => "keyboard"; + public int Index => 0; + public bool IsConnected => true; + public IReadOnlyList SupportedKeys { get; } = []; + public string ClipboardText { get; set; } = string.Empty; + + public event Action? KeyDown + { + add => faults.Add(1, () => + { + _down += value; + if (RaiseDuringAdd) + _down?.Invoke(this, Key.A, 0); + }); + remove => faults.Remove(1, () => _down -= value); + } + public event Action? KeyUp + { + add => faults.Add(2, () => _up += value); + remove => faults.Remove(2, () => _up -= value); + } + public event Action? KeyChar + { + add => faults.Add(3, () => _char += value); + remove => faults.Remove(3, () => _char -= value); + } + + public bool IsKeyPressed(Key key) => false; + public bool IsScancodePressed(int scancode) => false; + public void BeginInput() { } + public void EndInput() { } + public void RaiseDown() => _down?.Invoke(this, Key.A, 0); + public void RaiseAll() + { + _down?.Invoke(this, Key.A, 0); + _up?.Invoke(this, Key.A, 0); + _char?.Invoke(this, 'a'); + } + } + + public class MouseProxy : DispatchProxy + { + private readonly Dictionary _events = new(); + private FaultPlan _faults = null!; + private IMouse _self = null!; + + public void Initialize(FaultPlan faults, IMouse self) + { + _faults = faults; + _self = self; + } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + ArgumentNullException.ThrowIfNull(targetMethod); + args ??= []; + if (targetMethod.Name.StartsWith("add_", StringComparison.Ordinal)) + { + string name = targetMethod.Name[4..]; + int edge = Edge(name); + _faults.Add(edge, () => + _events[name] = Delegate.Combine(_events.GetValueOrDefault(name), (Delegate)args[0]!)); + return null; + } + if (targetMethod.Name.StartsWith("remove_", StringComparison.Ordinal)) + { + string name = targetMethod.Name[7..]; + int edge = Edge(name); + _faults.Remove(edge, () => + _events[name] = Delegate.Remove(_events.GetValueOrDefault(name), (Delegate)args[0]!)); + return null; + } + + if (targetMethod.Name == nameof(IMouse.IsButtonPressed)) + return false; + Type returnType = targetMethod.ReturnType; + if (returnType == typeof(void)) + return null; + if (returnType == typeof(string)) + return string.Empty; + return returnType.IsValueType ? Activator.CreateInstance(returnType) : null; + } + + public void RaiseAll() + { + Get>(nameof(IMouse.MouseDown))?.Invoke(_self, MouseButton.Left); + Get>(nameof(IMouse.MouseUp))?.Invoke(_self, MouseButton.Left); + Get>(nameof(IMouse.Click)) + ?.Invoke(_self, MouseButton.Left, System.Numerics.Vector2.One); + Get>(nameof(IMouse.DoubleClick)) + ?.Invoke(_self, MouseButton.Left, System.Numerics.Vector2.One); + Get>(nameof(IMouse.MouseMove)) + ?.Invoke(_self, System.Numerics.Vector2.One); + Get>(nameof(IMouse.Scroll)) + ?.Invoke(_self, new ScrollWheel(1, 1)); + } + + private T? Get(string name) where T : Delegate => + _events.GetValueOrDefault(name) as T; + + private static int Edge(string name) => name switch + { + nameof(IMouse.MouseDown) => 4, + nameof(IMouse.MouseUp) => 5, + nameof(IMouse.Click) => 6, + nameof(IMouse.DoubleClick) => 7, + nameof(IMouse.MouseMove) => 8, + nameof(IMouse.Scroll) => 9, + _ => throw new InvalidOperationException($"Unexpected mouse event {name}."), + }; + } +} diff --git a/tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs b/tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs new file mode 100644 index 00000000..d341f6b4 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs @@ -0,0 +1,284 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class SilkInputSourceLifetimeTests +{ + [Theory] + [InlineData(1)] + [InlineData(2)] + public void KeyboardAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd) + { + var surface = new KeyboardSurface { FailAdd = failAdd }; + var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate()); + + Assert.ThrowsAny(source.Attach); + + Assert.True(source.IsDisposalComplete); + Assert.Equal(failAdd, surface.AddCalls); + Assert.Equal(failAdd, surface.RemoveCalls); + Assert.Equal(0, surface.LiveEdges); + } + + [Fact] + public void KeyboardDeactivateSilencesCopiedDelegateBeforePhysicalDetach() + { + var surface = new KeyboardSurface(); + var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate()); + int calls = 0; + source.KeyDown += (_, _) => calls++; + source.Attach(); + Action copied = surface.Down!; + + surface.RaiseDown(Key.A); + source.Deactivate(); + copied(Key.B); + source.Dispose(); + + Assert.Equal(1, calls); + Assert.True(source.IsDisposalComplete); + } + + [Fact] + public void KeyboardEventRaisedReentrantlyDuringAttachCannotEnterPartialSource() + { + var surface = new KeyboardSurface { RaiseDuringAdd = true }; + var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate()); + int calls = 0; + source.KeyDown += (_, _) => calls++; + + source.Attach(); + Assert.Equal(0, calls); + surface.RaiseDown(Key.A); + + Assert.Equal(1, calls); + } + + [Fact] + public void KeyboardDetachRetriesOnlyPendingEdge() + { + var surface = new KeyboardSurface(); + var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate()); + source.Attach(); + surface.FailRemoveDown = true; + + Assert.Throws(source.Dispose); + int upRemoves = surface.UpRemoveCalls; + surface.FailRemoveDown = false; + source.Dispose(); + + Assert.Equal(upRemoves, surface.UpRemoveCalls); + Assert.True(source.IsDisposalComplete); + } + + [Fact] + public void KeyboardDisposeBeforeAttachMakesSourceTerminal() + { + var surface = new KeyboardSurface(); + var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate()); + + source.Dispose(); + + Assert.Throws(source.Attach); + source.Dispose(); + Assert.Equal(0, surface.LiveEdges); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void MouseAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd) + { + var surface = new MouseSurface { FailAdd = failAdd }; + var source = SilkMouseSource.CreateDetached( + surface, + new Capture(), + modifierSource: null, + new HostQuiescenceGate()); + + Assert.ThrowsAny(source.Attach); + + Assert.True(source.IsDisposalComplete); + Assert.Equal(failAdd, surface.AddCalls); + Assert.Equal(failAdd, surface.RemoveCalls); + Assert.Equal(0, surface.LiveEdges); + } + + [Fact] + public void MouseDeactivateSilencesAllCopiedDelegatesBeforePhysicalDetach() + { + var surface = new MouseSurface(); + var source = SilkMouseSource.CreateDetached( + surface, + new Capture(), + modifierSource: null, + new HostQuiescenceGate()); + int calls = 0; + source.MouseDown += (_, _) => calls++; + source.MouseUp += (_, _) => calls++; + source.MouseMove += (_, _) => calls++; + source.Scroll += _ => calls++; + source.Attach(); + var copied = surface.CopyCallbacks(); + + surface.RaiseAll(); + source.Deactivate(); + copied.Down(MouseButton.Left); + copied.Up(MouseButton.Left); + copied.Move(new Vector2(5, 6)); + copied.Scroll(1); + source.Dispose(); + + Assert.Equal(4, calls); + Assert.True(source.IsDisposalComplete); + } + + [Fact] + public void MouseFailedDetachRetriesOnlyPendingEdge() + { + var surface = new MouseSurface(); + var source = SilkMouseSource.CreateDetached( + surface, + new Capture(), + modifierSource: null, + new HostQuiescenceGate()); + source.Attach(); + surface.FailRemoveMoveOnce = true; + + source.Dispose(); + int removes = surface.RemoveCalls; + source.Dispose(); + + Assert.Equal(removes, surface.RemoveCalls); + Assert.Equal(2, surface.MoveRemoveCalls); + Assert.True(source.IsDisposalComplete); + } + + [Fact] + public void MouseDisposeBeforeAttachMakesSourceTerminal() + { + var surface = new MouseSurface(); + var source = SilkMouseSource.CreateDetached( + surface, + new Capture(), + modifierSource: null, + new HostQuiescenceGate()); + + source.Dispose(); + + Assert.Throws(source.Attach); + source.Dispose(); + Assert.Equal(0, surface.LiveEdges); + } + + private sealed class Capture : IInputCaptureSource + { + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => false; + public bool DevToolsWantCaptureKeyboard => false; + } + + private sealed class KeyboardSurface : IKeyboardEventSurface + { + public int FailAdd { get; init; } + public bool FailRemoveDown { get; set; } + public bool RaiseDuringAdd { get; init; } + public int AddCalls { get; private set; } + public int RemoveCalls { get; private set; } + public int UpRemoveCalls { get; private set; } + public int LiveEdges { get; private set; } + private Action? _down; + private Action? _up; + public Action? Down => _down; + + public void AddKeyDown(Action callback) => Add(ref _down, callback); + public void AddKeyUp(Action callback) => Add(ref _up, callback); + public void RemoveKeyDown(Action callback) + { + RemoveCalls++; + if (FailRemoveDown) throw new InvalidOperationException("remove down"); + if (ReferenceEquals(_down, callback)) { _down = null; LiveEdges--; } + } + public void RemoveKeyUp(Action callback) + { + RemoveCalls++; + UpRemoveCalls++; + if (ReferenceEquals(_up, callback)) { _up = null; LiveEdges--; } + } + public bool IsKeyPressed(Key key) => false; + public void RaiseDown(Key key) => _down?.Invoke(key); + + private void Add(ref Action? slot, Action callback) + { + AddCalls++; + slot = callback; + LiveEdges++; + if (RaiseDuringAdd) callback(Key.A); + if (AddCalls == FailAdd) throw new InvalidOperationException("add"); + } + } + + private sealed class MouseSurface : IMouseEventSurface + { + public int FailAdd { get; init; } + public bool FailRemoveMoveOnce { get; set; } + public int AddCalls { get; private set; } + public int RemoveCalls { get; private set; } + public int MoveRemoveCalls { get; private set; } + public int LiveEdges { get; private set; } + private Action? _down; + private Action? _up; + private Action? _move; + private Action? _scroll; + + public void AddMouseDown(Action callback) => Add(ref _down, callback); + public void AddMouseUp(Action callback) => Add(ref _up, callback); + public void AddMouseMove(Action callback) => Add(ref _move, callback); + public void AddScroll(Action callback) => Add(ref _scroll, callback); + public void RemoveMouseDown(Action callback) => Remove(ref _down, callback); + public void RemoveMouseUp(Action callback) => Remove(ref _up, callback); + public void RemoveMouseMove(Action callback) + { + MoveRemoveCalls++; + if (FailRemoveMoveOnce) + { + FailRemoveMoveOnce = false; + throw new InvalidOperationException("remove move"); + } + Remove(ref _move, callback); + } + public void RemoveScroll(Action callback) => Remove(ref _scroll, callback); + public bool IsButtonPressed(MouseButton button) => false; + + public (Action Down, Action Up, Action Move, Action Scroll) + CopyCallbacks() => (_down!, _up!, _move!, _scroll!); + + public void RaiseAll() + { + _down?.Invoke(MouseButton.Left); + _up?.Invoke(MouseButton.Left); + _move?.Invoke(Vector2.One); + _scroll?.Invoke(1); + } + + private void Add(ref Action? slot, Action callback) + { + AddCalls++; + slot = callback; + LiveEdges++; + if (AddCalls == FailAdd) throw new InvalidOperationException("add"); + } + + private void Remove(ref Action? slot, Action callback) + { + RemoveCalls++; + if (ReferenceEquals(slot, callback)) { slot = null; LiveEdges--; } + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs b/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs new file mode 100644 index 00000000..e26d845e --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs @@ -0,0 +1,76 @@ +using AcDream.App.Input; +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class FramebufferResizeControllerTests +{ + [Fact] + public void ResizePreservesViewportAspectCameraDevToolsOrder() + { + var calls = new List(); + var aspect = new ViewportAspectState(); + var owner = new FramebufferResizeController(aspect); + owner.BindViewport(new Viewport(calls)); + owner.BindCamera(new Camera(calls)); + owner.BindDevTools(new DevTools(calls)); + + owner.Resize(1600, 900); + + Assert.Equal(["viewport:1600x900", "camera:1.778", "devtools:1600x900"], calls); + Assert.Equal(1600f / 900f, aspect.Aspect, 5); + } + + [Theory] + [InlineData(0, 720)] + [InlineData(1280, 0)] + [InlineData(-1, 720)] + [InlineData(1280, -1)] + public void NonPositiveFramebufferIsRejectedWithoutMutatingTargets(int width, int height) + { + var calls = new List(); + var aspect = new ViewportAspectState(); + var owner = new FramebufferResizeController(aspect); + owner.BindViewport(new Viewport(calls)); + owner.BindCamera(new Camera(calls)); + owner.BindDevTools(new DevTools(calls)); + + owner.Resize(width, height); + + Assert.Empty(calls); + Assert.Equal(16f / 9f, aspect.Aspect); + } + + [Fact] + public void LateBindingDoesNotReplayEarlierResize() + { + var calls = new List(); + var owner = new FramebufferResizeController(new ViewportAspectState()); + owner.Resize(1024, 768); + + owner.BindViewport(new Viewport(calls)); + owner.BindCamera(new Camera(calls)); + owner.BindDevTools(new DevTools(calls)); + + Assert.Empty(calls); + owner.Resize(800, 600); + Assert.Equal(["viewport:800x600", "camera:1.333", "devtools:800x600"], calls); + } + + private sealed class Viewport(List calls) : IFramebufferViewportTarget + { + public void ResizeViewport(int width, int height) => + calls.Add($"viewport:{width}x{height}"); + } + + private sealed class Camera(List calls) : IFramebufferCameraTarget + { + public void SetAspect(float aspect) => calls.Add($"camera:{aspect:F3}"); + } + + private sealed class DevTools(List calls) : IFramebufferDevToolsTarget + { + public void ResetLayout(int width, int height) => + calls.Add($"devtools:{width}x{height}"); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index 91f0533b..ca339663 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -99,7 +99,13 @@ public sealed class GameWindowRenderLeafCompositionTests Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source); Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source); Assert.Contains("new DisplayFramePacingController(", source); - Assert.Contains("_inputCapture.WantCaptureMouse", source); + string pointerSource = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Input", + "CameraPointerInputController.cs")); + Assert.Contains("_capture.WantCaptureMouse", pointerSource); } [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 66428e24..54f050f9 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -54,14 +54,18 @@ public sealed class GameWindowSlice8BoundaryTests body, "_gl = GL.GetApi(_window!);", "_input = _window!.CreateInput();", - "_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);", - "_mouseSource = new AcDream.App.Input.SilkMouseSource(", - "_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(", + "_kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached(", + "_kbSource.Attach();", + "_mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached(", + "_mouseSource.Attach();", + "_inputDispatcher = AcDream.UI.Abstractions.Input.InputDispatcher.CreateDetached(", + "_inputDispatcher.Attach();", "_movementInput.Bind(_inputDispatcher);", "_cameraInput.Bind(_inputDispatcher);", "_inputDispatcher.Fired += OnInputAction;", - "mouse.MouseMove +=", "_cameraController = new CameraController(orbit, fly);", + "_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(", + "_cameraPointerInput.AttachRaw();", "_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);", "LoadAndApplyPersistedSettings();", "_uiHost = new AcDream.App.UI.UiHost(", @@ -74,6 +78,16 @@ public sealed class GameWindowSlice8BoundaryTests "_liveSessionHost = CreateLiveSessionHost();", "_liveSessionHost.Start(_options)"); Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)")); + Assert.Contains("if (firstKb is not null)", body, StringComparison.Ordinal); + Assert.Contains("if (firstMouse is not null)", body, StringComparison.Ordinal); + Assert.Contains( + "if (_kbSource is not null && _mouseSource is not null)", + body, + StringComparison.Ordinal); + Assert.DoesNotContain( + "if (firstKb is not null && firstMouse is not null)", + body, + StringComparison.Ordinal); int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal); string postStart = body[start..]; Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal); @@ -144,20 +158,28 @@ public sealed class GameWindowSlice8BoundaryTests } [Fact] - public void FramebufferResize_PreservesViewportCameraAndDevtoolsOrder() + public void FramebufferResize_IsOneTypedOwnerHandoff() { + string load = MethodBody( + "private void OnLoad()", + "private AcDream.App.Net.LiveSessionHost"); string body = MethodBody( "private void OnFramebufferResize(", "private void ApplyDisplayWindowState("); + Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal); + Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal); + Assert.DoesNotContain("SetAspect(", body, StringComparison.Ordinal); + Assert.DoesNotContain("ResetLayout(", body, StringComparison.Ordinal); AssertAppearsInOrder( - body, - "if (newSize.X <= 0 || newSize.Y <= 0) return;", - "_gl?.Viewport(", - "_viewportAspect.Update(", - "_cameraController?.SetAspect(", - "_devToolsFramePresenter?.ResetLayout("); - Assert.DoesNotContain("_uiHost", body, StringComparison.Ordinal); + load, + "_framebufferResize.BindViewport(", + "_framebufferResize.BindCamera(", + "_framebufferResize.Resize(_window!.FramebufferSize);"); + Assert.DoesNotContain( + "_viewportAspect.Update(_window", + load, + StringComparison.Ordinal); } [Fact] @@ -167,7 +189,7 @@ public sealed class GameWindowSlice8BoundaryTests string update = Slice( source, "private void OnUpdate(double dt)", - "private void OnCameraModeChanged("); + "private void OnRender(double deltaSeconds)"); string render = Slice( source, "private void OnRender(double deltaSeconds)", @@ -189,7 +211,10 @@ public sealed class GameWindowSlice8BoundaryTests "_renderFrameOrchestrator!.Render(", "new AcDream.App.Rendering.RenderFrameInput("); Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal); - AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();"); + Assert.Contains( + "=> _cameraPointerInput?.HandleFocusChanged(focused);", + focus, + StringComparison.Ordinal); AssertAppearsInOrder( close, "_hostQuiescence.StopAccepting();", @@ -212,7 +237,9 @@ public sealed class GameWindowSlice8BoundaryTests string[] stages = [ + "new ResourceShutdownStage(\"input callback deactivation\"", "new ResourceShutdownStage(\"session lifetime\"", + "new ResourceShutdownStage(\"input callback detach\"", "new ResourceShutdownStage(\"frame borrowers\"", "new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"live entities\"", @@ -236,6 +263,17 @@ public sealed class GameWindowSlice8BoundaryTests "binding.Dispose();", "if (!binding.IsDisposalComplete)", "_windowCallbacks = null;"); + AssertAppearsInOrder( + manifest, + "new(\"camera pointer\", () => _cameraPointerInput?.Deactivate())", + "new ResourceShutdownStage(\"session lifetime\"", + "controller.Dispose();", + "new ResourceShutdownStage(\"input callback detach\"", + "pointer.Dispose();", + "new ResourceShutdownStage(\"session dependents\"", + "pointer.ReleaseMouseLookAfterSessionRetirement();", + "pointer.UnbindGameplayFrame(gameplayFrame);", + "_cameraPointerInput = null;"); AssertAppearsInOrder( dispose, "CompleteShutdown();", diff --git a/tests/AcDream.App.Tests/UI/RetainedUiInputBindingTests.cs b/tests/AcDream.App.Tests/UI/RetainedUiInputBindingTests.cs new file mode 100644 index 00000000..1b29f4ae --- /dev/null +++ b/tests/AcDream.App.Tests/UI/RetainedUiInputBindingTests.cs @@ -0,0 +1,262 @@ +using AcDream.App.Rendering; +using AcDream.App.UI; +using Silk.NET.Input; + +namespace AcDream.App.Tests.UI; + +public sealed class RetainedUiInputBindingTests +{ + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void MouseAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd) + { + var surface = new MouseSurface { FailAdd = failAdd }; + var binding = new RetainedMouseInputBinding( + surface, + new UiRoot(), + new HostQuiescenceGate()); + + Assert.ThrowsAny(binding.Attach); + + Assert.True(binding.IsDisposalComplete); + Assert.Equal(failAdd, surface.AddCalls); + Assert.Equal(failAdd, surface.RemoveCalls); + Assert.Equal(0, surface.LiveEdges); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void KeyboardAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd) + { + var surface = new KeyboardSurface { FailAdd = failAdd }; + var binding = new RetainedKeyboardInputBinding( + surface, + new UiRoot(), + new HostQuiescenceGate()); + + Assert.ThrowsAny(binding.Attach); + + Assert.True(binding.IsDisposalComplete); + Assert.Equal(failAdd, surface.AddCalls); + Assert.Equal(failAdd, surface.RemoveCalls); + Assert.Equal(0, surface.LiveEdges); + } + + [Fact] + public void MouseDeactivateSilencesCopiedDelegateBeforePhysicalDetach() + { + var surface = new MouseSurface(); + var root = new UiRoot(); + int worldClicks = 0; + root.WorldMouseFallThrough += (_, _, _, _) => worldClicks++; + var binding = new RetainedMouseInputBinding( + surface, + root, + new HostQuiescenceGate()); + binding.Attach(); + Action copied = surface.Down!; + + copied(MouseButton.Left, 10, 20); + binding.Deactivate(); + copied(MouseButton.Left, 10, 20); + binding.Dispose(); + + Assert.Equal(1, worldClicks); + } + + [Fact] + public void MouseEventRaisedReentrantlyDuringAttachCannotEnterPartialUiBinding() + { + var surface = new MouseSurface { RaiseDuringAdd = true }; + var root = new UiRoot(); + int worldClicks = 0; + root.WorldMouseFallThrough += (_, _, _, _) => worldClicks++; + var binding = new RetainedMouseInputBinding( + surface, + root, + new HostQuiescenceGate()); + + binding.Attach(); + Assert.Equal(0, worldClicks); + surface.Down!(MouseButton.Left, 1, 2); + + Assert.Equal(1, worldClicks); + } + + [Fact] + public void KeyboardDeactivateSilencesCopiedDelegateBeforePhysicalDetach() + { + var surface = new KeyboardSurface(); + var root = new UiRoot(); + int worldKeys = 0; + root.WorldKeyFallThrough += (_, _) => worldKeys++; + var binding = new RetainedKeyboardInputBinding( + surface, + root, + new HostQuiescenceGate()); + binding.Attach(); + Action copied = surface.Down!; + + copied(Key.A); + binding.Deactivate(); + copied(Key.B); + binding.Dispose(); + + Assert.Equal(1, worldKeys); + } + + [Fact] + public void RetainedBindingsAreTerminalAfterDisposeBeforeAttach() + { + var mouseSurface = new MouseSurface(); + var mouse = new RetainedMouseInputBinding( + mouseSurface, + new UiRoot(), + new HostQuiescenceGate()); + var keyboardSurface = new KeyboardSurface(); + var keyboard = new RetainedKeyboardInputBinding( + keyboardSurface, + new UiRoot(), + new HostQuiescenceGate()); + + mouse.Dispose(); + keyboard.Dispose(); + + Assert.Throws(mouse.Attach); + Assert.Throws(keyboard.Attach); + mouse.Dispose(); + keyboard.Dispose(); + Assert.Equal(0, mouseSurface.LiveEdges); + Assert.Equal(0, keyboardSurface.LiveEdges); + } + + [Fact] + public void RetainedBindingsRetryOnlyFailedPhysicalEdges() + { + var mouseSurface = new MouseSurface(); + var mouse = new RetainedMouseInputBinding( + mouseSurface, + new UiRoot(), + new HostQuiescenceGate()); + var keyboardSurface = new KeyboardSurface(); + var keyboard = new RetainedKeyboardInputBinding( + keyboardSurface, + new UiRoot(), + new HostQuiescenceGate()); + mouse.Attach(); + keyboard.Attach(); + mouseSurface.FailRemoveMoveOnce = true; + keyboardSurface.FailRemoveUpOnce = true; + + mouse.Dispose(); + keyboard.Dispose(); + int mouseRemoves = mouseSurface.RemoveCalls; + int keyboardRemoves = keyboardSurface.RemoveCalls; + mouse.Dispose(); + keyboard.Dispose(); + + Assert.Equal(mouseRemoves, mouseSurface.RemoveCalls); + Assert.Equal(keyboardRemoves, keyboardSurface.RemoveCalls); + Assert.Equal(2, mouseSurface.MoveRemoveCalls); + Assert.Equal(2, keyboardSurface.UpRemoveCalls); + Assert.True(mouse.IsDisposalComplete); + Assert.True(keyboard.IsDisposalComplete); + } + + private sealed class MouseSurface : IRetainedMouseSurface + { + public int FailAdd { get; init; } + public bool RaiseDuringAdd { get; init; } + public bool FailRemoveMoveOnce { get; set; } + public int AddCalls { get; private set; } + public int RemoveCalls { get; private set; } + public int MoveRemoveCalls { get; private set; } + public int LiveEdges { get; private set; } + public Action? Down { get; private set; } + private Action? _up; + private Action? _move; + private Action? _scroll; + + public void AddMouseDown(Action callback) => Add(() => Down = callback); + public void AddMouseUp(Action callback) => Add(() => _up = callback); + public void AddMouseMove(Action callback) => Add(() => _move = callback); + public void AddScroll(Action callback) => Add(() => _scroll = callback); + public void RemoveMouseDown(Action callback) => Remove(() => Down = null); + public void RemoveMouseUp(Action callback) => Remove(() => _up = null); + public void RemoveMouseMove(Action callback) + { + MoveRemoveCalls++; + if (FailRemoveMoveOnce) + { + FailRemoveMoveOnce = false; + throw new InvalidOperationException("remove move"); + } + Remove(() => _move = null); + } + public void RemoveScroll(Action callback) => Remove(() => _scroll = null); + + private void Add(Action publish) + { + AddCalls++; + publish(); + LiveEdges++; + if (RaiseDuringAdd) + Down?.Invoke(MouseButton.Left, 1, 2); + if (AddCalls == FailAdd) throw new InvalidOperationException("add"); + } + private void Remove(Action clear) + { + RemoveCalls++; + clear(); + LiveEdges--; + } + } + + private sealed class KeyboardSurface : IRetainedKeyboardSurface + { + public int FailAdd { get; init; } + public bool FailRemoveUpOnce { get; set; } + public int AddCalls { get; private set; } + public int RemoveCalls { get; private set; } + public int UpRemoveCalls { get; private set; } + public int LiveEdges { get; private set; } + public Action? Down { get; private set; } + private Action? _up; + private Action? _char; + + public void AddKeyDown(Action callback) => Add(() => Down = callback); + public void AddKeyUp(Action callback) => Add(() => _up = callback); + public void AddKeyChar(Action callback) => Add(() => _char = callback); + public void RemoveKeyDown(Action callback) => Remove(() => Down = null); + public void RemoveKeyUp(Action callback) + { + UpRemoveCalls++; + if (FailRemoveUpOnce) + { + FailRemoveUpOnce = false; + throw new InvalidOperationException("remove up"); + } + Remove(() => _up = null); + } + public void RemoveKeyChar(Action callback) => Remove(() => _char = null); + + private void Add(Action publish) + { + AddCalls++; + publish(); + LiveEdges++; + if (AddCalls == FailAdd) throw new InvalidOperationException("add"); + } + private void Remove(Action clear) + { + RemoveCalls++; + clear(); + LiveEdges--; + } + } +} diff --git a/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs b/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs index fdb9982c..eed4b3d1 100644 --- a/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs @@ -39,10 +39,11 @@ public sealed class LiveObjectFrameControllerTests private static SettingsVM CreateSettings() { - var dispatcher = new InputDispatcher( + var dispatcher = InputDispatcher.CreateDetached( new NullKeyboardSource(), new NullMouseSource(), new KeyBindings()); + dispatcher.Attach(); return new SettingsVM( new KeyBindings(), dispatcher, diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index b9e2aded..6f11f50d 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -409,13 +409,18 @@ public sealed class UpdateFrameOrchestratorTests StringComparison.Ordinal); Assert.DoesNotContain("wantCaptureMouse: ()", source, StringComparison.Ordinal); - Assert.Equal(2, CountOccurrences( - source, - "_gameplayInputFrame?.EndMouseLook();")); - Assert.Contains( - "new(\"mouse capture\", () => _gameplayInputFrame?.EndMouseLook())", + Assert.DoesNotContain( + "_gameplayInputFrame?.EndMouseLook", source, StringComparison.Ordinal); + string pointerSource = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Input", + "CameraPointerInputController.cs")); + Assert.Contains("_gameplayFrame?.EndMouseLook();", pointerSource, + StringComparison.Ordinal); string teleportSource = File.ReadAllText(Path.Combine( FindRepoRoot(), "src", @@ -438,8 +443,7 @@ public sealed class UpdateFrameOrchestratorTests AssertAppearsInOrder( source, "private void OnFocusChanged(bool focused)", - "if (!focused)", - "_gameplayInputFrame?.EndMouseLook();"); + "_cameraPointerInput?.HandleFocusChanged(focused);"); } [Fact] @@ -696,7 +700,7 @@ public sealed class UpdateFrameOrchestratorTests "private void OnUpdate(double dt)", "_frameProfiler.BeginStage(", "_updateFrameOrchestrator.Tick(", - "private void OnCameraModeChanged"); + "private void OnRender(double deltaSeconds)"); } [Fact] diff --git a/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs b/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs index 0e124177..2769e3ed 100644 --- a/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs +++ b/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs @@ -92,7 +92,8 @@ public class DispatcherToMovementIntegrationTests var kb = new FakeKb(); var mouse = new FakeMouse(); var bindings = KeyBindings.AcdreamCurrentDefaults(); - var dispatcher = new InputDispatcher(kb, mouse, bindings); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings); + dispatcher.Attach(); kb.Press(Key.W); @@ -122,7 +123,8 @@ public class DispatcherToMovementIntegrationTests var kb = new FakeKb(); var mouse = new FakeMouse(); var bindings = KeyBindings.AcdreamCurrentDefaults(); - var dispatcher = new InputDispatcher(kb, mouse, bindings); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings); + dispatcher.Attach(); kb.Press(Key.W); // Shift pressed alongside W — real keyboard delivers KeyDown(Shift, @@ -140,7 +142,8 @@ public class DispatcherToMovementIntegrationTests var kb = new FakeKb(); var mouse = new FakeMouse(); var bindings = KeyBindings.AcdreamCurrentDefaults(); - var dispatcher = new InputDispatcher(kb, mouse, bindings); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings); + dispatcher.Attach(); kb.Press(Key.W); Assert.True(BuildInputFromDispatcher(dispatcher).Forward); @@ -188,7 +191,8 @@ public class DispatcherToMovementIntegrationTests var kb = new FakeKb(); var mouse = new FakeMouse(); var bindings = KeyBindings.AcdreamCurrentDefaults(); - var dispatcher = new InputDispatcher(kb, mouse, bindings); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings); + dispatcher.Attach(); var input = BuildInputFromDispatcher(dispatcher); Assert.False(input.Forward); diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs index 9106846c..c10160a6 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs @@ -21,7 +21,8 @@ public class InputDispatcherCaptureTests var kb = new FakeKeyboardSource(); var mouse = new FakeMouseSource(); var bindings = new KeyBindings(); - var dispatcher = new InputDispatcher(kb, mouse, bindings); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings); + dispatcher.Attach(); var fired = new List<(InputAction, ActivationType)>(); dispatcher.Fired += (a, t) => fired.Add((a, t)); return (dispatcher, kb, mouse, bindings, fired); diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs index f6de5daa..891cf049 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs @@ -33,7 +33,8 @@ public class InputDispatcherDoubleClickTests bindings.Add(new Binding(lmbChord, InputAction.SelectDblLeft, ActivationType.DoubleClick)); bindings.Add(new Binding(rmbChord, InputAction.SelectRight)); - var dispatcher = new InputDispatcher(kb, mouse, bindings); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings); + dispatcher.Attach(); var fired = new List<(InputAction, ActivationType)>(); dispatcher.Fired += (a, t) => fired.Add((a, t)); return (dispatcher, mouse, fired); diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs index 163f763a..8dc6c274 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs @@ -19,7 +19,8 @@ public class InputDispatcherIsActionHeldTests var kb = new FakeKeyboardSource(); var mouse = new FakeMouseSource(); var bindings = new KeyBindings(); - var dispatcher = new InputDispatcher(kb, mouse, bindings); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings); + dispatcher.Attach(); return (dispatcher, kb, mouse, bindings); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherLifetimeTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherLifetimeTests.cs new file mode 100644 index 00000000..3f272f5c --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherLifetimeTests.cs @@ -0,0 +1,187 @@ +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +public sealed class InputDispatcherLifetimeTests +{ + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public void AttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd) + { + var source = new ThrowingSources { FailAdd = failAdd }; + var dispatcher = InputDispatcher.CreateDetached( + source, + source, + new KeyBindings()); + + Assert.ThrowsAny(dispatcher.Attach); + + Assert.True(dispatcher.IsDisposalComplete); + Assert.Equal(failAdd, source.AddCalls); + Assert.Equal(failAdd, source.RemoveCalls); + Assert.Equal(0, source.LiveEdges); + } + + [Fact] + public void DeactivateSilencesCopiedSourceDelegateBeforePhysicalDetach() + { + var source = new ThrowingSources(); + var bindings = new KeyBindings(); + bindings.Add(new Binding( + new KeyChord(Key.W, ModifierMask.None), + InputAction.MovementForward)); + var dispatcher = InputDispatcher.CreateDetached(source, source, bindings); + int fired = 0; + dispatcher.Fired += (_, _) => fired++; + dispatcher.Attach(); + Action copied = source.KeyDownDelegate!; + + copied(Key.W, ModifierMask.None); + dispatcher.Deactivate(); + copied(Key.W, ModifierMask.None); + dispatcher.Dispose(); + + Assert.Equal(1, fired); + Assert.True(dispatcher.IsDisposalComplete); + } + + [Fact] + public void SourceEventRaisedReentrantlyDuringAttachCannotEnterPartialDispatcher() + { + var source = new ThrowingSources { RaiseDuringKeyDownAdd = true }; + var bindings = new KeyBindings(); + bindings.Add(new Binding( + new KeyChord(Key.W, ModifierMask.None), + InputAction.MovementForward)); + var dispatcher = InputDispatcher.CreateDetached(source, source, bindings); + int fired = 0; + dispatcher.Fired += (_, _) => fired++; + + dispatcher.Attach(); + Assert.Equal(0, fired); + source.KeyDownDelegate!(Key.W, ModifierMask.None); + + Assert.Equal(1, fired); + } + + [Fact] + public void FailedDetachRetainsOnlyPendingSourceForRetry() + { + var source = new ThrowingSources(); + var dispatcher = InputDispatcher.CreateDetached( + source, + source, + new KeyBindings()); + dispatcher.Attach(); + source.FailRemoveKeyDown = true; + + Assert.Throws(dispatcher.Dispose); + int completedRemoves = source.RemoveCalls - source.KeyDownRemoveCalls; + source.FailRemoveKeyDown = false; + dispatcher.Dispose(); + + Assert.Equal(completedRemoves, source.RemoveCalls - source.KeyDownRemoveCalls); + Assert.True(dispatcher.IsDisposalComplete); + } + + [Fact] + public void DisposeBeforeAttachMakesDispatcherTerminal() + { + var source = new ThrowingSources(); + var dispatcher = InputDispatcher.CreateDetached( + source, + source, + new KeyBindings()); + + dispatcher.Dispose(); + + Assert.Throws(dispatcher.Attach); + dispatcher.Dispose(); + Assert.Equal(0, source.AddCalls); + Assert.Equal(0, source.LiveEdges); + } + + private sealed class ThrowingSources : IKeyboardSource, IMouseSource + { + private Action? _keyDown; + private Action? _keyUp; + private Action? _mouseDown; + private Action? _mouseUp; + private Action? _scroll; + + public int FailAdd { get; init; } + public bool FailRemoveKeyDown { get; set; } + public bool RaiseDuringKeyDownAdd { get; init; } + public int AddCalls { get; private set; } + public int RemoveCalls { get; private set; } + public int KeyDownRemoveCalls { get; private set; } + public int LiveEdges { get; private set; } + public Action? KeyDownDelegate => _keyDown; + + public event Action? KeyDown + { + add + { + Add(ref _keyDown, value!); + if (RaiseDuringKeyDownAdd) + value?.Invoke(Key.W, ModifierMask.None); + } + remove + { + KeyDownRemoveCalls++; + if (FailRemoveKeyDown) throw new InvalidOperationException("remove key down"); + Remove(ref _keyDown, value!); + } + } + public event Action? KeyUp + { + add => Add(ref _keyUp, value!); + remove => Remove(ref _keyUp, value!); + } + public event Action? MouseDown + { + add => Add(ref _mouseDown, value!); + remove => Remove(ref _mouseDown, value!); + } + public event Action? MouseUp + { + add => Add(ref _mouseUp, value!); + remove => Remove(ref _mouseUp, value!); + } +#pragma warning disable CS0067 + public event Action? MouseMove; +#pragma warning restore CS0067 + public event Action? Scroll + { + add => Add(ref _scroll, value!); + remove => Remove(ref _scroll, value!); + } + + public ModifierMask CurrentModifiers => ModifierMask.None; + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => false; + public bool IsHeld(Key key) => false; + public bool IsHeld(MouseButton button) => false; + + private void Add(ref T? slot, T value) where T : Delegate + { + AddCalls++; + slot = (T?)Delegate.Combine(slot, value); + LiveEdges++; + if (AddCalls == FailAdd) throw new InvalidOperationException("add"); + } + + private void Remove(ref T? slot, T value) where T : Delegate + { + RemoveCalls++; + if (slot is null) return; + slot = (T?)Delegate.Remove(slot, value); + LiveEdges--; + } + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs index c46d7a1d..6be58e1e 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs @@ -13,7 +13,8 @@ public class InputDispatcherTests var kb = new FakeKeyboardSource(); var mouse = new FakeMouseSource(); var bindings = new KeyBindings(); - var dispatcher = new InputDispatcher(kb, mouse, bindings); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings); + dispatcher.Attach(); var fired = new List<(InputAction, ActivationType)>(); dispatcher.Fired += (a, t) => fired.Add((a, t)); return (dispatcher, kb, mouse, bindings, fired); diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 6d51c515..7b420af9 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -28,7 +28,8 @@ public sealed class SettingsPanelTests var persisted = new KeyBindings(); persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); - var dispatcher = new InputDispatcher(kb, mouse, persisted); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted); + dispatcher.Attach(); var vm = new SettingsVM( persisted, dispatcher, _ => { }, DisplaySettings.Default, _ => { }, diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index c3e5b234..2c75537d 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -22,7 +22,8 @@ public sealed class SettingsVMTests persisted ??= MakeMinimalBindings(); var kb = new FakeKeyboardSource(); var mouse = new FakeMouseSource(); - var dispatcher = new InputDispatcher(kb, mouse, persisted); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted); + dispatcher.Attach(); var savedHistory = new System.Collections.Generic.List(); var savedDisplayHistory = new System.Collections.Generic.List(); var savedAudioHistory = new System.Collections.Generic.List(); From 4eae9b4f5a908bb55e2e93d98f6c0ad5de3feaeb Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 12:43:05 +0200 Subject: [PATCH 96/98] refactor(input): own gameplay action routing Move the sole semantic action-priority graph, combat and diagnostic commands, and retained-root item-drop lifetime behind focused typed owners. Preserve retail toggle behavior, explicit auto-wield cancellation, shutdown quiescence, and symmetric callback cleanup. Co-authored-by: Codex --- AGENTS.md | 17 +- CLAUDE.md | 17 +- docs/ISSUES.md | 9 +- docs/architecture/acdream-architecture.md | 18 +- docs/architecture/code-structure.md | 16 +- docs/plans/2026-04-11-roadmap.md | 13 +- docs/plans/2026-05-12-milestones.md | 10 +- ...amewindow-slice-8-composition-lifecycle.md | 19 +- ...bat-default-and-parent-event-pseudocode.md | 22 +- memory/project_gamewindow_decomposition.md | 26 +- .../Combat/LiveCombatModeCommandController.cs | 214 ++++++++ .../RuntimeDiagnosticCommandController.cs | 340 +++++++++++++ .../Input/CameraPointerInputController.cs | 8 +- .../Input/GameplayInputActionRouter.cs | 316 ++++++++++++ .../Input/GameplayInputCommandController.cs | 210 ++++++++ .../Input/RetainedUiGameplayBinding.cs | 139 ++++++ src/AcDream.App/Rendering/GameWindow.cs | 465 +++++------------- src/AcDream.Core/Combat/CombatModel.cs | 39 +- .../LiveCombatModeCommandControllerTests.cs | 190 +++++++ ...RuntimeDiagnosticCommandControllerTests.cs | 185 +++++++ .../Input/GameplayInputActionRouterTests.cs | 328 ++++++++++++ .../GameplayInputCommandControllerTests.cs | 180 +++++++ .../Input/RetainedUiGameplayBindingTests.cs | 158 ++++++ .../GameWindowSlice8BoundaryTests.cs | 75 ++- .../Combat/CombatInputPlannerTests.cs | 12 +- 25 files changed, 2608 insertions(+), 418 deletions(-) create mode 100644 src/AcDream.App/Combat/LiveCombatModeCommandController.cs create mode 100644 src/AcDream.App/Diagnostics/RuntimeDiagnosticCommandController.cs create mode 100644 src/AcDream.App/Input/GameplayInputActionRouter.cs create mode 100644 src/AcDream.App/Input/GameplayInputCommandController.cs create mode 100644 src/AcDream.App/Input/RetainedUiGameplayBinding.cs create mode 100644 tests/AcDream.App.Tests/Combat/LiveCombatModeCommandControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Diagnostics/RuntimeDiagnosticCommandControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs create mode 100644 tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Input/RetainedUiGameplayBindingTests.cs diff --git a/AGENTS.md b/AGENTS.md index f3e7514e..b4af475e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,477 tests pass / 5 skip. +Release builds; 7,524 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -127,7 +127,7 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–E are also complete: dead host residue is removed, +Slice 8 checkpoints A–F are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT @@ -136,11 +136,14 @@ sky/day group, weather, synchronization, and AdminEnvirons state. camera input/focus/scroll and physical framebuffer publication. Silk, dispatcher, retained-UI, and devtools device edges are reversible, transactional, and terminal after disposal; live-session retirement precedes -physical callback removal. `GameWindow` is 4,266 raw lines / 194 fields / 65 -methods, down 11,457 lines (72.9%) from the 15,723-line campaign baseline. The -7,477-test Release suite, 315.6-second lifecycle/reconnect gate, and -395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints F–L—action -routing, settings, resource ownership, ordered composition, +physical callback removal. `GameplayInputActionRouter` now owns the sole +semantic action-priority +graph; typed owners route combat, diagnostics, and remaining commands, and the +retained-root item-drop edge is reversible. `GameWindow` is 4,057 raw lines / +198 fields / 54 methods, down 11,666 lines (74.2%) from the 15,723-line campaign +baseline. The 7,524-test Release suite, 315.6-second lifecycle/reconnect gate, +and 395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints G–L— +settings, resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, diff --git a/CLAUDE.md b/CLAUDE.md index 4274735b..1ddc71c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,477 tests pass / 5 skip. +Release builds; 7,524 tests pass / 5 skip. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -125,7 +125,7 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–E are also complete: dead host residue is removed, +Slice 8 checkpoints A–F are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT @@ -134,11 +134,14 @@ sky/day group, weather, synchronization, and AdminEnvirons state. camera input/focus/scroll and physical framebuffer publication. Silk, dispatcher, retained-UI, and devtools device edges are reversible, transactional, and terminal after disposal; live-session retirement precedes -physical callback removal. `GameWindow` is 4,266 raw lines / 194 fields / 65 -methods, down 11,457 lines (72.9%) from the 15,723-line campaign baseline. The -7,477-test Release suite, 315.6-second lifecycle/reconnect gate, and -395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints F–L—action -routing, settings, resource ownership, ordered composition, +physical callback removal. `GameplayInputActionRouter` now owns the sole +semantic action-priority +graph; typed owners route combat, diagnostics, and remaining commands, and the +retained-root item-drop edge is reversible. `GameWindow` is 4,057 raw lines / +198 fields / 54 methods, down 11,666 lines (74.2%) from the 15,723-line campaign +baseline. The 7,524-test Release suite, 315.6-second lifecycle/reconnect gate, +and 395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints G–L— +settings, resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 38412aca..194d46b8 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -27,12 +27,13 @@ What does NOT go here: ## Current queue — 2026-07-22 - **Active structural work:** `GameWindow` decomposition. Slices 1–7 are - complete; Slice 8 checkpoints A–E now own native callbacks, live-session + complete; Slice 8 checkpoints A–F now own native callbacks, live-session composition, world-environment state, reversible device input/raw pointer, - and physical framebuffer publication outside the shell. + physical framebuffer publication, gameplay action priority, typed commands, + and the retained-root item-drop edge outside the shell. `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration - handoffs and the class is 4,266 lines / 194 fields / 65 methods. Remaining - Slice 8 action-routing/settings/composition/shutdown + handoffs and the class is 4,057 lines / 198 fields / 54 methods. Remaining + Slice 8 settings/resource/composition/shutdown cleanup is active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index ea8971d5..a5f62057 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -625,11 +625,12 @@ The accepted host order above is preserved as TS-53; it is not overclaimed as the exact retail `Client::UseTime` order. See the completed Slice 6 ledger and `memory/project_gamewindow_decomposition.md`. -The render callback currently owns the GPU frame boundary and draw graph. Slice -7 extracts that graph without changing its order: per-resource frame begins and -render-thread uploads; world/PView and its two shared-alpha scopes; portal and -paperdoll private viewports; retained gameplay UI; ImGui devtools; screenshot; -then the GPU-flight close in `finally`. Both UI stacks coexist in the same frame. +`GameWindow.OnRender` likewise owns only one handoff to the shipped +`RenderFrameOrchestrator`. Its frozen graph begins per-resource frames and +render-thread uploads; draws world/PView and its two shared-alpha scopes; +renders portal and paperdoll private viewports, retained gameplay UI, ImGui +devtools, and screenshots; then closes the GPU-flight transaction in +`finally`. Both UI stacks coexist in the same frame. --- @@ -828,10 +829,11 @@ Current movement/collision ownership: routers; Slice 4 moved hydration, teardown, inbound authority, network updates, and reusable motion-runtime policy over canonical `LiveEntityRuntime`. Slices 5–7 complete landblock presentation and the - update/render frame graphs. Slice 8 checkpoints A–E now own the native + update/render frame graphs. Slice 8 checkpoints A–F now own the native callback shell, live-session composition, world environment, reversible - input/pointer lifetime, and physical framebuffer publication. Gameplay action - routing is the next checkpoint. The reconciled ledger and sequence live in + input/pointer lifetime, physical framebuffer publication, the sole gameplay + action-priority graph, typed commands, and the retained-root item-drop edge. + Two-phase runtime settings is the next checkpoint. The reconciled ledger and sequence live in `docs/architecture/code-structure.md`. This decision does not reclassify the remaining L.2 conformance work or Track LH. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index c53770e1..3cf636e6 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -32,6 +32,8 @@ after Slice 5 closeout 8,811 lines / 247 fields / 153 method after Slice 6 closeout 7,026 lines / 241 fields / 108 methods after Slice 7 draw-frame cutover 4,666 lines / 196 fields / 70 methods after Slice 8 checkpoint D 4,330 lines / 192 fields / 67 methods +after Slice 8 checkpoint E 4,266 lines / 194 fields / 65 methods +after Slice 8 checkpoint F 4,057 lines / 198 fields / 54 methods ``` `GameWindow` is the single object that: @@ -690,15 +692,21 @@ slices. `OnClosing` delegates to the existing retryable shutdown transaction. Silk callbacks become narrow calls into the input, update, render, resize, focus, and shutdown owners. -Checkpoint E is complete. `CameraPointerInputController` owns raw pointer, +Checkpoints E and F are complete. `CameraPointerInputController` owns raw pointer, camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController` alone publishes physical framebuffer changes. All Silk, dispatcher, retained UI, and optional devtools device edges are reversible, transactional, and terminal after disposal. Logical cutoff precedes live-session retirement; physical detach follows it, so neither copied callbacks nor a failed event -remove can interfere with graceful transport teardown. `GameWindow` is now -4,266 raw lines / 194 fields / 65 methods. Checkpoints F–L remain active, with -gameplay action routing next. +remove can interfere with graceful transport teardown. + +`GameplayInputActionRouter` is now the sole `InputDispatcher.Fired` +subscriber and preserves the accepted input-priority graph. Focused combat, +diagnostic, and general command owners replace the remaining window command +bodies. The retained-root item-drop edge has one reversible owner, while its +deferred toolbar targets become inert before session retirement. `GameWindow` +is now 4,057 raw lines / 198 fields / 54 methods. Checkpoints G–L remain +active, with two-phase runtime settings next. ### 4.4 Exit criteria diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 92dedd0f..4b46c03b 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-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–E are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–F are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. 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. --- @@ -79,7 +79,7 @@ second synchronized soak pass with graceful exits, and the six stable PNG checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse process-residency gate's diagnostic blind spot; no tolerance was loosened. -Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–E now own +Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–F now own the frozen shell boundary, all nine native callback edges, live-session reset/selection/entry/route composition, and world clock/sky/day/weather/ AdminEnvirons state. Checkpoint E adds reversible, transactional Silk, @@ -87,9 +87,12 @@ dispatcher, retained-UI, and devtools input edges; a focused raw pointer owner; and one physical framebuffer-size publisher. Logical callback cutoff and live-session retirement precede physical removal, so failed device detach cannot block graceful transport close. `GameWindow` is 4,266 lines / 194 -fields / 65 methods; the Checkpoint E Release suite passes 7,477 tests / 5 -skips. Gameplay routing, settings, startup/resource transfer, shutdown, soak, -and closeout checkpoints F–L remain. +fields / 65 methods. Checkpoint F adds the sole gameplay action-priority +subscriber, typed combat/diagnostic/general commands, and a reversible +retained-root item-drop binding. `GameWindow` is now 4,057 lines / 198 fields / +54 methods; the Checkpoint F Release suite passes 7,524 tests / 5 skips. +Settings, startup/resource transfer, shutdown, soak, and closeout checkpoints +G–L remain. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index e733d96a..84fb2e4e 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -39,15 +39,17 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized nine-destination soak pass with graceful exits, and six stable PNG checkpoints -preserve Slice 6 presentation. Final Slice 8 checkpoints A–E are complete: +preserve Slice 6 presentation. Final Slice 8 checkpoints A–F are complete: native callbacks and live-session composition now have explicit retryable owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky, day group, weather, and AdminEnvirons state. Checkpoint E adds terminal, transactional ownership for Silk/dispatcher/retained/devtools input, the raw camera pointer, focus, and physical framebuffer size. Graceful live-session -retirement now precedes physical callback removal. `GameWindow` is 4,266 lines -/ 194 fields / 65 methods; 7,477 Release tests pass / 5 skip. Checkpoints F–L -remain active. Issue #232 tracks process-residency variance in the soak without +retirement now precedes physical callback removal. Checkpoint F adds the sole +gameplay action-priority owner, typed combat/diagnostic/general commands, and a +reversible retained-root item-drop binding. `GameWindow` is 4,057 lines / 198 +fields / 54 methods; 7,524 Release tests pass / 5 skip. Checkpoints G–L remain +active. Issue #232 tracks process-residency variance in the soak without loosening its leak threshold. Carried: diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index 79532b69..194b910a 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -23,7 +23,7 @@ audit. - [x] D — extract retail world-environment/day/weather behavior. - [x] E — extract two-phase raw pointer/camera input, focus, and framebuffer resize behind symmetric named subscriptions. -- [ ] F — extract the sole gameplay input-action router plus focused combat and +- [x] F — extract the sole gameplay input-action router plus focused combat and diagnostic command owners. - [ ] G — extract two-phase persisted settings/display/quality ownership. - [ ] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots @@ -527,6 +527,23 @@ connected lifecycle and visual gates. - Make Combat, Camera, dispatcher, and retained-root subscriptions named, transactional, and symmetrically detached. +Result: `GameplayInputActionRouter` is the sole gameplay subscriber to +`InputDispatcher.Fired` and preserves the frozen pointer → scroll → combat → +Press/DoubleClick gate → retained UI → selection → movement → command +priority. Focused command owners now route combat, diagnostics, window/player +mode, item-target mode, and Escape without calling feature bodies on +`GameWindow`. `RetainedUiGameplayBinding` owns the outside-UI item-drop edge; +the combat/diagnostic deferred slots release their targets before session +retirement, and the retained binding detaches transactionally afterward. +Named-retail review +also corrected `ToggleCombatMode @ 0x0056C8C0`: active combat short-circuits to +peace without an equipment lookup, while an incompatible Held object prints +the exact retail notice and sends no mode request. Three corrected-diff review +loops are clean; 54 focused App tests, 20 Core combat tests, the App suite +(3,155 pass / 3 intentional skips), the Release build, and the complete suite +(7,524 pass / 5 intentional skips) pass. `GameWindow` is 4,057 raw lines / 198 +fields / 54 methods. + ### G — two-phase runtime settings - Construct `RuntimeSettingsController` before Window.Create and source startup diff --git a/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md b/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md index 1e7e52aa..6f6cbc07 100644 --- a/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md +++ b/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md @@ -3,6 +3,8 @@ ## Sources - `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310` +- `ClientCombatSystem::SetCombatMode @ 0x0056BE30` +- `ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0` - `ACCWeenieObject::GetObjectAtLocation @ 0x0058CE00` - `CM_Physics::DispatchSB_ParentEvent @ 0x006ACAF0` - `SmartBox::HandleParentEvent @ 0x004535D0` @@ -52,10 +54,28 @@ GetDefaultCombatMode(showError): return Magic if showError == false: - print the retail cannot-use-held-item notice + print "You can't enter combat mode while wielding the %s" + formatted with held.GetObjectName(NAME_APPROPRIATE) return NonCombat ``` +`ToggleCombatMode` preserves that rejected result verbatim: + +```text +ToggleCombatMode(): + if current combat mode != NonCombat: + SetCombatMode(NonCombat, send = true) + return + + requested = GetDefaultCombatMode(showError = false) + SetCombatMode(requested, send = true) +``` + +`SetCombatMode` immediately returns when `requested == current`. Therefore an +incompatible Held object prints the notice in `GetDefaultCombatMode`, returns +`NonCombat`, and produces no wire request or local mode transition. It is not +coerced to unarmed Melee. + `GetObjectAtLocation` walks the player's ordered inventory-placement list and returns the first entry whose location intersects the requested mask (and whose priority intersects the requested priority when that priority is non-zero). diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index 156689b9..a5d1c192 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -3,9 +3,9 @@ ## Current state The behavior-preserving App ownership campaign is complete through Slice 7 and -Slice 8 checkpoints A–E. `GameWindow.cs` moved from the 2026-07-21 baseline of -15,723 lines / 278 fields / 205 methods to 4,266 lines / 194 fields / 65 -methods: 11,457 lines (72.9%) were removed without changing accepted gameplay +Slice 8 checkpoints A–F. `GameWindow.cs` moved from the 2026-07-21 baseline of +15,723 lines / 278 fields / 205 methods to 4,057 lines / 198 fields / 54 +methods: 11,666 lines (74.2%) were removed without changing accepted gameplay or rendering behavior. | Slice | Ownership moved out | Closeout size | @@ -18,6 +18,7 @@ or rendering behavior. | 6 | complete update-frame orchestration | 7,026 / 241 / 108 | | 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 | | 8 A–E | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 | +| 8 F | sole gameplay action route, typed commands, retained-root callback lifetime | 4,057 / 198 / 54 | Slice 8 is an ordered checkpoint campaign rather than another feature-body move. A froze/pruned the shell boundary; B gave all nine native window callback @@ -43,6 +44,17 @@ run only after session retirement. This prevents both copied callback reentry and a failed event accessor from blocking F653/transport teardown. The carried non-retail post-filter camera scalars are explicitly registered as TS-56. +F adds `GameplayInputActionRouter` as the sole `InputDispatcher.Fired` +subscriber while preserving the frozen action priority. Typed command owners +replace the remaining combat, diagnostic, window/player mode, item-target, +and Escape bodies. `RetainedUiGameplayBinding` owns the outside-UI item-drop +event. The two deferred command slots release their targets before session +retirement; the retained binding alone detaches its physical edge +transactionally afterward. Retail +review also corrected the carried default-combat mismatch: active combat +short-circuits directly to peace, and an incompatible Held object prints the +exact retail notice without a wire request or local mode mutation. + Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`, @@ -99,6 +111,10 @@ review rule live in `docs/architecture/code-structure.md`. ## Accepted verification +- Slice 8 Checkpoint F Release suite: 7,524 passed / 5 fixture or environment + skips; App 3,155/3 skips, 54 focused App ownership tests and 20 Core combat + conformance tests pass, and all three independent corrected-diff reviews are + clean. - Slice 8 Checkpoint E Release suite: 7,477 passed / 5 fixture or environment skips; App 3,108/3 skips, UI Abstractions 533/0, and all three independent corrected-diff reviews are clean. @@ -131,8 +147,8 @@ review rule live in `docs/architecture/code-structure.md`. ## Next work -Slice 8 checkpoints F–L group the remaining action routing, settings, resource -ownership, startup, and shutdown wiring and reduce Silk callbacks to +Slice 8 checkpoints G–L group the remaining settings, resource ownership, +startup, and shutdown wiring and reduce Silk callbacks to narrow calls into input, update, render, resize, focus, and shutdown owners. It removes leftover feature state only where Slices 1–7 already established a canonical owner; it does not redesign gameplay, rendering, or resource lifetime. diff --git a/src/AcDream.App/Combat/LiveCombatModeCommandController.cs b/src/AcDream.App/Combat/LiveCombatModeCommandController.cs new file mode 100644 index 00000000..c84cf5ab --- /dev/null +++ b/src/AcDream.App/Combat/LiveCombatModeCommandController.cs @@ -0,0 +1,214 @@ +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.App.UI; +using AcDream.Core.Combat; +using AcDream.Core.Items; + +namespace AcDream.App.Combat; + +internal interface ILiveCombatModeAuthority +{ + bool IsInWorld { get; } + + void SendChangeCombatMode(CombatMode mode); +} + +internal sealed class LiveSessionCombatModeAuthority(LiveSessionHost session) + : ILiveCombatModeAuthority +{ + private readonly LiveSessionHost _session = session + ?? throw new ArgumentNullException(nameof(session)); + + public bool IsInWorld => + _session.IsInWorld && _session.CurrentSession is not null; + + public void SendChangeCombatMode(CombatMode mode) + { + if (!IsInWorld) + { + throw new InvalidOperationException( + "A combat-mode request requires an active in-world session."); + } + + _session.CurrentSession!.SendChangeCombatMode(mode); + } +} + +internal interface ICombatEquipmentSource +{ + IReadOnlyList GetOrderedEquipment(); +} + +internal sealed class LocalPlayerCombatEquipmentSource( + ClientObjectTable objects, + ILocalPlayerIdentitySource identity) : ICombatEquipmentSource +{ + private readonly ClientObjectTable _objects = objects + ?? throw new ArgumentNullException(nameof(objects)); + private readonly ILocalPlayerIdentitySource _identity = identity + ?? throw new ArgumentNullException(nameof(identity)); + + public IReadOnlyList GetOrderedEquipment() => + _objects.GetEquippedBy(_identity.ServerGuid); +} + +internal interface IExplicitCombatModeIntentSink +{ + void NotifyExplicitCombatModeRequest(); +} + +internal sealed class ItemInteractionCombatModeIntentSink( + ItemInteractionController items) : IExplicitCombatModeIntentSink +{ + private readonly ItemInteractionController _items = items + ?? throw new ArgumentNullException(nameof(items)); + + public void NotifyExplicitCombatModeRequest() => + _items.NotifyExplicitCombatModeRequest(); +} + +internal interface ILiveCombatModeCommand +{ + void Toggle(); +} + +/// +/// Focused deferred edge shared by the early-created retained toolbar and the +/// later-created gameplay router. It owns no combat state and forwards to one +/// window-lifetime command owner after session composition is complete. +/// +internal sealed class LiveCombatModeCommandSlot : ILiveCombatModeCommand +{ + private readonly object _gate = new(); + private ILiveCombatModeCommand? _target; + private bool _deactivated; + + public void Bind(ILiveCombatModeCommand target) + { + ArgumentNullException.ThrowIfNull(target); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_deactivated, this); + if (_target is not null && !ReferenceEquals(_target, target)) + { + throw new InvalidOperationException( + "Live combat-mode commands are already bound."); + } + + _target = target; + } + } + + public void Unbind(ILiveCombatModeCommand target) + { + ArgumentNullException.ThrowIfNull(target); + lock (_gate) + { + if (ReferenceEquals(_target, target)) + _target = null; + } + } + + public void Deactivate() + { + lock (_gate) + { + _deactivated = true; + _target = null; + } + } + + public void Toggle() + { + lock (_gate) + { + if (!_deactivated) + _target?.Toggle(); + } + } +} + +/// +/// Owns the input-facing combat-mode command. The choice itself remains the +/// retail port in : +/// ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310 and +/// ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0. ACE remains the +/// authority; the local state update preserves the accepted immediate toolbar +/// response while the server confirmation travels through the normal route. +/// +internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand +{ + private readonly ILiveCombatModeAuthority _authority; + private readonly ICombatEquipmentSource _equipment; + private readonly CombatState _combat; + private readonly IExplicitCombatModeIntentSink _itemIntent; + private readonly Action _log; + private readonly Action? _toast; + private readonly Action _systemMessage; + + public LiveCombatModeCommandController( + ILiveCombatModeAuthority authority, + ICombatEquipmentSource equipment, + CombatState combat, + IExplicitCombatModeIntentSink itemIntent, + Action? log = null, + Action? toast = null, + Action? systemMessage = null) + { + _authority = authority ?? throw new ArgumentNullException(nameof(authority)); + _equipment = equipment ?? throw new ArgumentNullException(nameof(equipment)); + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent)); + _log = log ?? (_ => { }); + _toast = toast; + _systemMessage = systemMessage ?? (_ => { }); + } + + public void Toggle() + { + if (!_authority.IsInWorld) + return; + + // Every explicit user combat request supersedes an in-flight auto-wield + // settlement, including a request retail rejects before SetCombatMode. + _itemIntent.NotifyExplicitCombatModeRequest(); + + CombatMode currentMode = _combat.CurrentMode; + CombatMode nextMode; + if (currentMode != CombatMode.NonCombat) + { + // ToggleCombatMode @ 0x0056C8C0 immediately selects NonCombat and + // never queries default equipment on this branch. + nextMode = CombatMode.NonCombat; + } + else + { + DefaultCombatModeDecision defaultDecision = + CombatInputPlanner.GetDefaultCombatModeDecision( + _equipment.GetOrderedEquipment()); + nextMode = CombatInputPlanner.ToggleMode( + currentMode, + defaultDecision.Mode); + + // GetDefaultCombatMode prints this notice before SetCombatMode. + // SetCombatMode then sees NonCombat == NonCombat and does no work. + if (defaultDecision.IncompatibleHeldItem is { } held) + { + string notice = + $"You can't enter combat mode while wielding the {held.GetAppropriateName()}"; + _log($"combat: {notice}"); + _systemMessage(notice); + return; + } + } + + // Preserve the accepted command order: explicit intent was published + // above, then send the request and publish local presentation. + _authority.SendChangeCombatMode(nextMode); + _combat.SetCombatMode(nextMode); + + string message = $"Combat mode {nextMode}"; + _log($"combat: {message}"); + _toast?.Invoke(message); + } +} diff --git a/src/AcDream.App/Diagnostics/RuntimeDiagnosticCommandController.cs b/src/AcDream.App/Diagnostics/RuntimeDiagnosticCommandController.cs new file mode 100644 index 00000000..b5d1926f --- /dev/null +++ b/src/AcDream.App/Diagnostics/RuntimeDiagnosticCommandController.cs @@ -0,0 +1,340 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Diagnostics; + +internal interface IRuntimeDiagnosticCommands +{ + bool Handle(InputAction action); + + void CycleTimeOfDay(); + + void CycleWeather(); + + void ToggleCollisionWireframes(); +} + +/// +/// Focused deferred edge used by the early-created developer panel and the +/// later-created gameplay router. It owns no diagnostic state and binds one +/// window-lifetime command owner exactly once. +/// +internal sealed class RuntimeDiagnosticCommandSlot : IRuntimeDiagnosticCommands +{ + private readonly object _gate = new(); + private IRuntimeDiagnosticCommands? _target; + private bool _deactivated; + + public void Bind(IRuntimeDiagnosticCommands target) + { + ArgumentNullException.ThrowIfNull(target); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_deactivated, this); + if (_target is not null && !ReferenceEquals(_target, target)) + { + throw new InvalidOperationException( + "Runtime diagnostic commands are already bound."); + } + + _target = target; + } + } + + public void Unbind(IRuntimeDiagnosticCommands target) + { + ArgumentNullException.ThrowIfNull(target); + lock (_gate) + { + if (ReferenceEquals(_target, target)) + _target = null; + } + } + + public void Deactivate() + { + lock (_gate) + { + _deactivated = true; + _target = null; + } + } + + public bool Handle(InputAction action) + { + lock (_gate) + { + if (!_deactivated && _target is { } target) + return target.Handle(action); + } + + return RuntimeDiagnosticCommandController.IsDiagnosticAction(action); + } + + public void CycleTimeOfDay() + { + lock (_gate) + { + if (!_deactivated) + _target?.CycleTimeOfDay(); + } + } + + public void CycleWeather() + { + lock (_gate) + { + if (!_deactivated) + _target?.CycleWeather(); + } + } + + public void ToggleCollisionWireframes() + { + lock (_gate) + { + if (!_deactivated) + _target?.ToggleCollisionWireframes(); + } + } +} + +internal interface INearbyWorldDiagnosticSource +{ + Vector3 ObserverPosition { get; } + + int LiveCenterX { get; } + + int LiveCenterY { get; } + + int TotalShadowObjects { get; } + + IReadOnlyList WorldEntities { get; } + + IEnumerable ShadowEntries { get; } +} + +/// +/// Read-only view over canonical runtime owners. It does not cache the current +/// player, camera, entity list, origin, or shadow registry. +/// +internal sealed class RuntimeNearbyWorldDiagnosticSource : INearbyWorldDiagnosticSource +{ + private readonly ILocalPlayerModeSource _mode; + private readonly ILocalPlayerControllerSource _player; + private readonly CameraController _camera; + private readonly LiveWorldOriginState _origin; + private readonly GpuWorldState _world; + private readonly PhysicsEngine _physics; + + public RuntimeNearbyWorldDiagnosticSource( + ILocalPlayerModeSource mode, + ILocalPlayerControllerSource player, + CameraController camera, + LiveWorldOriginState origin, + GpuWorldState world, + PhysicsEngine physics) + { + _mode = mode ?? throw new ArgumentNullException(nameof(mode)); + _player = player ?? throw new ArgumentNullException(nameof(player)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _world = world ?? throw new ArgumentNullException(nameof(world)); + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + } + + public Vector3 ObserverPosition + { + get + { + if (_mode.IsPlayerMode && _player.Controller is { } player) + return player.Position; + + Matrix4x4.Invert(_camera.Active.View, out Matrix4x4 inverseView); + return new Vector3( + inverseView.M41, + inverseView.M42, + inverseView.M43); + } + } + + public int LiveCenterX => _origin.CenterX; + + public int LiveCenterY => _origin.CenterY; + + public int TotalShadowObjects => _physics.ShadowObjects.TotalRegistered; + + public IReadOnlyList WorldEntities => _world.Entities; + + public IEnumerable ShadowEntries => + _physics.ShadowObjects.AllEntriesForDebug(); +} + +internal sealed class NearbyWorldDiagnosticDumper +{ + private const float NearbyDistance = 15f; + private readonly INearbyWorldDiagnosticSource _source; + private readonly Action _log; + + public NearbyWorldDiagnosticDumper( + INearbyWorldDiagnosticSource source, + Action? log = null) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _log = log ?? (_ => { }); + } + + public void Dump() + { + Vector3 position = _source.ObserverPosition; + int centerX = _source.LiveCenterX; + int centerY = _source.LiveCenterY; + int landblockX = centerX + (int)MathF.Floor(position.X / 192f); + int landblockY = centerY + (int)MathF.Floor(position.Y / 192f); + + _log( + $"=== F3 DEBUG DUMP ===\n" + + $" player pos=({position.X:F2},{position.Y:F2},{position.Z:F2})\n" + + $" landblock=0x{(uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF):X8} " + + $"local=({position.X - (landblockX - centerX) * 192f:F2}," + + $"{position.Y - (landblockY - centerY) * 192f:F2})\n" + + $" total shadow objects: {_source.TotalShadowObjects}"); + + List visibleNearby = _source.WorldEntities + .Where(entity => HorizontalDistanceSquared(entity.Position, position) + < NearbyDistance * NearbyDistance) + .OrderBy(entity => (entity.Position - position).Length()) + .ToList(); + _log($" VISIBLE entities within 15m: {visibleNearby.Count}"); + foreach (WorldEntity entity in visibleNearby.Take(12)) + { + float distance = (entity.Position - position).Length(); + _log( + $" VIS id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " + + $"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) " + + $"dist={distance:F2} scale={entity.Scale:F2}"); + } + + List<(ShadowEntry Entry, float Distance)> shadows = _source.ShadowEntries + .Select(entry => + { + float dx = entry.Position.X - position.X; + float dy = entry.Position.Y - position.Y; + return (Entry: entry, Distance: MathF.Sqrt(dx * dx + dy * dy)); + }) + .Where(item => item.Distance < NearbyDistance) + .OrderBy(item => item.Distance) + .ToList(); + _log($" SHADOW objects within 15m: {shadows.Count}"); + foreach ((ShadowEntry entry, float distance) in shadows.Take(12)) + { + _log( + $" SHAD id=0x{entry.EntityId:X8} {entry.CollisionType} " + + $"r={entry.Radius:F2} h={entry.CylHeight:F2} " + + $"pos=({entry.Position.X:F2},{entry.Position.Y:F2},{entry.Position.Z:F2}) " + + $"dist={distance:F2}"); + } + } + + private static float HorizontalDistanceSquared(Vector3 left, Vector3 right) + { + float dx = left.X - right.X; + float dy = left.Y - right.Y; + return dx * dx + dy * dy; + } +} + +/// +/// Routes developer-only semantic actions while leaving every mutable value in +/// its canonical owner. No diagnostic command owns a second clock, weather, +/// camera-sensitivity, collision, entity, or shadow state. +/// +internal sealed class RuntimeDiagnosticCommandController : IRuntimeDiagnosticCommands +{ + private readonly WorldEnvironmentController _environment; + private readonly WorldSceneDebugState _sceneDebug; + private readonly IPointerSensitivityCommands? _pointer; + private readonly NearbyWorldDiagnosticDumper _nearby; + private readonly Action? _toast; + + public RuntimeDiagnosticCommandController( + WorldEnvironmentController environment, + WorldSceneDebugState sceneDebug, + IPointerSensitivityCommands? pointer, + NearbyWorldDiagnosticDumper nearby, + Action? toast = null) + { + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + _sceneDebug = sceneDebug ?? throw new ArgumentNullException(nameof(sceneDebug)); + _pointer = pointer; + _nearby = nearby ?? throw new ArgumentNullException(nameof(nearby)); + _toast = toast; + } + + public bool Handle(InputAction action) + { + switch (action) + { + case InputAction.AcdreamToggleCollisionWires: + ToggleCollisionWireframes(); + return true; + case InputAction.AcdreamDumpNearby: + _nearby.Dump(); + return true; + case InputAction.AcdreamCycleTimeOfDay: + CycleTimeOfDay(); + return true; + case InputAction.AcdreamSensitivityDown: + if (_pointer is not null) + { + string message = _pointer.AdjustSensitivity(1f / 1.2f); + _toast?.Invoke(message); + } + return true; + case InputAction.AcdreamSensitivityUp: + if (_pointer is not null) + { + string message = _pointer.AdjustSensitivity(1.2f); + _toast?.Invoke(message); + } + return true; + case InputAction.AcdreamCycleWeather: + CycleWeather(); + return true; + default: + return false; + } + } + + public void CycleTimeOfDay() + { + string message = _environment.CycleTimeOfDay(); + _toast?.Invoke(message); + } + + public void CycleWeather() + { + string message = _environment.CycleWeather(); + _toast?.Invoke(message); + } + + public void ToggleCollisionWireframes() + { + bool visible = _sceneDebug.ToggleCollisionWireframes(); + _toast?.Invoke($"Collision wireframes {(visible ? "ON" : "OFF")}"); + } + + internal static bool IsDiagnosticAction(InputAction action) => action is + InputAction.AcdreamToggleCollisionWires + or InputAction.AcdreamDumpNearby + or InputAction.AcdreamCycleTimeOfDay + or InputAction.AcdreamSensitivityDown + or InputAction.AcdreamSensitivityUp + or InputAction.AcdreamCycleWeather; +} diff --git a/src/AcDream.App/Input/CameraPointerInputController.cs b/src/AcDream.App/Input/CameraPointerInputController.cs index 22d43807..fcbb78fe 100644 --- a/src/AcDream.App/Input/CameraPointerInputController.cs +++ b/src/AcDream.App/Input/CameraPointerInputController.cs @@ -74,13 +74,19 @@ internal sealed class SilkPointerCursorModeTarget(IMouse mouse) } } +internal interface IPointerSensitivityCommands +{ + string AdjustSensitivity(float factor); +} + /// /// Owns raw pointer subscriptions and the camera-facing pointer policy that /// previously lived in GameWindow. Physical event removal is retriable; logical /// deactivation is immediate, so a copied or stubborn Silk delegate cannot /// re-enter gameplay while shutdown is closing the live session. /// -internal sealed class CameraPointerInputController : IDisposable +internal sealed class CameraPointerInputController + : IDisposable, IPointerSensitivityCommands { private readonly IReadOnlyList _surfaces; private readonly IPointerCursorModeTarget _cursor; diff --git a/src/AcDream.App/Input/GameplayInputActionRouter.cs b/src/AcDream.App/Input/GameplayInputActionRouter.cs new file mode 100644 index 00000000..5800bd0d --- /dev/null +++ b/src/AcDream.App/Input/GameplayInputActionRouter.cs @@ -0,0 +1,316 @@ +using AcDream.App.Interaction; +using AcDream.App.Rendering; +using AcDream.App.UI; +using AcDream.Core.Combat; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Input; + +internal interface IGameplayInputActionSurface +{ + void AddFired(Action callback); + + void RemoveFired(Action callback); + + void SetCombatScope(InputScope? scope); +} + +internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher) + : IGameplayInputActionSurface +{ + private readonly InputDispatcher _dispatcher = dispatcher + ?? throw new ArgumentNullException(nameof(dispatcher)); + + public void AddFired(Action callback) => + _dispatcher.Fired += callback; + + public void RemoveFired(Action callback) => + _dispatcher.Fired -= callback; + + public void SetCombatScope(InputScope? scope) => + _dispatcher.SetCombatScope(scope); +} + +internal interface ICombatModeEventSurface +{ + CombatMode CurrentMode { get; } + + void AddChanged(Action callback); + + void RemoveChanged(Action callback); +} + +internal sealed class CombatStateModeEventSurface(CombatState combat) + : ICombatModeEventSurface +{ + private readonly CombatState _combat = combat + ?? throw new ArgumentNullException(nameof(combat)); + + public CombatMode CurrentMode => _combat.CurrentMode; + + public void AddChanged(Action callback) => + _combat.CombatModeChanged += callback; + + public void RemoveChanged(Action callback) => + _combat.CombatModeChanged -= callback; +} + +internal interface IGameplayInputPriorityTargets +{ + bool HandlePointerAction(InputAction action, ActivationType activation); + + void HandleScroll(InputAction action); + + bool HandleCombatAction(InputAction action, ActivationType activation); + + bool HandleRetainedUiAction(InputAction action); + + bool HandleSelectionAction(InputAction action); + + bool HandlePressedMovementAction(InputAction action); + + void HandleCommand(InputAction action); +} + +/// +/// Typed adapter over canonical gameplay owners. Optional presentation owners +/// remain no-ops when their stack is disabled; no state is mirrored here. +/// +internal sealed class RuntimeGameplayInputPriorityTargets + : IGameplayInputPriorityTargets +{ + private readonly GameplayInputFrameController _frame; + private readonly CameraPointerInputController _pointer; + private readonly RetailUiRuntime? _retainedUi; + private readonly SelectionInteractionController? _selection; + private readonly IGameplayInputCommandTarget _commands; + + public RuntimeGameplayInputPriorityTargets( + GameplayInputFrameController frame, + CameraPointerInputController pointer, + RetailUiRuntime? retainedUi, + SelectionInteractionController? selection, + IGameplayInputCommandTarget commands) + { + _frame = frame ?? throw new ArgumentNullException(nameof(frame)); + _pointer = pointer ?? throw new ArgumentNullException(nameof(pointer)); + _retainedUi = retainedUi; + _selection = selection; + _commands = commands ?? throw new ArgumentNullException(nameof(commands)); + } + + public bool HandlePointerAction(InputAction action, ActivationType activation) => + _frame.HandlePointerAction(action, activation); + + public void HandleScroll(InputAction action) => + _pointer.HandleScroll(action); + + public bool HandleCombatAction(InputAction action, ActivationType activation) => + _frame.HandleCombatAction(action, activation); + + public bool HandleRetainedUiAction(InputAction action) => + _retainedUi?.HandleInputAction(action) == true; + + public bool HandleSelectionAction(InputAction action) => + _selection?.HandleInputAction(action) == true; + + public bool HandlePressedMovementAction(InputAction action) => + _frame.HandlePressedMovementAction(action); + + public void HandleCommand(InputAction action) => + _commands.Handle(action); +} + +/// +/// Sole gameplay subscriber to . The route +/// preserves the accepted priority exactly: pointer, scroll, combat, +/// Press/DoubleClick gate, retained UI, selection, pressed movement, commands. +/// Subscription ownership is reversible and callbacks become inert before the +/// live-session shutdown barrier. +/// +internal sealed class GameplayInputActionRouter : IDisposable +{ + private readonly IGameplayInputActionSurface _actions; + private readonly ICombatModeEventSurface _combat; + private readonly IGameplayInputPriorityTargets _targets; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _log; + private readonly Action _fired; + private readonly Action _combatModeChanged; + private readonly bool[] _attached = new bool[2]; + private ResourceShutdownTransaction? _detach; + private bool _attachStarted; + private int _disposeRequested; + private int _active; + + public GameplayInputActionRouter( + IGameplayInputActionSurface actions, + ICombatModeEventSurface combat, + IGameplayInputPriorityTargets targets, + HostQuiescenceGate quiescence, + Action? log = null) + { + _actions = actions ?? throw new ArgumentNullException(nameof(actions)); + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _targets = targets ?? throw new ArgumentNullException(nameof(targets)); + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); + _log = log ?? (_ => { }); + _fired = OnFired; + _combatModeChanged = OnCombatModeChanged; + } + + public static GameplayInputActionRouter Create( + InputDispatcher dispatcher, + CombatState combat, + IGameplayInputPriorityTargets targets, + HostQuiescenceGate quiescence, + Action? log = null) => + new( + new DispatcherGameplayInputActionSurface(dispatcher), + new CombatStateModeEventSurface(combat), + targets, + quiescence, + log); + + public bool IsDisposalComplete => + _attached.All(static attached => !attached); + + public void Attach() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + if (_attachStarted) + { + throw new InvalidOperationException( + "Gameplay action attachment has already started."); + } + + _attachStarted = true; + try + { + // Mark each edge before invoking its custom accessor. An accessor + // may perform the add and then throw; rollback must still remove it. + _attached[0] = true; + _actions.AddFired(_fired); + _attached[1] = true; + _combat.AddChanged(_combatModeChanged); + + Volatile.Write(ref _active, 1); + SetCombatScope(_combat.CurrentMode); + } + catch (Exception attachError) + { + Deactivate(); + try + { + EnsureDetachTransaction().CompleteOrThrow(); + } + catch (Exception rollbackError) + { + throw new AggregateException( + "Gameplay action registration and rollback both failed.", + new InvalidOperationException( + "Gameplay action registration failed.", attachError), + rollbackError); + } + + throw new InvalidOperationException( + "Gameplay action registration failed and was rolled back.", + attachError); + } + } + + public void Deactivate() => Interlocked.Exchange(ref _active, 0); + + public void Dispose() + { + Interlocked.Exchange(ref _disposeRequested, 1); + Deactivate(); + EnsureDetachTransaction().CompleteOrThrow(); + } + + private void OnFired(InputAction action, ActivationType activation) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + Route(action, activation); + }); + + private void OnCombatModeChanged(CombatMode mode) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0) + SetCombatScope(mode); + }); + + private void Route(InputAction action, ActivationType activation) + { + _log($"[input] {action} {activation}"); + + if (_targets.HandlePointerAction(action, activation)) + return; + + if (action is InputAction.ScrollUp or InputAction.ScrollDown) + { + if (activation == ActivationType.Press) + _targets.HandleScroll(action); + return; + } + + if (_targets.HandleCombatAction(action, activation)) + return; + + if (activation is not ActivationType.Press + and not ActivationType.DoubleClick) + { + return; + } + + if (_targets.HandleRetainedUiAction(action)) + return; + if (_targets.HandleSelectionAction(action)) + return; + if (_targets.HandlePressedMovementAction(action)) + return; + + _targets.HandleCommand(action); + } + + private void SetCombatScope(CombatMode mode) => + _actions.SetCombatScope(mode switch + { + CombatMode.Melee => InputScope.MeleeCombat, + CombatMode.Missile => InputScope.MissileCombat, + CombatMode.Magic => InputScope.MagicCombat, + _ => null, + }); + + private ResourceShutdownTransaction EnsureDetachTransaction() => + _detach ??= new ResourceShutdownTransaction( + new ResourceShutdownStage("gameplay action callbacks", + [ + new("combat mode", () => Remove(1)), + new("dispatcher fired", () => Remove(0)), + ])); + + private void Remove(int index) + { + if (!_attached[index]) + return; + + switch (index) + { + case 0: + _actions.RemoveFired(_fired); + break; + case 1: + _combat.RemoveChanged(_combatModeChanged); + break; + default: + throw new ArgumentOutOfRangeException(nameof(index)); + } + + _attached[index] = false; + } +} diff --git a/src/AcDream.App/Input/GameplayInputCommandController.cs b/src/AcDream.App/Input/GameplayInputCommandController.cs new file mode 100644 index 00000000..457bbf55 --- /dev/null +++ b/src/AcDream.App/Input/GameplayInputCommandController.cs @@ -0,0 +1,210 @@ +using AcDream.App.Combat; +using AcDream.App.Diagnostics; +using AcDream.App.Rendering; +using AcDream.App.UI; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Input; + +internal interface IRetainedGameplayWindowCommands +{ + void ToggleInventory(); +} + +internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime) + : IRetainedGameplayWindowCommands +{ + private readonly RetailUiRuntime? _runtime = runtime; + + public void ToggleInventory() => + _runtime?.ToggleWindow(WindowNames.Inventory); +} + +internal interface IDevToolsGameplayCommands +{ + void ToggleDebugPanel(); + + void FocusChatInput(); + + void ToggleSettingsPanel(); +} + +internal sealed class DevToolsGameplayCommands(DevToolsFramePresenter? presenter) + : IDevToolsGameplayCommands +{ + private readonly DevToolsFramePresenter? _presenter = presenter; + + public void ToggleDebugPanel() => _presenter?.ToggleDebugPanel(); + + public void FocusChatInput() => _presenter?.FocusChatInput(); + + public void ToggleSettingsPanel() => _presenter?.ToggleSettingsPanel(); +} + +internal interface IPlayerModeGameplayCommands +{ + bool IsPlayerMode { get; } + + void ToggleFlyOrChase(); + + void TogglePlayerMode(); + + void ExitPlayerMode(); +} + +internal sealed class PlayerModeGameplayCommands( + ILocalPlayerModeSource mode, + PlayerModeController controller) : IPlayerModeGameplayCommands +{ + private readonly ILocalPlayerModeSource _mode = mode + ?? throw new ArgumentNullException(nameof(mode)); + private readonly PlayerModeController _controller = controller + ?? throw new ArgumentNullException(nameof(controller)); + + public bool IsPlayerMode => _mode.IsPlayerMode; + + public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase(); + + public void TogglePlayerMode() => _controller.Toggle(); + + public void ExitPlayerMode() => _controller.Exit(); +} + +internal interface IItemTargetModeCommands +{ + bool IsAnyTargetModeActive { get; } + + void CancelTargetMode(); +} + +internal sealed class ItemTargetModeCommands(ItemInteractionController items) + : IItemTargetModeCommands +{ + private readonly ItemInteractionController _items = items + ?? throw new ArgumentNullException(nameof(items)); + + public bool IsAnyTargetModeActive => _items.IsAnyTargetModeActive; + + public void CancelTargetMode() => _items.CancelTargetMode(); +} + +internal interface IGameplayCameraModeCommands +{ + bool IsFlyMode { get; } + + void ExitFlyMode(); +} + +internal sealed class GameplayCameraModeCommands(CameraController camera) + : IGameplayCameraModeCommands +{ + private readonly CameraController _camera = camera + ?? throw new ArgumentNullException(nameof(camera)); + + public bool IsFlyMode => _camera.IsFlyMode; + + public void ExitFlyMode() => _camera.ToggleFly(); +} + +internal interface IGameplayWindowCommands +{ + void Close(); +} + +internal sealed class GameplayWindowCommands(Action close) : IGameplayWindowCommands +{ + private readonly Action _close = close + ?? throw new ArgumentNullException(nameof(close)); + + public void Close() => _close(); +} + +internal interface IGameplayInputCommandTarget +{ + bool Handle(InputAction action); +} + +/// +/// Owns the final command tier of the frozen gameplay-action priority graph. +/// It coordinates canonical owners but stores no UI, camera, player, combat, +/// item-target, or diagnostic state of its own. +/// +internal sealed class GameplayInputCommandController : IGameplayInputCommandTarget +{ + private readonly IRetainedGameplayWindowCommands _retained; + private readonly IDevToolsGameplayCommands _devTools; + private readonly IRuntimeDiagnosticCommands _diagnostics; + private readonly IPlayerModeGameplayCommands _playerMode; + private readonly IItemTargetModeCommands _targetMode; + private readonly IGameplayCameraModeCommands _camera; + private readonly ILiveCombatModeCommand _combat; + private readonly IGameplayWindowCommands _window; + + public GameplayInputCommandController( + IRetainedGameplayWindowCommands retained, + IDevToolsGameplayCommands devTools, + IRuntimeDiagnosticCommands diagnostics, + IPlayerModeGameplayCommands playerMode, + IItemTargetModeCommands targetMode, + IGameplayCameraModeCommands camera, + ILiveCombatModeCommand combat, + IGameplayWindowCommands window) + { + _retained = retained ?? throw new ArgumentNullException(nameof(retained)); + _devTools = devTools ?? throw new ArgumentNullException(nameof(devTools)); + _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); + _targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _combat = combat ?? throw new ArgumentNullException(nameof(combat)); + _window = window ?? throw new ArgumentNullException(nameof(window)); + } + + public bool Handle(InputAction action) + { + if (_diagnostics.Handle(action)) + return true; + + switch (action) + { + case InputAction.ToggleInventoryPanel: + _retained.ToggleInventory(); + return true; + case InputAction.AcdreamToggleDebugPanel: + _devTools.ToggleDebugPanel(); + return true; + case InputAction.AcdreamToggleFlyMode: + _playerMode.ToggleFlyOrChase(); + return true; + case InputAction.AcdreamTogglePlayerMode: + _playerMode.TogglePlayerMode(); + return true; + case InputAction.ToggleChatEntry: + _devTools.FocusChatInput(); + return true; + case InputAction.ToggleOptionsPanel: + _devTools.ToggleSettingsPanel(); + return true; + case InputAction.CombatToggleCombat: + _combat.Toggle(); + return true; + case InputAction.EscapeKey: + HandleEscape(); + return true; + default: + return false; + } + } + + private void HandleEscape() + { + if (_targetMode.IsAnyTargetModeActive) + _targetMode.CancelTargetMode(); + else if (_camera.IsFlyMode) + _camera.ExitFlyMode(); + else if (_playerMode.IsPlayerMode) + _playerMode.ExitPlayerMode(); + else + _window.Close(); + } +} diff --git a/src/AcDream.App/Input/RetainedUiGameplayBinding.cs b/src/AcDream.App/Input/RetainedUiGameplayBinding.cs new file mode 100644 index 00000000..f4e0907d --- /dev/null +++ b/src/AcDream.App/Input/RetainedUiGameplayBinding.cs @@ -0,0 +1,139 @@ +using AcDream.App.Rendering; +using AcDream.App.UI; + +namespace AcDream.App.Input; + +internal interface IRetainedUiDragReleaseSurface +{ + void AddReleasedOutside(Action callback); + + void RemoveReleasedOutside(Action callback); +} + +internal sealed class UiRootDragReleaseSurface(UiRoot root) + : IRetainedUiDragReleaseSurface +{ + private readonly UiRoot _root = root + ?? throw new ArgumentNullException(nameof(root)); + + public void AddReleasedOutside(Action callback) => + _root.DragReleasedOutsideUi += callback; + + public void RemoveReleasedOutside(Action callback) => + _root.DragReleasedOutsideUi -= callback; +} + +/// +/// Owns the retained-root gameplay edge used when an item drag is released in +/// the world. Logical deactivation precedes live-session teardown and physical +/// removal remains retriable if a custom event accessor fails. +/// +internal sealed class RetainedUiGameplayBinding : IDisposable +{ + private readonly IRetainedUiDragReleaseSurface _surface; + private readonly Action _placeDraggedItem; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _releasedOutside; + private ResourceShutdownTransaction? _detach; + private bool _attached; + private bool _attachStarted; + private int _disposeRequested; + private int _active; + + public RetainedUiGameplayBinding( + IRetainedUiDragReleaseSurface surface, + Action placeDraggedItem, + HostQuiescenceGate quiescence) + { + _surface = surface ?? throw new ArgumentNullException(nameof(surface)); + _placeDraggedItem = placeDraggedItem + ?? throw new ArgumentNullException(nameof(placeDraggedItem)); + _quiescence = quiescence + ?? throw new ArgumentNullException(nameof(quiescence)); + _releasedOutside = OnReleasedOutside; + } + + public static RetainedUiGameplayBinding Create( + UiRoot root, + Action placeDraggedItem, + HostQuiescenceGate quiescence) => + new(new UiRootDragReleaseSurface(root), placeDraggedItem, quiescence); + + public bool IsDisposalComplete => !_attached; + + public void Attach() + { + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + if (_attachStarted) + { + throw new InvalidOperationException( + "Retained gameplay attachment has already started."); + } + + _attachStarted = true; + try + { + // Publish before the custom accessor: it may add and then throw. + _attached = true; + _surface.AddReleasedOutside(_releasedOutside); + Volatile.Write(ref _active, 1); + } + catch (Exception attachError) + { + Deactivate(); + try + { + EnsureDetachTransaction().CompleteOrThrow(); + } + catch (Exception rollbackError) + { + throw new AggregateException( + "Retained gameplay registration and rollback both failed.", + new InvalidOperationException( + "Retained gameplay registration failed.", attachError), + rollbackError); + } + + throw new InvalidOperationException( + "Retained gameplay registration failed and was rolled back.", + attachError); + } + } + + public void Deactivate() => Interlocked.Exchange(ref _active, 0); + + public void Dispose() + { + Interlocked.Exchange(ref _disposeRequested, 1); + Deactivate(); + EnsureDetachTransaction().CompleteOrThrow(); + } + + private void OnReleasedOutside(object payload, int x, int y) => + _quiescence.Invoke(() => + { + if (Volatile.Read(ref _active) != 0 + && payload is ItemDragPayload item) + { + _placeDraggedItem(item, x, y); + } + }); + + private ResourceShutdownTransaction EnsureDetachTransaction() => + _detach ??= new ResourceShutdownTransaction( + new ResourceShutdownStage("retained gameplay callbacks", + [ + new("drag released outside UI", Remove), + ])); + + private void Remove() + { + if (!_attached) + return; + + _surface.RemoveReleasedOutside(_releasedOutside); + _attached = false; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 17e01e66..e467ca3c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -447,12 +447,9 @@ public sealed class GameWindow : IDisposable // the bool here. private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry; - // Phase K.1b — single input path. Every keyboard/mouse-button reaction - // flows through InputDispatcher.Fired (see OnInputAction below) or - // IsActionHeld (per-frame polling for movement). The legacy direct - // kb.KeyDown switch + mouse.MouseDown/MouseUp handlers are GONE; only - // mouse.MouseMove survives as a direct handler because mouse-delta is - // axis input, not chord input. + // Phase K.1b / Slice 8 F — one semantic input path. Transitional actions + // flow through GameplayInputActionRouter; held movement is polled through + // InputDispatcher. Raw axis motion belongs to CameraPointerInputController. private AcDream.App.Input.SilkKeyboardSource? _kbSource; private AcDream.App.Input.SilkMouseSource? _mouseSource; private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher; @@ -462,6 +459,12 @@ public sealed class GameWindow : IDisposable private readonly AcDream.App.Input.DispatcherCameraInputSource _cameraInput = new(); private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor; private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame; + private AcDream.App.Input.GameplayInputActionRouter? _gameplayInputActions; + private AcDream.App.Input.RetainedUiGameplayBinding? _retainedUiGameplayBinding; + private readonly AcDream.App.Diagnostics.RuntimeDiagnosticCommandSlot + _runtimeDiagnosticCommands = new(); + private readonly AcDream.App.Combat.LiveCombatModeCommandSlot + _liveCombatModeCommands = new(); // K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json, // falling back to the retail-faithful defaults if the file is missing // or corrupt. This is THE single source of truth for the keymap at @@ -693,10 +696,6 @@ public sealed class GameWindow : IDisposable _inputDispatcher.Attach(); _movementInput.Bind(_inputDispatcher); _cameraInput.Bind(_inputDispatcher); - _inputDispatcher.Fired += OnInputAction; - Combat.CombatModeChanged += SetInputCombatScope; - SetInputCombatScope(Combat.CurrentMode); - } _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); @@ -960,9 +959,12 @@ public sealed class GameWindow : IDisposable getFrameMs: () => (float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7), combat: Combat); - _debugVm.CycleTimeOfDay = CycleTimeOfDay; - _debugVm.CycleWeather = CycleWeather; - _debugVm.ToggleCollisionWires = ToggleCollisionWires; + _debugVm.CycleTimeOfDay = + _runtimeDiagnosticCommands.CycleTimeOfDay; + _debugVm.CycleWeather = + _runtimeDiagnosticCommands.CycleWeather; + _debugVm.ToggleCollisionWires = + _runtimeDiagnosticCommands.ToggleCollisionWireframes; // Phase K.2: free-fly toggle button — same routine the // legacy F-key alias hits. Cancels the one-shot // auto-entry if the user opts out of player mode before @@ -1406,8 +1408,6 @@ public sealed class GameWindow : IDisposable displayMessage: text => Chat.OnSystemMessage(text, 0x1Au), incrementBusy: () => _itemInteractionController.IncrementBusyCount(), canSend: () => _liveSessionHost?.IsInWorld == true); - _uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside; - // Feed Silk input to the UiRoot tree so windows drag / close / select. // UiRoot consumes UI events; the game InputDispatcher (subscribed to the // same devices) is gated off via WantCaptureMouse/Keyboard above when the @@ -1577,7 +1577,7 @@ public sealed class GameWindow : IDisposable UseItemByGuid, Combat, ItemMana, - ToggleLiveCombatMode, + _liveCombatModeCommands.Toggle, _itemInteractionController, entry => LiveSession?.SendAddShortcut(entry), index => LiveSession?.SendRemoveShortcut(index), @@ -2007,6 +2007,17 @@ public sealed class GameWindow : IDisposable text => _debugVm?.AddToast(text), _playerApproachCompletions); } + if (_uiHost is { } retainedHost + && _selectionInteractions is { } selectionInteractions) + { + _retainedUiGameplayBinding = + AcDream.App.Input.RetainedUiGameplayBinding.Create( + retainedHost.Root, + (item, x, y) => + selectionInteractions.PlaceDraggedItem(item, x, y), + _hostQuiescence); + _retainedUiGameplayBinding.Attach(); + } // A.5 T22.5: apply A2C gate from quality preset. _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; @@ -2813,6 +2824,81 @@ public sealed class GameWindow : IDisposable _playerModeAutoEntry), cameraFrame); _liveSessionHost = CreateLiveSessionHost(); + + AcDream.UI.Abstractions.Panels.Debug.DebugVM? debugVm = _debugVm; + Action? debugToast = debugVm is null + ? null + : message => debugVm.AddToast(message); + AcDream.Core.Chat.ChatLog chat = Chat; + var combatCommand = + new AcDream.App.Combat.LiveCombatModeCommandController( + new AcDream.App.Combat.LiveSessionCombatModeAuthority( + _liveSessionHost), + new AcDream.App.Combat.LocalPlayerCombatEquipmentSource( + Objects, + _localPlayerIdentity), + Combat, + new AcDream.App.Combat.ItemInteractionCombatModeIntentSink( + _itemInteractionController!), + Console.WriteLine, + debugToast, + text => chat.OnSystemMessage(text, 0x1Au)); + _liveCombatModeCommands.Bind(combatCommand); + + var nearbyDiagnostics = + new AcDream.App.Diagnostics.NearbyWorldDiagnosticDumper( + new AcDream.App.Diagnostics.RuntimeNearbyWorldDiagnosticSource( + _localPlayerMode, + _playerControllerSlot, + _cameraController!, + _liveWorldOrigin, + _worldState, + _physicsEngine), + Console.WriteLine); + var runtimeDiagnostics = + new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController( + _worldEnvironment, + _worldSceneDebugState, + _cameraPointerInput, + nearbyDiagnostics, + debugToast); + _runtimeDiagnosticCommands.Bind(runtimeDiagnostics); + + if (_inputDispatcher is { } dispatcher) + { + var pointer = _cameraPointerInput + ?? throw new InvalidOperationException( + "Gameplay action routing requires the composed pointer owner."); + var commands = new AcDream.App.Input.GameplayInputCommandController( + new AcDream.App.Input.RetainedGameplayWindowCommands( + _retailUiRuntime), + new AcDream.App.Input.DevToolsGameplayCommands( + _devToolsFramePresenter), + runtimeDiagnostics, + new AcDream.App.Input.PlayerModeGameplayCommands( + _localPlayerMode, + _playerModeController), + new AcDream.App.Input.ItemTargetModeCommands( + _itemInteractionController!), + new AcDream.App.Input.GameplayCameraModeCommands( + _cameraController!), + combatCommand, + new AcDream.App.Input.GameplayWindowCommands(_window!.Close)); + var targets = new AcDream.App.Input.RuntimeGameplayInputPriorityTargets( + _gameplayInputFrame!, + pointer, + _retailUiRuntime, + _selectionInteractions, + commands); + _gameplayInputActions = AcDream.App.Input.GameplayInputActionRouter.Create( + dispatcher, + Combat, + targets, + _hostQuiescence, + Console.WriteLine); + _gameplayInputActions.Attach(); + } + AcDream.App.Net.LiveSessionStartResult liveStart = _liveSessionHost.Start(_options); switch (liveStart.Status) @@ -3241,36 +3327,6 @@ public sealed class GameWindow : IDisposable private bool GetDebugPlayerOnGround() => _playerMode && _playerController is not null && !_playerController.IsAirborne; - /// - /// Cycle the time-of-day debug override. Same body as the old F7 - /// keybind handler; called by both the keybind AND the DebugPanel - /// "Cycle time of day" button via DebugVM.CycleTimeOfDay. - /// - private void CycleTimeOfDay() - { - string message = _worldEnvironment.CycleTimeOfDay(); - _debugVm?.AddToast(message); - } - - /// - /// Cycle the weather kind. Same body as the old F10 keybind handler. - /// - private void CycleWeather() - { - string message = _worldEnvironment.CycleWeather(); - _debugVm?.AddToast(message); - } - - /// - /// Toggle the collision-wires debug renderer. Same body as the old - /// F2 keybind handler. - /// - private void ToggleCollisionWires() - { - bool visible = _worldSceneDebugState.ToggleCollisionWireframes(); - _debugVm?.AddToast($"Collision wireframes {(visible ? "ON" : "OFF")}"); - } - // Phase K.3 settings state remains runtime-owned; the presenter owns the // optional SettingsPanel and its visibility/input operations. private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm; @@ -3282,11 +3338,6 @@ public sealed class GameWindow : IDisposable private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore; private string _activeToonKey = "default"; - private bool ToggleRetailWindow(string name) - { - return _retailUiRuntime?.ToggleWindow(name) ?? false; - } - private void SyncToolbarWindowButtons() { _retailUiRuntime?.SyncToolbarWindowButtons(); @@ -3369,12 +3420,6 @@ public sealed class GameWindow : IDisposable } } - private void OnUiDragReleasedOutside(object payload, int x, int y) - { - if (payload is AcDream.App.UI.ItemDragPayload itemPayload) - _selectionInteractions?.PlaceDraggedItem(itemPayload, x, y); - } - // L.0 follow-up: persisted-settings cache populated by // LoadAndApplyPersistedSettings (runs unconditionally in OnLoad, // not gated on DevToolsEnabled). The Settings PANEL construction @@ -3563,202 +3608,6 @@ public sealed class GameWindow : IDisposable && width > 0 && height > 0; } - // ── K.1b: dispatcher action handler ────────────────────────────────── - // - // SINGLE place where every game-side keyboard/mouse-button reaction - // lives. The legacy direct kb.KeyDown switch + mouse.MouseDown/MouseUp - // handlers are gone; everything now flows through InputDispatcher.Fired - // → here. New behaviors register a new InputAction in the enum + a - // case in this switch + a binding in KeyBindings. - - /// - /// K.1b — multicast subscriber on . - /// Handles every game-side reaction to a keyboard/mouse-button chord. - /// Per-frame held-state polling (movement WASD/Shift/Space) lives in - /// via ; - /// this method handles transitional Press/Release events only. - /// - private void SetInputCombatScope(AcDream.Core.Combat.CombatMode mode) - { - _inputDispatcher?.SetCombatScope(mode switch - { - AcDream.Core.Combat.CombatMode.Melee => AcDream.UI.Abstractions.Input.InputScope.MeleeCombat, - AcDream.Core.Combat.CombatMode.Missile => AcDream.UI.Abstractions.Input.InputScope.MissileCombat, - AcDream.Core.Combat.CombatMode.Magic => AcDream.UI.Abstractions.Input.InputScope.MagicCombat, - _ => null, - }); - } - - private void OnInputAction( - AcDream.UI.Abstractions.Input.InputAction action, - AcDream.UI.Abstractions.Input.ActivationType activation) - { - // Diagnostic — kept from K.1a; helpful for K.1c verification. - Console.WriteLine($"[input] {action} {activation}"); - - // RMB orbit and instant mouse-look remain the first accepted input - // transition. Their state is owned by the gameplay/pointer owners. - if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true) - return; - - // Phase K.2 — MMB-hold instant mouse-look. Press hides the - // cursor + activates yaw drive; release restores. WantCapture - // edge handling lives in OnUpdate; only Press needs to read it - // for the initial gate (defense in depth — the dispatcher - // already filters on WantCaptureMouse in OnMouseDown). - // ScrollUp / ScrollDown — emit by InputDispatcher.OnScroll on every - // wheel tick. Press is the only activation type for wheel. - if (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp - || action == AcDream.UI.Abstractions.Input.InputAction.ScrollDown) - { - if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press) return; - HandleScrollAction(action); - return; - } - - // ACCmdInterp::HandleNewForwardMovement (0x0058B1F0) aborts automatic - // combat before the movement command enters CommandInterpreter. This - // notification does not consume the input; the movement owner below - // still receives it normally. - // Retail attack actions consume their full transition stream: key-down - // starts the power build and key-up commits it. Handle them before the - // generic Press-only gate below. - if (_gameplayInputFrame?.HandleCombatAction(action, activation) == true) - return; - - // Every other action fires on Press only (no Release / Hold side- - // effects in the K.1b set). Filter out non-Press activations early - // so subscribers that have Release-mode bindings don't accidentally - // re-fire. B.4b exception: DoubleClick must pass through so - // SelectDblLeft / SelectDblRight / SelectDblMid can reach the switch. - if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press - && activation != AcDream.UI.Abstractions.Input.ActivationType.DoubleClick) return; - - // Wave 4.1: one retained-UI delegation seam. The runtime routes only - // toolbar-owned semantic actions; every other action continues through - // the game switch below. Keyboard capture is already enforced at the - // InputDispatcher source, matching retail's stack-entry focus gate. - if (_retailUiRuntime?.HandleInputAction(action) == true) - return; - - if (_selectionInteractions?.HandleInputAction(action) == true) - return; - - // K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press - // Q to start, press Q again to stop. Pressing Backup / Stop / - // StrafeLeft / StrafeRight while autorun is active also cancels it - // — retail-faithful: any deliberate movement input wins. (Pressing - // Forward AGAIN does NOT cancel — retail's autorun stays active - // even when you press W; the two stack.) - if (_gameplayInputFrame?.HandlePressedMovementAction(action) == true) - return; - - switch (action) - { - case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel: - // Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it - // does not fire while the chat input holds focus. Null _uiHost = - // retail UI off (ACDREAM_RETAIL_UI unset) → no-op. - ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel: - _devToolsFramePresenter?.ToggleDebugPanel(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleCollisionWires: - ToggleCollisionWires(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamDumpNearby: - DumpPlayerAndNearbyEntities(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleTimeOfDay: - CycleTimeOfDay(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityDown: - AdjustActiveSensitivity(1f / 1.2f); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityUp: - AdjustActiveSensitivity(1.2f); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleWeather: - CycleWeather(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleFlyMode: - // K-fix3 (2026-04-26): proper round-trip when player has - // an active chase camera. ToggleFly() only swaps - // Fly↔Orbit, so a user who flew out of player mode used - // to land in Holtburg-orbit on toggle-back. With a chase - // camera available, prefer Fly→Chase / Chase→Fly so the - // user round-trips back to the same player view. - _playerModeController?.ToggleFlyOrChase(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode: - _playerModeController?.Toggle(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry: - // K.2: Tab focuses the chat input. ChatPanel.FocusInput() - // sets a one-shot flag that emits SetKeyboardFocusHere on - // the next render. No-op when the devtools presenter is absent - // (offline / non-devtools build) — the dispatcher still - // logs the action via the [input] diagnostic above so the - // path is observable in either case. - _devToolsFramePresenter?.FocusChatInput(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel: - // K.3: F11 toggles the Settings panel. Null-safe vs. - // devtools-off / panel-not-registered — the [input] log - // line above still records the press regardless. - _devToolsFramePresenter?.ToggleSettingsPanel(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat: - ToggleLiveCombatMode(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.EscapeKey: - if (_itemInteractionController?.IsAnyTargetModeActive == true) - _itemInteractionController.CancelTargetMode(); - else if (_cameraController?.IsFlyMode == true) - _cameraController.ToggleFly(); // exit fly, release cursor - else if (_playerMode) - _playerModeController?.Exit(); - else - _window!.Close(); - break; - } - } - - private void ToggleLiveCombatMode() - { - AcDream.Core.Net.WorldSession? session = LiveSession; - if (_liveSessionHost?.IsInWorld != true || session is null) - return; - - IReadOnlyList orderedEquipment = - Objects.GetEquippedBy(_playerServerGuid); - var defaultMode = AcDream.Core.Combat.CombatInputPlanner - .GetDefaultCombatMode(orderedEquipment); - var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode( - Combat.CurrentMode, - defaultMode); - _itemInteractionController?.NotifyExplicitCombatModeRequest(); - session.SendChangeCombatMode(nextMode); - Combat.SetCombatMode(nextMode); - string text = $"Combat mode {nextMode}"; - Console.WriteLine($"combat: {text}"); - _debugVm?.AddToast(text); - } - /// /// Item-target-mode world pick at the current cursor. The renderer supplies the /// exact visible CPhysicsPart equivalents and RetailWorldPicker performs @@ -3843,87 +3692,6 @@ public sealed class GameWindow : IDisposable return unhydratable; } - /// - /// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies - /// the currently-active mode's sensitivity (chase / fly / orbit) by the - /// given factor and clamps to [0.005, 3.0]. - /// - private void AdjustActiveSensitivity(float factor) - { - if (_cameraPointerInput is not { } pointer) - return; - string message = pointer.AdjustSensitivity(factor); - _debugVm?.AddToast(message); - } - - /// - /// K.1b: F3 dump handler extracted into a method. Same body as the - /// previous in-line F3 branch — prints the player's position + - /// nearby visible entities + nearby shadow physics objects. - /// - private void DumpPlayerAndNearbyEntities() - { - System.Numerics.Vector3 pos; - if (_playerMode && _playerController is not null) - pos = _playerController.Position; - else - { - System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv); - pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43); - } - int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f); - int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f); - Console.WriteLine( - $"=== F3 DEBUG DUMP ===\n" + - $" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" + - $" landblock=0x{(uint)((lbX << 24) | (lbY << 16) | 0xFFFF):X8} local=({pos.X - (lbX - _liveCenterX) * 192f:F2},{pos.Y - (lbY - _liveCenterY) * 192f:F2})\n" + - $" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}"); - - var visibleNearby = new List(); - foreach (var e in _worldState.Entities) - { - float dx = e.Position.X - pos.X; - float dy = e.Position.Y - pos.Y; - if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e); - } - Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}"); - foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12)) - { - float d = (e.Position - pos).Length(); - Console.WriteLine( - $" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " + - $"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}"); - } - - var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>(); - foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug()) - { - float dx = o.Position.X - pos.X; - float dy = o.Position.Y - pos.Y; - float d = MathF.Sqrt(dx * dx + dy * dy); - if (d < 15f) sorted.Add((o, d)); - } - sorted.Sort((a, b) => a.dist.CompareTo(b.dist)); - Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}"); - foreach (var (o, d) in sorted.Take(12)) - { - Console.WriteLine( - $" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " + - $"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}"); - } - } - - /// - /// K.1b: ScrollUp / ScrollDown action handler. Adjusts whichever - /// camera distance is current — chase camera distance in player mode, - /// orbit camera distance otherwise. Fly mode ignores scroll. Magnitude - /// is fixed-step (the previous proportional scroll.Y was lost when we - /// moved scroll into the dispatcher, but the discrete step matches - /// retail wheel feel). - /// - private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action) - => _cameraPointerInput?.HandleScroll(action); - private void OnClosing() { _hostQuiescence.StopAccepting(); @@ -3956,6 +3724,10 @@ public sealed class GameWindow : IDisposable // are already inert. new ResourceShutdownStage("input callback deactivation", [ + new("combat command slot", _liveCombatModeCommands.Deactivate), + new("diagnostic command slot", _runtimeDiagnosticCommands.Deactivate), + new("retained gameplay", () => _retainedUiGameplayBinding?.Deactivate()), + new("gameplay actions", () => _gameplayInputActions?.Deactivate()), new("camera pointer", () => _cameraPointerInput?.Deactivate()), new("dispatcher", () => _inputDispatcher?.Deactivate()), new("mouse source", () => _mouseSource?.Deactivate()), @@ -3991,6 +3763,28 @@ public sealed class GameWindow : IDisposable ]), new ResourceShutdownStage("input callback detach", [ + new("retained gameplay", () => + { + AcDream.App.Input.RetainedUiGameplayBinding? binding = + _retainedUiGameplayBinding; + if (binding is null) return; + binding.Dispose(); + if (!binding.IsDisposalComplete) + throw new InvalidOperationException( + "Retained gameplay callback removal remains pending."); + _retainedUiGameplayBinding = null; + }), + new("gameplay actions", () => + { + AcDream.App.Input.GameplayInputActionRouter? actions = + _gameplayInputActions; + if (actions is null) return; + actions.Dispose(); + if (!actions.IsDisposalComplete) + throw new InvalidOperationException( + "Gameplay action callback removal remains pending."); + _gameplayInputActions = null; + }), new("retained UI input", () => _uiHost?.DeactivateInput()), new("developer tools input", () => _devToolsInputContext?.Dispose()), new("camera pointer", () => @@ -4003,14 +3797,11 @@ public sealed class GameWindow : IDisposable throw new InvalidOperationException( "Camera pointer callback removal remains pending."); }), - new("combat input subscription", () => - Combat.CombatModeChanged -= SetInputCombatScope), new("dispatcher", () => { AcDream.UI.Abstractions.Input.InputDispatcher? dispatcher = _inputDispatcher; if (dispatcher is null) return; - dispatcher.Fired -= OnInputAction; _movementInput.Unbind(dispatcher); _cameraInput.Unbind(dispatcher); dispatcher.Dispose(); diff --git a/src/AcDream.Core/Combat/CombatModel.cs b/src/AcDream.Core/Combat/CombatModel.cs index b0221418..1e752102 100644 --- a/src/AcDream.Core/Combat/CombatModel.cs +++ b/src/AcDream.Core/Combat/CombatModel.cs @@ -36,6 +36,14 @@ public enum CombatAttackAction High, } +/// +/// Retail's default-mode lookup returns the selected mode and, on the one +/// rejected held-item branch, the object used to format its user notice. +/// +public readonly record struct DefaultCombatModeDecision( + CombatMode Mode, + ClientObject? IncompatibleHeldItem); + /// /// Retail input-facing combat decisions. The heavyweight parts of the combat /// system remain server authoritative; this helper only maps UI intent to the @@ -92,6 +100,14 @@ public static class CombatInputPlanner /// object intersecting the requested location mask. /// public static CombatMode GetDefaultCombatMode( + IReadOnlyList orderedPlayerContents) => + GetDefaultCombatModeDecision(orderedPlayerContents).Mode; + + /// + /// Complete result of retail GetDefaultCombatMode, including the + /// incompatible Held object used by its exact failure notice. + /// + public static DefaultCombatModeDecision GetDefaultCombatModeDecision( IReadOnlyList orderedPlayerContents) { ArgumentNullException.ThrowIfNull(orderedPlayerContents); @@ -102,20 +118,22 @@ public static class CombatInputPlanner { // Retail COMBAT_USE_MISSILE = 2. Every other combat-use value in // this primary weapon slot selects melee. - return weapon.CombatUse == 2 - ? CombatMode.Missile - : CombatMode.Melee; + return new( + weapon.CombatUse == 2 + ? CombatMode.Missile + : CombatMode.Melee, + IncompatibleHeldItem: null); } ClientObject? held = GetObjectAtLocation( orderedPlayerContents, EquipMask.Held); if (held is null) - return CombatMode.Melee; + return new(CombatMode.Melee, IncompatibleHeldItem: null); // The decomp's byte-1 sign test is ITEM_TYPE bit 15 (Caster). return (held.Type & ItemType.Caster) != 0 - ? CombatMode.Magic - : CombatMode.NonCombat; + ? new(CombatMode.Magic, IncompatibleHeldItem: null) + : new(CombatMode.NonCombat, held); } private static ClientObject? GetObjectAtLocation( @@ -136,12 +154,13 @@ public static class CombatInputPlanner CombatMode currentMode, CombatMode defaultCombatMode = CombatMode.Melee) { - if ((currentMode & CombatMode.CombatCombat) != 0) + // ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0 compares the + // current value directly with NonCombat. It does not coerce a + // rejected/default NonCombat result to unarmed Melee. + if (currentMode != CombatMode.NonCombat) return CombatMode.NonCombat; - return (defaultCombatMode & CombatMode.CombatCombat) != 0 - ? defaultCombatMode - : CombatMode.Melee; + return defaultCombatMode; } public static bool SupportsTargetedAttack(CombatMode mode) => diff --git a/tests/AcDream.App.Tests/Combat/LiveCombatModeCommandControllerTests.cs b/tests/AcDream.App.Tests/Combat/LiveCombatModeCommandControllerTests.cs new file mode 100644 index 00000000..39136863 --- /dev/null +++ b/tests/AcDream.App.Tests/Combat/LiveCombatModeCommandControllerTests.cs @@ -0,0 +1,190 @@ +using AcDream.App.Combat; +using AcDream.Core.Combat; +using AcDream.Core.Items; + +namespace AcDream.App.Tests.Combat; + +public sealed class LiveCombatModeCommandControllerTests +{ + [Fact] + public void OutsideWorld_IsCompleteNoOpBeforeEquipmentQuery() + { + var harness = new Harness { Authority = { IsInWorldValue = false } }; + + harness.Controller.Toggle(); + + Assert.Equal(0, harness.Equipment.QueryCount); + Assert.Empty(harness.Calls); + Assert.Equal(CombatMode.NonCombat, harness.Combat.CurrentMode); + } + + [Fact] + public void PeaceWithBow_PreservesIntentSendLocalStateFeedbackOrder() + { + var harness = new Harness(); + harness.Equipment.Items.Add(new ClientObject + { + ObjectId = 1, + CurrentlyEquippedLocation = EquipMask.MissileWeapon, + Type = ItemType.MissileWeapon, + CombatUse = 2, + }); + harness.Combat.CombatModeChanged += mode => + harness.Calls.Add($"state:{mode}"); + + harness.Controller.Toggle(); + + Assert.Equal(1, harness.Equipment.QueryCount); + Assert.Equal(CombatMode.Missile, harness.Combat.CurrentMode); + Assert.Equal( + [ + "intent", + "send:Missile", + "state:Missile", + "log:combat: Combat mode Missile", + "toast:Combat mode Missile", + ], + harness.Calls); + } + + [Fact] + public void ActiveCombat_TogglesToPeaceRegardlessOfEquipmentDefault() + { + var harness = new Harness(); + harness.Combat.SetCombatMode(CombatMode.Magic); + + harness.Controller.Toggle(); + + Assert.Equal(0, harness.Equipment.QueryCount); + Assert.Equal([CombatMode.NonCombat], harness.Authority.Requests); + Assert.Equal(CombatMode.NonCombat, harness.Combat.CurrentMode); + } + + [Fact] + public void PeaceWithIncompatibleHeldItem_PrintsRetailNoticeWithoutSending() + { + var harness = new Harness(); + harness.Equipment.Items.Add(new ClientObject + { + ObjectId = 1, + Name = "Torch", + CurrentlyEquippedLocation = EquipMask.Held, + Type = ItemType.Misc, + }); + + harness.Controller.Toggle(); + + Assert.Empty(harness.Authority.Requests); + Assert.Equal(CombatMode.NonCombat, harness.Combat.CurrentMode); + Assert.Equal( + [ + "intent", + "log:combat: You can't enter combat mode while wielding the Torch", + "system:You can't enter combat mode while wielding the Torch", + ], + harness.Calls); + } + + [Fact] + public void TransportFailure_LeavesLocalModeAndFeedbackUnpublished() + { + var harness = new Harness { Authority = { ThrowOnSend = true } }; + + Assert.Throws(harness.Controller.Toggle); + + Assert.Equal(["intent", "send:Melee"], harness.Calls); + Assert.Equal(CombatMode.NonCombat, harness.Combat.CurrentMode); + } + + [Fact] + public void DeferredSlot_IsNoOpBeforeBindAndForwardsOneOwnerAfterBind() + { + var slot = new LiveCombatModeCommandSlot(); + var target = new FakeCommand(); + + slot.Toggle(); + slot.Bind(target); + slot.Toggle(); + slot.Bind(target); + + Assert.Equal(1, target.ToggleCount); + Assert.Throws( + () => slot.Bind(new FakeCommand())); + slot.Unbind(target); + slot.Toggle(); + Assert.Equal(1, target.ToggleCount); + + slot.Bind(target); + Action copied = slot.Toggle; + slot.Deactivate(); + copied(); + Assert.Equal(1, target.ToggleCount); + Assert.Throws(() => slot.Bind(target)); + } + + private sealed class Harness + { + public Harness() + { + Authority = new FakeAuthority(Calls); + Equipment = new FakeEquipment(); + Intent = new FakeIntent(Calls); + Controller = new LiveCombatModeCommandController( + Authority, + Equipment, + Combat, + Intent, + line => Calls.Add($"log:{line}"), + line => Calls.Add($"toast:{line}"), + line => Calls.Add($"system:{line}")); + } + + public List Calls { get; } = []; + public FakeAuthority Authority { get; } + public FakeEquipment Equipment { get; } + public CombatState Combat { get; } = new(); + public FakeIntent Intent { get; } + public LiveCombatModeCommandController Controller { get; } + } + + private sealed class FakeAuthority(List calls) + : ILiveCombatModeAuthority + { + public bool IsInWorldValue { get; set; } = true; + public bool IsInWorld => IsInWorldValue; + public bool ThrowOnSend { get; set; } + public List Requests { get; } = []; + + public void SendChangeCombatMode(CombatMode mode) + { + calls.Add($"send:{mode}"); + if (ThrowOnSend) + throw new InvalidOperationException("send"); + Requests.Add(mode); + } + } + + private sealed class FakeEquipment : ICombatEquipmentSource + { + public List Items { get; } = []; + public int QueryCount { get; private set; } + + public IReadOnlyList GetOrderedEquipment() + { + QueryCount++; + return Items; + } + } + + private sealed class FakeIntent(List calls) + : IExplicitCombatModeIntentSink + { + public void NotifyExplicitCombatModeRequest() => calls.Add("intent"); + } + + private sealed class FakeCommand : ILiveCombatModeCommand + { + public int ToggleCount { get; private set; } + public void Toggle() => ToggleCount++; + } +} diff --git a/tests/AcDream.App.Tests/Diagnostics/RuntimeDiagnosticCommandControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/RuntimeDiagnosticCommandControllerTests.cs new file mode 100644 index 00000000..6c10f259 --- /dev/null +++ b/tests/AcDream.App.Tests/Diagnostics/RuntimeDiagnosticCommandControllerTests.cs @@ -0,0 +1,185 @@ +using System.Numerics; +using AcDream.App.Diagnostics; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.Diagnostics; + +[Collection(AcDream.App.Tests.World.WorldEnvironmentControllerCollection.Name)] +public sealed class RuntimeDiagnosticCommandControllerTests +{ + [Fact] + public void Handle_RoutesCanonicalEnvironmentSceneAndDumpOwners() + { + var source = new FakeNearbySource(); + var logs = new List(); + var toasts = new List(); + var scene = new WorldSceneDebugState(); + var controller = new RuntimeDiagnosticCommandController( + new WorldEnvironmentController(), + scene, + pointer: null, + new NearbyWorldDiagnosticDumper(source, logs.Add), + toasts.Add); + + Assert.True(controller.Handle(InputAction.AcdreamToggleCollisionWires)); + Assert.True(scene.CollisionWireframesVisible); + Assert.True(controller.Handle(InputAction.AcdreamCycleTimeOfDay)); + Assert.True(controller.Handle(InputAction.AcdreamCycleWeather)); + Assert.True(controller.Handle(InputAction.AcdreamDumpNearby)); + Assert.True(controller.Handle(InputAction.AcdreamSensitivityDown)); + Assert.True(controller.Handle(InputAction.AcdreamSensitivityUp)); + Assert.False(controller.Handle(InputAction.MovementForward)); + + Assert.Contains("Collision wireframes ON", toasts); + Assert.Contains("Time override = 0.00", toasts); + Assert.Contains("Weather = Overcast", toasts); + Assert.Equal(3, toasts.Count); + Assert.Contains(logs, line => line.StartsWith( + "=== F3 DEBUG DUMP ===", StringComparison.Ordinal)); + } + + [Fact] + public void NearbyDump_PreservesLandblockCoordinatesRangeAndDistanceOrder() + { + var source = new FakeNearbySource + { + ObserverPositionValue = new Vector3(200f, -1f, 3f), + LiveCenterXValue = 10, + LiveCenterYValue = 20, + TotalShadowObjectsValue = 4, + }; + source.Entities.Add(Entity(2, new Vector3(203f, -1f, 3f))); + source.Entities.Add(Entity(1, new Vector3(201f, -1f, 3f))); + source.Entities.Add(Entity(3, new Vector3(216f, -1f, 3f))); + source.Shadows.Add(new ShadowEntry( + EntityId: 5, + GfxObjId: 6, + Position: new Vector3(202f, -1f, 3f), + Rotation: Quaternion.Identity, + Radius: 0.5f, + CollisionType: ShadowCollisionType.Sphere)); + var lines = new List(); + + new NearbyWorldDiagnosticDumper(source, lines.Add).Dump(); + + Assert.Contains("landblock=0x0B13FFFF local=(8.00,191.00)", lines[0]); + Assert.Contains("total shadow objects: 4", lines[0]); + Assert.Equal(" VISIBLE entities within 15m: 2", lines[1]); + Assert.Contains("id=0x00000001", lines[2]); + Assert.Contains("id=0x00000002", lines[3]); + Assert.Equal(" SHADOW objects within 15m: 1", lines[4]); + Assert.Contains("id=0x00000005", lines[5]); + } + + [Fact] + public void DeferredSlot_ClaimsKnownActionsBeforeBindAndForwardsAfterBind() + { + var slot = new RuntimeDiagnosticCommandSlot(); + var target = new FakeCommands(); + + Assert.True(slot.Handle(InputAction.AcdreamDumpNearby)); + Assert.False(slot.Handle(InputAction.MovementForward)); + slot.CycleTimeOfDay(); + + slot.Bind(target); + slot.Bind(target); + Assert.True(slot.Handle(InputAction.AcdreamDumpNearby)); + slot.CycleTimeOfDay(); + slot.CycleWeather(); + slot.ToggleCollisionWireframes(); + + Assert.Equal(4, target.CallCount); + Assert.Throws( + () => slot.Bind(new FakeCommands())); + slot.Unbind(target); + slot.CycleWeather(); + Assert.Equal(4, target.CallCount); + + slot.Bind(target); + Action copied = slot.CycleWeather; + slot.Deactivate(); + copied(); + Assert.Equal(4, target.CallCount); + Assert.Throws(() => slot.Bind(target)); + } + + [Fact] + public void NullToast_DoesNotSuppressDiagnosticStateChanges() + { + var environment = new WorldEnvironmentController(); + var scene = new WorldSceneDebugState(); + var sensitivity = new FakeSensitivity(); + var controller = new RuntimeDiagnosticCommandController( + environment, + scene, + sensitivity, + new NearbyWorldDiagnosticDumper(new FakeNearbySource()), + toast: null); + + controller.CycleTimeOfDay(); + controller.CycleWeather(); + controller.ToggleCollisionWireframes(); + Assert.True(controller.Handle(InputAction.AcdreamSensitivityDown)); + Assert.True(controller.Handle(InputAction.AcdreamSensitivityUp)); + + Assert.Equal(0f, environment.DayFraction); + Assert.Equal(WeatherKind.Overcast, environment.Weather.Kind); + Assert.True(scene.CollisionWireframesVisible); + Assert.Equal(2, sensitivity.AdjustCount); + } + + private static WorldEntity Entity(uint id, Vector3 position) => new() + { + Id = id, + SourceGfxObjOrSetupId = id + 100, + Position = position, + Rotation = Quaternion.Identity, + MeshRefs = [], + }; + + private sealed class FakeNearbySource : INearbyWorldDiagnosticSource + { + public Vector3 ObserverPositionValue { get; set; } + public int LiveCenterXValue { get; set; } + public int LiveCenterYValue { get; set; } + public int TotalShadowObjectsValue { get; set; } + public List Entities { get; } = []; + public List Shadows { get; } = []; + + public Vector3 ObserverPosition => ObserverPositionValue; + public int LiveCenterX => LiveCenterXValue; + public int LiveCenterY => LiveCenterYValue; + public int TotalShadowObjects => TotalShadowObjectsValue; + public IReadOnlyList WorldEntities => Entities; + public IEnumerable ShadowEntries => Shadows; + } + + private sealed class FakeCommands : IRuntimeDiagnosticCommands + { + public int CallCount { get; private set; } + public bool Handle(InputAction action) + { + CallCount++; + return true; + } + public void CycleTimeOfDay() => CallCount++; + public void CycleWeather() => CallCount++; + public void ToggleCollisionWireframes() => CallCount++; + } + + private sealed class FakeSensitivity : IPointerSensitivityCommands + { + public int AdjustCount { get; private set; } + + public string AdjustSensitivity(float factor) + { + AdjustCount++; + return $"sensitivity {factor}"; + } + } +} diff --git a/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs new file mode 100644 index 00000000..a840d884 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs @@ -0,0 +1,328 @@ +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.Core.Combat; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class GameplayInputActionRouterTests +{ + [Theory] + [InlineData("pointer", "pointer")] + [InlineData("combat", "pointer,combat")] + [InlineData("retained", "pointer,combat,retained")] + [InlineData("selection", "pointer,combat,retained,selection")] + [InlineData("movement", "pointer,combat,retained,selection,movement")] + [InlineData("command", "pointer,combat,retained,selection,movement,command")] + public void Press_PreservesFrozenPriorityAndStopsAtConsumer( + string consumeAt, + string expectedCsv) + { + var harness = new Harness(consumeAt); + harness.Router.Attach(); + + harness.Actions.Raise( + InputAction.AcdreamToggleDebugPanel, + ActivationType.Press); + + Assert.Equal(expectedCsv.Split(','), harness.Targets.Calls); + } + + [Fact] + public void Scroll_IsBetweenPointerAndCombat_AndOnlyPressMovesIt() + { + var harness = new Harness(); + harness.Router.Attach(); + + harness.Actions.Raise(InputAction.ScrollUp, ActivationType.Release); + Assert.Equal(["pointer"], harness.Targets.Calls); + + harness.Targets.Calls.Clear(); + harness.Actions.Raise(InputAction.ScrollDown, ActivationType.Press); + Assert.Equal(["pointer", "scroll:ScrollDown"], harness.Targets.Calls); + } + + [Fact] + public void Release_ReachesCombatThenStopsAtPressGate() + { + var harness = new Harness(); + harness.Router.Attach(); + + harness.Actions.Raise( + InputAction.CombatHighAttack, + ActivationType.Release); + + Assert.Equal(["pointer", "combat"], harness.Targets.Calls); + } + + [Fact] + public void DoubleClick_PassesGateToRetainedSelectionMovementAndCommands() + { + var harness = new Harness(); + harness.Router.Attach(); + + harness.Actions.Raise( + InputAction.SelectDblLeft, + ActivationType.DoubleClick); + + Assert.Equal( + ["pointer", "combat", "retained", "selection", "movement", "command"], + harness.Targets.Calls); + } + + [Fact] + public void Attach_SeedsAndTracksCombatScopes() + { + var harness = new Harness(); + harness.Combat.CurrentModeValue = CombatMode.Missile; + harness.Router.Attach(); + + harness.Combat.Raise(CombatMode.Magic); + harness.Combat.Raise(CombatMode.NonCombat); + + Assert.Equal( + [InputScope.MissileCombat, InputScope.MagicCombat, null], + harness.Actions.Scopes); + } + + [Theory] + [InlineData(0, "remove-actions")] + [InlineData(1, "remove-combat,remove-actions")] + public void AttachFailureAfterSideEffect_RollsBackExactPrefixInReverse( + int failingEdge, + string expectedRemovalCsv) + { + var calls = new List(); + var actions = new FakeActionSurface(calls) + { + ThrowAfterAdd = failingEdge == 0, + }; + var combat = new FakeCombatSurface(calls) + { + ThrowAfterAdd = failingEdge == 1, + }; + var router = Create(actions, combat, new FakeTargets()); + + Assert.Throws(router.Attach); + + Assert.Equal(expectedRemovalCsv.Split(','), calls.Where( + call => call.StartsWith("remove", StringComparison.Ordinal))); + Assert.True(router.IsDisposalComplete); + } + + [Fact] + public void ReentrantDeliveryDuringAdd_CannotEnterPartialRouter() + { + var calls = new List(); + var actions = new FakeActionSurface(calls) + { + RaiseDuringAdd = true, + }; + var targets = new FakeTargets(); + var router = Create(actions, new FakeCombatSurface(calls), targets); + + router.Attach(); + + Assert.Empty(targets.Calls); + actions.Raise(InputAction.AcdreamToggleDebugPanel, ActivationType.Press); + Assert.NotEmpty(targets.Calls); + } + + [Fact] + public void AttachAndRollbackFailure_RetainsCleanupForLaterDispose() + { + var calls = new List(); + var actions = new FakeActionSurface(calls) + { + ThrowAfterAdd = true, + RemoveFailures = int.MaxValue, + }; + var router = Create( + actions, + new FakeCombatSurface(calls), + new FakeTargets()); + + Assert.Throws(router.Attach); + Assert.False(router.IsDisposalComplete); + + actions.RemoveFailures = 0; + router.Dispose(); + + Assert.True(router.IsDisposalComplete); + Assert.Null(actions.Callback); + } + + [Fact] + public void Dispose_RetriesOnlyFailedRemovalAndNeverReplaysSuccess() + { + var calls = new List(); + var actions = new FakeActionSurface(calls); + var combat = new FakeCombatSurface(calls) + { + RemoveFailures = int.MaxValue, + }; + var router = Create(actions, combat, new FakeTargets()); + router.Attach(); + + Assert.Throws(router.Dispose); + Assert.False(router.IsDisposalComplete); + + combat.RemoveFailures = 0; + router.Dispose(); + + Assert.True(router.IsDisposalComplete); + Assert.Equal(4, calls.Count(call => call == "remove-combat")); + Assert.Equal(1, calls.Count(call => call == "remove-actions")); + } + + [Fact] + public void Deactivate_MakesCopiedCallbacksSilentBeforePhysicalDetach() + { + var harness = new Harness(); + harness.Router.Attach(); + Action copiedAction = + harness.Actions.Callback!; + Action copiedCombat = harness.Combat.Callback!; + + harness.Router.Deactivate(); + copiedAction(InputAction.AcdreamToggleDebugPanel, ActivationType.Press); + copiedCombat(CombatMode.Melee); + + Assert.Empty(harness.Targets.Calls); + Assert.Equal([null], harness.Actions.Scopes); + } + + [Fact] + public void DisposeBeforeAttach_IsTerminalAndCannotResurrectCallbacks() + { + var harness = new Harness(); + + harness.Router.Dispose(); + + Assert.True(harness.Router.IsDisposalComplete); + Assert.Throws(harness.Router.Attach); + Assert.Null(harness.Actions.Callback); + Assert.Null(harness.Combat.Callback); + } + + private static GameplayInputActionRouter Create( + FakeActionSurface actions, + FakeCombatSurface combat, + FakeTargets targets) => + new(actions, combat, targets, new HostQuiescenceGate()); + + private sealed class Harness + { + public Harness(string? consumeAt = null) + { + var calls = new List(); + Actions = new FakeActionSurface(calls); + Combat = new FakeCombatSurface(calls); + Targets = new FakeTargets(consumeAt); + Router = Create(Actions, Combat, Targets); + } + + public FakeActionSurface Actions { get; } + public FakeCombatSurface Combat { get; } + public FakeTargets Targets { get; } + public GameplayInputActionRouter Router { get; } + } + + private sealed class FakeActionSurface(List calls) + : IGameplayInputActionSurface + { + public Action? Callback { get; private set; } + public bool ThrowAfterAdd { get; set; } + public bool RaiseDuringAdd { get; set; } + public int RemoveFailures { get; set; } + public List Scopes { get; } = []; + + public void AddFired(Action callback) + { + calls.Add("add-actions"); + Callback = callback; + if (RaiseDuringAdd) + { + callback( + InputAction.AcdreamToggleDebugPanel, + ActivationType.Press); + } + if (ThrowAfterAdd) + throw new InvalidOperationException("actions add"); + } + + public void RemoveFired(Action callback) + { + calls.Add("remove-actions"); + if (RemoveFailures-- > 0) + throw new InvalidOperationException("actions remove"); + if (ReferenceEquals(Callback, callback)) + Callback = null; + } + + public void SetCombatScope(InputScope? scope) => Scopes.Add(scope); + + public void Raise(InputAction action, ActivationType activation) => + Callback?.Invoke(action, activation); + } + + private sealed class FakeCombatSurface(List calls) + : ICombatModeEventSurface + { + public CombatMode CurrentModeValue { get; set; } = CombatMode.NonCombat; + public CombatMode CurrentMode => CurrentModeValue; + public Action? Callback { get; private set; } + public bool ThrowAfterAdd { get; set; } + public int RemoveFailures { get; set; } + + public void AddChanged(Action callback) + { + calls.Add("add-combat"); + Callback = callback; + if (ThrowAfterAdd) + throw new InvalidOperationException("combat add"); + } + + public void RemoveChanged(Action callback) + { + calls.Add("remove-combat"); + if (RemoveFailures-- > 0) + throw new InvalidOperationException("combat remove"); + if (ReferenceEquals(Callback, callback)) + Callback = null; + } + + public void Raise(CombatMode mode) => Callback?.Invoke(mode); + } + + private sealed class FakeTargets(string? consumeAt = null) + : IGameplayInputPriorityTargets + { + public List Calls { get; } = []; + + public bool HandlePointerAction(InputAction action, ActivationType activation) => + Record("pointer"); + + public void HandleScroll(InputAction action) => Calls.Add($"scroll:{action}"); + + public bool HandleCombatAction(InputAction action, ActivationType activation) => + Record("combat"); + + public bool HandleRetainedUiAction(InputAction action) => + Record("retained"); + + public bool HandleSelectionAction(InputAction action) => + Record("selection"); + + public bool HandlePressedMovementAction(InputAction action) => + Record("movement"); + + public void HandleCommand(InputAction action) => Calls.Add("command"); + + private bool Record(string name) + { + Calls.Add(name); + return consumeAt == name; + } + } +} diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs new file mode 100644 index 00000000..5bc9543f --- /dev/null +++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs @@ -0,0 +1,180 @@ +using AcDream.App.Combat; +using AcDream.App.Diagnostics; +using AcDream.App.Input; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class GameplayInputCommandControllerTests +{ + [Theory] + [InlineData(InputAction.ToggleInventoryPanel, "inventory")] + [InlineData(InputAction.AcdreamToggleDebugPanel, "debug")] + [InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")] + [InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")] + [InlineData(InputAction.ToggleChatEntry, "chat")] + [InlineData(InputAction.ToggleOptionsPanel, "settings")] + [InlineData(InputAction.CombatToggleCombat, "combat")] + public void RecognizedCommand_RoutesToTypedOwner( + InputAction action, + string expected) + { + var harness = new Harness(); + + bool handled = harness.Controller.Handle(action); + + Assert.True(handled); + Assert.Equal([expected], harness.Calls); + } + + [Fact] + public void DiagnosticCommand_PrecedesRemainingCommandSwitch() + { + var harness = new Harness(); + harness.Diagnostics.HandledAction = InputAction.AcdreamToggleDebugPanel; + + bool handled = harness.Controller.Handle( + InputAction.AcdreamToggleDebugPanel); + + Assert.True(handled); + Assert.Equal(["diagnostic"], harness.Calls); + } + + [Fact] + public void UnknownCommand_IsNotClaimed() + { + var harness = new Harness(); + + bool handled = harness.Controller.Handle(InputAction.MovementForward); + + Assert.False(handled); + Assert.Empty(harness.Calls); + } + + [Theory] + [InlineData(true, true, true, "cancel-target")] + [InlineData(false, true, true, "exit-fly")] + [InlineData(false, false, true, "exit-player")] + [InlineData(false, false, false, "close")] + public void Escape_PreservesTargetFlyPlayerWindowPriority( + bool targetMode, + bool flyMode, + bool playerMode, + string expected) + { + var harness = new Harness + { + TargetMode = { IsActive = targetMode }, + Camera = { IsFly = flyMode }, + Player = { IsPlayer = playerMode }, + }; + + bool handled = harness.Controller.Handle(InputAction.EscapeKey); + + Assert.True(handled); + Assert.Equal([expected], harness.Calls); + } + + private sealed class Harness + { + public Harness() + { + Retained = new FakeRetained(Calls); + DevTools = new FakeDevTools(Calls); + Diagnostics = new FakeDiagnostics(Calls); + Player = new FakePlayerMode(Calls); + TargetMode = new FakeTargetMode(Calls); + Camera = new FakeCamera(Calls); + Combat = new FakeCombat(Calls); + Window = new FakeWindow(Calls); + Controller = new GameplayInputCommandController( + Retained, + DevTools, + Diagnostics, + Player, + TargetMode, + Camera, + Combat, + Window); + } + + public List Calls { get; } = []; + public FakeRetained Retained { get; } + public FakeDevTools DevTools { get; } + public FakeDiagnostics Diagnostics { get; } + public FakePlayerMode Player { get; } + public FakeTargetMode TargetMode { get; } + public FakeCamera Camera { get; } + public FakeCombat Combat { get; } + public FakeWindow Window { get; } + public GameplayInputCommandController Controller { get; } + } + + private sealed class FakeRetained(List calls) + : IRetainedGameplayWindowCommands + { + public void ToggleInventory() => calls.Add("inventory"); + } + + private sealed class FakeDevTools(List calls) + : IDevToolsGameplayCommands + { + public void ToggleDebugPanel() => calls.Add("debug"); + public void FocusChatInput() => calls.Add("chat"); + public void ToggleSettingsPanel() => calls.Add("settings"); + } + + private sealed class FakeDiagnostics(List calls) + : IRuntimeDiagnosticCommands + { + public InputAction? HandledAction { get; set; } + + public bool Handle(InputAction action) + { + if (action != HandledAction) + return false; + calls.Add("diagnostic"); + return true; + } + + public void CycleTimeOfDay() => calls.Add("time"); + public void CycleWeather() => calls.Add("weather"); + public void ToggleCollisionWireframes() => calls.Add("collision"); + } + + private sealed class FakePlayerMode(List calls) + : IPlayerModeGameplayCommands + { + public bool IsPlayer { get; set; } + public bool IsPlayerMode => IsPlayer; + public void ToggleFlyOrChase() => calls.Add("fly-or-chase"); + public void TogglePlayerMode() => calls.Add("player-mode"); + public void ExitPlayerMode() => calls.Add("exit-player"); + } + + private sealed class FakeTargetMode(List calls) + : IItemTargetModeCommands + { + public bool IsActive { get; set; } + public bool IsAnyTargetModeActive => IsActive; + public void CancelTargetMode() => calls.Add("cancel-target"); + } + + private sealed class FakeCamera(List calls) + : IGameplayCameraModeCommands + { + public bool IsFly { get; set; } + public bool IsFlyMode => IsFly; + public void ExitFlyMode() => calls.Add("exit-fly"); + } + + private sealed class FakeCombat(List calls) : ILiveCombatModeCommand + { + public void Toggle() => calls.Add("combat"); + } + + private sealed class FakeWindow(List calls) : IGameplayWindowCommands + { + public void Close() => calls.Add("close"); + } +} diff --git a/tests/AcDream.App.Tests/Input/RetainedUiGameplayBindingTests.cs b/tests/AcDream.App.Tests/Input/RetainedUiGameplayBindingTests.cs new file mode 100644 index 00000000..0344d702 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/RetainedUiGameplayBindingTests.cs @@ -0,0 +1,158 @@ +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.App.UI; + +namespace AcDream.App.Tests.Input; + +public sealed class RetainedUiGameplayBindingTests +{ + [Fact] + public void AttachedBinding_RoutesOnlyItemPayloads() + { + var surface = new Surface(); + var calls = new List<(uint Id, int X, int Y)>(); + var binding = Create(surface, calls); + binding.Attach(); + + surface.Raise("not an item", 1, 2); + surface.Raise(Item(42), 7, 9); + + Assert.Equal([(42u, 7, 9)], calls); + } + + [Fact] + public void AddAfterSideEffectFailure_RollsBackExactCallback() + { + var surface = new Surface { ThrowAfterAdd = true }; + var binding = Create(surface, []); + + Assert.Throws(binding.Attach); + + Assert.True(binding.IsDisposalComplete); + Assert.Null(surface.Callback); + Assert.Equal(1, surface.RemoveCalls); + } + + [Fact] + public void ReentrantDeliveryDuringAdd_CannotEnterPartialBinding() + { + var surface = new Surface { RaiseDuringAdd = true }; + var calls = new List<(uint Id, int X, int Y)>(); + var binding = Create(surface, calls); + + binding.Attach(); + Assert.Empty(calls); + surface.Raise(Item(2), 3, 4); + Assert.Equal([(2u, 3, 4)], calls); + } + + [Fact] + public void AttachAndRollbackFailure_RetainsCleanupForLaterDispose() + { + var surface = new Surface + { + ThrowAfterAdd = true, + RemoveFailures = int.MaxValue, + }; + var binding = Create(surface, []); + + Assert.Throws(binding.Attach); + Assert.False(binding.IsDisposalComplete); + + surface.RemoveFailures = 0; + binding.Dispose(); + + Assert.True(binding.IsDisposalComplete); + Assert.Null(surface.Callback); + } + + [Fact] + public void Deactivate_MakesCopiedCallbackSilentBeforePhysicalDetach() + { + var surface = new Surface(); + var calls = new List<(uint Id, int X, int Y)>(); + var binding = Create(surface, calls); + binding.Attach(); + Action copied = surface.Callback!; + + binding.Deactivate(); + copied(Item(8), 1, 1); + + Assert.Empty(calls); + Assert.False(binding.IsDisposalComplete); + } + + [Fact] + public void Dispose_RetriesFailedRemovalWithoutReattaching() + { + var surface = new Surface { RemoveFailures = int.MaxValue }; + var binding = Create(surface, []); + binding.Attach(); + + Assert.Throws(binding.Dispose); + Assert.False(binding.IsDisposalComplete); + + surface.RemoveFailures = 0; + binding.Dispose(); + + Assert.True(binding.IsDisposalComplete); + Assert.Equal(1, surface.AddCalls); + Assert.Equal(3, surface.RemoveCalls); + } + + [Fact] + public void DisposeBeforeAttach_IsTerminal() + { + var surface = new Surface(); + var binding = Create(surface, []); + + binding.Dispose(); + + Assert.True(binding.IsDisposalComplete); + Assert.Throws(binding.Attach); + Assert.Null(surface.Callback); + } + + private static RetainedUiGameplayBinding Create( + Surface surface, + List<(uint Id, int X, int Y)> calls) => + new( + surface, + (item, x, y) => calls.Add((item.ObjId, x, y)), + new HostQuiescenceGate()); + + private static ItemDragPayload Item(uint id) => + new(id, ItemDragSource.Inventory, 0, SourceCell: null!); + + private sealed class Surface : IRetainedUiDragReleaseSurface + { + public Action? Callback { get; private set; } + public bool ThrowAfterAdd { get; set; } + public bool RaiseDuringAdd { get; set; } + public int RemoveFailures { get; set; } + public int AddCalls { get; private set; } + public int RemoveCalls { get; private set; } + + public void AddReleasedOutside(Action callback) + { + AddCalls++; + Callback = callback; + if (RaiseDuringAdd) + callback(Item(1), 1, 1); + if (ThrowAfterAdd) + throw new InvalidOperationException("add"); + } + + public void RemoveReleasedOutside(Action callback) + { + RemoveCalls++; + if (RemoveFailures-- > 0) + throw new InvalidOperationException("remove"); + if (ReferenceEquals(Callback, callback)) + Callback = null; + } + + public void Raise(object payload, int x, int y) => + Callback?.Invoke(payload, x, y); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 54f050f9..42c200a6 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -62,7 +62,6 @@ public sealed class GameWindowSlice8BoundaryTests "_inputDispatcher.Attach();", "_movementInput.Bind(_inputDispatcher);", "_cameraInput.Bind(_inputDispatcher);", - "_inputDispatcher.Fired += OnInputAction;", "_cameraController = new CameraController(orbit, fly);", "_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(", "_cameraPointerInput.AttachRaw();", @@ -73,9 +72,17 @@ public sealed class GameWindowSlice8BoundaryTests "_uiHost.WireKeyboard(kb)", "_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(", "_liveEntities = new AcDream.App.World.LiveEntityRuntime(", + "_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(", + "_retainedUiGameplayBinding =", + "AcDream.App.Input.RetainedUiGameplayBinding.Create(", + "_retainedUiGameplayBinding.Attach();", "_renderFrameOrchestrator =", "_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(", "_liveSessionHost = CreateLiveSessionHost();", + "_liveCombatModeCommands.Bind(combatCommand);", + "_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);", + "_gameplayInputActions = AcDream.App.Input.GameplayInputActionRouter.Create(", + "_gameplayInputActions.Attach();", "_liveSessionHost.Start(_options)"); Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)")); Assert.Contains("if (firstKb is not null)", body, StringComparison.Ordinal); @@ -128,33 +135,53 @@ public sealed class GameWindowSlice8BoundaryTests Assert.DoesNotContain("private void RefreshSkyForCurrentDay()", source, StringComparison.Ordinal); Assert.DoesNotContain("private void OnEnvironChanged(", source, StringComparison.Ordinal); - string cycleTime = MethodBody( - "private void CycleTimeOfDay()", - "private void CycleWeather()"); + Assert.DoesNotContain("private void CycleTimeOfDay()", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void CycleWeather()", source, StringComparison.Ordinal); AssertAppearsInOrder( - cycleTime, - "_worldEnvironment.CycleTimeOfDay();", - "_debugVm?.AddToast(message);"); + load, + "_debugVm.CycleTimeOfDay =", + "_runtimeDiagnosticCommands.CycleTimeOfDay;", + "new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(", + "_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);"); } [Fact] - public void InputAction_PreservesRetailAcceptedPriority() + public void InputAction_IsOneTypedOwnerHandoff() { - string body = MethodBody( - "private void OnInputAction(", - "private void ToggleLiveCombatMode()"); + string source = GameWindowSource(); + string load = MethodBody( + "private void OnLoad()", + "private AcDream.App.Net.LiveSessionHost"); + string shutdown = MethodBody( + "private ResourceShutdownTransaction CreateShutdownTransaction()", + "private void OnFocusChanged(bool focused)"); + Assert.DoesNotContain("private void OnInputAction(", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void SetInputCombatScope(", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void ToggleLiveCombatMode()", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void OnUiDragReleasedOutside(", source, StringComparison.Ordinal); + Assert.DoesNotContain("DragReleasedOutsideUi +=", source, StringComparison.Ordinal); + Assert.Equal(1, CountOccurrences( + load, + "AcDream.App.Input.GameplayInputActionRouter.Create(")); + Assert.Equal(1, CountOccurrences(load, "_gameplayInputActions.Attach();")); + Assert.Equal(1, CountOccurrences( + load, + "AcDream.App.Input.RetainedUiGameplayBinding.Create(")); + Assert.Equal(1, CountOccurrences( + load, + "_retainedUiGameplayBinding.Attach();")); AssertAppearsInOrder( - body, - "HandlePointerAction(action, activation)", - "action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp", - "HandleScrollAction(action);", - "HandleCombatAction(action, activation)", - "activation != AcDream.UI.Abstractions.Input.ActivationType.Press", - "_retailUiRuntime?.HandleInputAction(action)", - "_selectionInteractions?.HandleInputAction(action)", - "HandlePressedMovementAction(action)", - "switch (action)"); + shutdown, + "_liveCombatModeCommands.Deactivate", + "_runtimeDiagnosticCommands.Deactivate", + "_retainedUiGameplayBinding?.Deactivate()", + "_gameplayInputActions?.Deactivate()", + "new ResourceShutdownStage(\"session lifetime\"", + "binding.Dispose();", + "_retainedUiGameplayBinding = null;", + "actions.Dispose();", + "_gameplayInputActions = null;"); } [Fact] @@ -265,10 +292,16 @@ public sealed class GameWindowSlice8BoundaryTests "_windowCallbacks = null;"); AssertAppearsInOrder( manifest, + "new(\"combat command slot\", _liveCombatModeCommands.Deactivate)", + "new(\"diagnostic command slot\", _runtimeDiagnosticCommands.Deactivate)", + "new(\"retained gameplay\", () => _retainedUiGameplayBinding?.Deactivate())", + "new(\"gameplay actions\", () => _gameplayInputActions?.Deactivate())", "new(\"camera pointer\", () => _cameraPointerInput?.Deactivate())", "new ResourceShutdownStage(\"session lifetime\"", "controller.Dispose();", "new ResourceShutdownStage(\"input callback detach\"", + "binding.Dispose();", + "actions.Dispose();", "pointer.Dispose();", "new ResourceShutdownStage(\"session dependents\"", "pointer.ReleaseMouseLookAfterSessionRetirement();", diff --git a/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs index afd8c11f..0b0d834f 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs @@ -44,9 +44,11 @@ public sealed class CombatInputPlannerTests { var held = Equipped(EquipMask.Held, ItemType.Misc, combatUse: 0); - Assert.Equal( - CombatMode.NonCombat, - CombatInputPlanner.GetDefaultCombatMode([held])); + DefaultCombatModeDecision decision = + CombatInputPlanner.GetDefaultCombatModeDecision([held]); + + Assert.Equal(CombatMode.NonCombat, decision.Mode); + Assert.Same(held, decision.IncompatibleHeldItem); } [Fact] @@ -64,6 +66,9 @@ public sealed class CombatInputPlannerTests Assert.Equal( CombatMode.Missile, CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.Missile)); + Assert.Equal( + CombatMode.NonCombat, + CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.NonCombat)); } [Fact] @@ -71,6 +76,7 @@ public sealed class CombatInputPlannerTests { Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Melee)); Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Magic)); + Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Undef)); } [Theory] From fec0d94148fb65fef720108504ce781b9f313646 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 13:30:22 +0200 Subject: [PATCH 97/98] refactor(settings): own two-phase runtime settings Move pre-window loading, startup application, live settings mutation, toon context, quality reapply, and SettingsVM loans behind one RuntimeSettingsController. Preserve retail command behavior, ordered target publication, draft semantics, and retryable failure convergence while removing duplicate GameWindow state and feature bodies. Co-authored-by: Codex --- AGENTS.md | 15 +- CLAUDE.md | 15 +- docs/ISSUES.md | 9 +- docs/architecture/acdream-architecture.md | 10 +- docs/architecture/code-structure.md | 22 +- .../retail-divergence-register.md | 3 +- docs/plans/2026-04-11-roadmap.md | 13 +- docs/plans/2026-05-12-milestones.md | 13 +- ...w-slice-8-checkpoint-g-runtime-settings.md | 132 ++ ...amewindow-slice-8-composition-lifecycle.md | 25 +- memory/project_gamewindow_decomposition.md | 33 +- src/AcDream.App/Rendering/GameWindow.cs | 552 ++------ src/AcDream.App/Rendering/TerrainAtlas.cs | 3 +- .../Rendering/Wb/WbDrawDispatcher.cs | 3 +- .../Rendering/WorldRenderFrameBuilder.cs | 32 +- .../Settings/RuntimeSettingsController.cs | 471 +++++++ .../Settings/RuntimeSettingsTargets.cs | 255 ++++ .../Update/LiveObjectFrameController.cs | 11 +- .../Panels/Settings/SettingsVM.cs | 15 + .../GameWindowSlice8BoundaryTests.cs | 74 +- .../PrivatePresentationRendererTests.cs | 2 +- .../RuntimeSettingsControllerTests.cs | 1159 +++++++++++++++++ .../World/LiveObjectFrameControllerTests.cs | 73 +- .../Panels/Settings/SettingsVMTests.cs | 38 + 24 files changed, 2379 insertions(+), 599 deletions(-) create mode 100644 docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-g-runtime-settings.md create mode 100644 src/AcDream.App/Settings/RuntimeSettingsController.cs create mode 100644 src/AcDream.App/Settings/RuntimeSettingsTargets.cs create mode 100644 tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs diff --git a/AGENTS.md b/AGENTS.md index b4af475e..b5d4a495 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,7 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–F are also complete: dead host residue is removed, +Slice 8 checkpoints A–G are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT @@ -139,11 +139,14 @@ transactional, and terminal after disposal; live-session retirement precedes physical callback removal. `GameplayInputActionRouter` now owns the sole semantic action-priority graph; typed owners route combat, diagnostics, and remaining commands, and the -retained-root item-drop edge is reversible. `GameWindow` is 4,057 raw lines / -198 fields / 54 methods, down 11,666 lines (74.2%) from the 15,723-line campaign -baseline. The 7,524-test Release suite, 315.6-second lifecycle/reconnect gate, -and 395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints G–L— -settings, resource ownership, ordered composition, +retained-root item-drop edge is reversible. `GameWindow` was 4,057 raw lines / +198 fields / 54 methods at F. Checkpoint G adds the sole pre-window/runtime +settings owner, one immutable startup snapshot, ordered one-shot startup +application, and inert late target binding. `GameWindow` is now 3,663 raw lines / +162 fields / 37 methods, down 12,060 lines (76.7%) from the 15,723-line campaign +baseline. The 7,553-test / 5-skip Checkpoint G Release suite, 315.6-second +lifecycle/reconnect gate, and 395.2-second synchronized nine-stop soak pass. +Slice 8 checkpoints H–L—resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, diff --git a/CLAUDE.md b/CLAUDE.md index 1ddc71c7..518ad669 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–F are also complete: dead host residue is removed, +Slice 8 checkpoints A–G are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT @@ -137,11 +137,14 @@ transactional, and terminal after disposal; live-session retirement precedes physical callback removal. `GameplayInputActionRouter` now owns the sole semantic action-priority graph; typed owners route combat, diagnostics, and remaining commands, and the -retained-root item-drop edge is reversible. `GameWindow` is 4,057 raw lines / -198 fields / 54 methods, down 11,666 lines (74.2%) from the 15,723-line campaign -baseline. The 7,524-test Release suite, 315.6-second lifecycle/reconnect gate, -and 395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints G–L— -settings, resource ownership, ordered composition, +retained-root item-drop edge is reversible. `GameWindow` was 4,057 raw lines / +198 fields / 54 methods at F. Checkpoint G adds the sole pre-window/runtime +settings owner, one immutable startup snapshot, ordered one-shot startup +application, and inert late target binding. `GameWindow` is now 3,663 raw lines / +162 fields / 37 methods, down 12,060 lines (76.7%) from the 15,723-line campaign +baseline. The 7,553-test / 5-skip Checkpoint G Release suite, 315.6-second +lifecycle/reconnect gate, and 395.2-second synchronized nine-stop soak pass. +Slice 8 checkpoints H–L—resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 194d46b8..8cf014d4 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -27,13 +27,14 @@ What does NOT go here: ## Current queue — 2026-07-22 - **Active structural work:** `GameWindow` decomposition. Slices 1–7 are - complete; Slice 8 checkpoints A–F now own native callbacks, live-session + complete; Slice 8 checkpoints A–G now own native callbacks, live-session composition, world-environment state, reversible device input/raw pointer, physical framebuffer publication, gameplay action priority, typed commands, - and the retained-root item-drop edge outside the shell. + the retained-root item-drop edge, and two-phase runtime settings outside the + shell. `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration - handoffs and the class is 4,057 lines / 198 fields / 54 methods. Remaining - Slice 8 settings/resource/composition/shutdown + handoffs and the class is 3,663 lines / 162 fields / 37 methods. Remaining + Slice 8 resource/composition/shutdown cleanup is active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index a5f62057..c2605a03 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -829,11 +829,15 @@ Current movement/collision ownership: routers; Slice 4 moved hydration, teardown, inbound authority, network updates, and reusable motion-runtime policy over canonical `LiveEntityRuntime`. Slices 5–7 complete landblock presentation and the - update/render frame graphs. Slice 8 checkpoints A–F now own the native + update/render frame graphs. Slice 8 checkpoints A–G now own the native callback shell, live-session composition, world environment, reversible input/pointer lifetime, physical framebuffer publication, the sole gameplay - action-priority graph, typed commands, and the retained-root item-drop edge. - Two-phase runtime settings is the next checkpoint. The reconciled ledger and sequence live in + action-priority graph, typed commands, the retained-root item-drop edge, and + two-phase runtime settings. `RuntimeSettingsController` is the sole settings + persistence/current-state owner, supplies the immutable pre-window snapshot, + applies startup pacing/display/FOV/audio once, and later borrows complete + runtime targets without replay. Explicit resource ownership and frame slots + are the next checkpoint. The reconciled ledger and sequence live in `docs/architecture/code-structure.md`. This decision does not reclassify the remaining L.2 conformance work or Track LH. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 3cf636e6..70e4a417 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -34,6 +34,7 @@ after Slice 7 draw-frame cutover 4,666 lines / 196 fields / 70 method after Slice 8 checkpoint D 4,330 lines / 192 fields / 67 methods after Slice 8 checkpoint E 4,266 lines / 194 fields / 65 methods after Slice 8 checkpoint F 4,057 lines / 198 fields / 54 methods +after Slice 8 checkpoint G 3,663 lines / 162 fields / 37 methods ``` `GameWindow` is the single object that: @@ -195,8 +196,9 @@ documentation, not an exhaustive allowlist): shared root and sibling controls. - `DevToolsFramePresenter -> DevToolsPanelSet -> panel/ViewModel bindings` for the optional developer UI. -- `WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview -> SettingsVM` - for the live settings draft preview applied before world drawing. +- `WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview -> + IRuntimeSettingsPreviewSource -> RuntimeSettingsController -> optional + SettingsVM` for the live settings draft preview applied before world drawing. - `LocalPlayerPortalViewport -> LocalPlayerTeleportController -> GameplayInputFrameController -> InputDispatcher.Fired -> GameWindow` for the canonical portal/input lifetime and the host's input-action subscription. @@ -692,7 +694,7 @@ slices. `OnClosing` delegates to the existing retryable shutdown transaction. Silk callbacks become narrow calls into the input, update, render, resize, focus, and shutdown owners. -Checkpoints E and F are complete. `CameraPointerInputController` owns raw pointer, +Checkpoints E, F, and G are complete. `CameraPointerInputController` owns raw pointer, camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController` alone publishes physical framebuffer changes. All Silk, dispatcher, retained UI, and optional devtools device edges are reversible, transactional, and @@ -705,8 +707,18 @@ subscriber and preserves the accepted input-priority graph. Focused combat, diagnostic, and general command owners replace the remaining window command bodies. The retained-root item-drop edge has one reversible owner, while its deferred toolbar targets become inert before session retirement. `GameWindow` -is now 4,057 raw lines / 198 fields / 54 methods. Checkpoints G–L remain -active, with two-phase runtime settings next. +now has no settings-store construction, duplicate settings loads, persisted +settings mirrors, or display/quality feature bodies. `RuntimeSettingsController` +is constructed before `Window.Create`, owns the one immutable startup snapshot +and current/toon settings state, applies startup display/pacing/FOV/audio once, +then borrows late-bound runtime targets without replay. Saved FOV now applies at +startup even when devtools are disabled; this is a deliberate correction of the +old accidental SettingsVM gate. The controller also preserves unrelated unsaved +Gameplay draft fields when combat preferences change. `GameWindow` is now 3,663 +raw lines / 162 fields / 37 methods. Checkpoints H–L remain active, with explicit +resource ownership and frame slots next. The Checkpoint G production App build +is warning-free, all three corrected-diff reviews are clean, and the complete +Release suite passes 7,553 tests / 5 intentional skips. ### 4.4 Exit criteria diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 29c0347f..66b0a092 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -107,7 +107,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 87 active rows +## 3. Documented approximation (AP) — 88 active rows 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 @@ -217,6 +217,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` | | AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | | AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` | +| AP-121 | The `/framerate` command persists acdream's live ShowFps value in the modern `settings.json` Display bag after performing retail's live toggle/notice behavior. The named retail `DoFrameRate` body itself only flips `fShowFramerate` and sends `SetFramerateDisplay`; no equivalent persistence write occurs in that command body. | `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ToggleFrameRate`) | This preserves acdream's shipped L.0 cross-launch display preference while keeping the live retail mechanism and SmartBox visibility result exact; it changes no gameplay or wire state | A relaunch can retain FPS visibility where the named retail command body alone would not; a future port of retail's wider preference owner must avoid double-persisting or applying conflicting startup state | `ClientCommunicationSystem::DoFrameRate @ 0x005707D0`; `CM_UI::SendNotice_SetFramerateDisplay @ 0x0047A050`; `gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0` | ## 4. Temporary stopgap (TS) — 38 active rows + 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) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 4b46c03b..dd23dcdb 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-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–F are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–G are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. 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. --- @@ -79,7 +79,7 @@ second synchronized soak pass with graceful exits, and the six stable PNG checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse process-residency gate's diagnostic blind spot; no tolerance was loosened. -Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–F now own +Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–G now own the frozen shell boundary, all nine native callback edges, live-session reset/selection/entry/route composition, and world clock/sky/day/weather/ AdminEnvirons state. Checkpoint E adds reversible, transactional Silk, @@ -91,8 +91,13 @@ fields / 65 methods. Checkpoint F adds the sole gameplay action-priority subscriber, typed combat/diagnostic/general commands, and a reversible retained-root item-drop binding. `GameWindow` is now 4,057 lines / 198 fields / 54 methods; the Checkpoint F Release suite passes 7,524 tests / 5 skips. -Settings, startup/resource transfer, shutdown, soak, and closeout checkpoints -G–L remain. +Checkpoint G adds the pre-window `RuntimeSettingsController`, one immutable +startup snapshot, ordered startup pacing/display/FOV/audio application, late +runtime-target binding without replay, and one current/toon settings owner. +`GameWindow` is now 3,663 lines / 162 fields / 37 methods. Resource transfer, +ordered composition, shutdown, soak, and closeout checkpoints H–L remain. The +Checkpoint G Release suite passes 7,553 tests / 5 intentional skips, and all +three corrected-diff reviews are clean. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 84fb2e4e..5cd9a405 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -39,7 +39,7 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized nine-destination soak pass with graceful exits, and six stable PNG checkpoints -preserve Slice 6 presentation. Final Slice 8 checkpoints A–F are complete: +preserve Slice 6 presentation. Final Slice 8 checkpoints A–G are complete: native callbacks and live-session composition now have explicit retryable owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky, day group, weather, and AdminEnvirons state. Checkpoint E adds terminal, @@ -47,10 +47,13 @@ transactional ownership for Silk/dispatcher/retained/devtools input, the raw camera pointer, focus, and physical framebuffer size. Graceful live-session retirement now precedes physical callback removal. Checkpoint F adds the sole gameplay action-priority owner, typed combat/diagnostic/general commands, and a -reversible retained-root item-drop binding. `GameWindow` is 4,057 lines / 198 -fields / 54 methods; 7,524 Release tests pass / 5 skip. Checkpoints G–L remain -active. Issue #232 tracks process-residency variance in the soak without -loosening its leak threshold. +reversible retained-root item-drop binding. Checkpoint G adds the sole +pre-window/runtime settings owner, one startup snapshot, ordered one-shot +startup application, and inert late target binding. `GameWindow` is 3,663 +lines / 162 fields / 37 methods. Checkpoints H–L remain active. Issue #232 +tracks process-residency variance in the soak without loosening its leak +threshold. The Checkpoint G Release suite passes 7,553 tests / 5 skips and all +three corrected-diff reviews are clean. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-g-runtime-settings.md b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-g-runtime-settings.md new file mode 100644 index 00000000..3b52e87a --- /dev/null +++ b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-g-runtime-settings.md @@ -0,0 +1,132 @@ +# GameWindow Slice 8 — Checkpoint G Runtime Settings Plan + +## Objective + +Replace `GameWindow`'s duplicated pre-window and `OnLoad` settings paths with +one two-phase `RuntimeSettingsController`. The controller owns persistence and +live settings state; render, window, audio, UI, and streaming objects remain +borrowed targets. This is a structural cutover, not a redesign of the settings +format or retail option behavior. + +Two carried behaviors are now explicit rather than accidental. Saved FOV is +applied during startup even when developer tools are disabled; previously it +was incorrectly gated on construction of `SettingsVM`. The named-retail +`/framerate` live toggle and notice remain intact, while acdream's already +shipped cross-launch ShowFps persistence is documented as AP-121 rather than +being attributed to the retail command body. + +## Frozen behavior and ordering + +1. Construct the controller before `Window.Create` and load Display, Audio, + Gameplay, Chat, and the `default` Character bag exactly once. +2. Resolve the preset plus environment overrides once for the immutable startup + snapshot. `WindowOptions.VSync` comes from the pacing policy initialized with + that snapshot; `WindowOptions.Samples` comes from the same resolved quality. +3. After window/GL input, camera, DAT effects, and optional audio exist, but + before devtools or world/render factories, apply startup settings in this + order: monitor-aware pacing, resolution/fullscreen, field of view, then audio. +4. Downstream terrain, dispatcher, render-range, and streaming factories read + the same startup quality snapshot. The legacy startup stream-radius override + remains at its current composition boundary. +5. Bind one complete `RuntimeSettingsTargets` only after window, UI (when + enabled), terrain atlas, dispatcher, render-range, and streaming exist. + Binding performs no display, quality, UI-lock, or audio replay. +6. Settings-panel Save preserves its callback order: keybindings, Display, + Audio, Gameplay, Chat, Character. Each section retains its existing + persistence/error boundary. +7. Display Save persists first, applies resolution/fullscreen, publishes the + new display snapshot, then reapplies quality. A changed MSAA sample count is + logged as restart-required and is not applied to the live GL context. +8. Runtime quality reapply preserves the existing order: alpha-to-coverage, + terrain anisotropy, render-range publication, streaming reconciliation, + completion budget. +9. `/framerate`, UI lock, loot permits, and combat preferences mutate the + controller's canonical state, synchronize the optional Settings draft using + the existing draft-preservation rules, and retain their existing save-error + behavior. +10. Enter-world changes the active toon key, loads that character bag even when + devtools are disabled, and publishes it to the optional Settings view model. + Session reset restores the startup/default character context before the + later identity-reset operation restores the key to `default`. +11. Per-frame display/audio preview reads the optional Settings draft through a + typed source. Particle range and FPS visibility read the same source so an + unsaved draft still previews exactly as it does today. +12. Shutdown first withdraws the Settings view-model and runtime-target loans; + the controller never disposes the window, renderer, UI, streaming, audio, or + input objects it borrows. + +## Types and ownership + +- `IRuntimeSettingsStorage` is the test seam for the five settings bags. + `JsonRuntimeSettingsStorage` is the production owner of the one concrete + `SettingsStore` and exposes that store only for retained-window layout + persistence. +- `RuntimeSettingsSnapshot` is the immutable startup value. +- `IRuntimeSettingsStartupTarget` receives the one startup Display/Audio apply. +- `IRuntimeSettingsTargets` receives future display-window, quality, and UI-lock + changes. `BindRuntimeTargets` stores it without invoking it. +- `IRuntimeSettingsPreviewSource` exposes current Display/Audio preview values + and whether a live draft exists. +- `RuntimeSettingsController` owns persistence, current values, active toon, + resolved quality, and the optional `SettingsVM` binding. It also implements + the combat settings source; no second gameplay-state mirror remains. +- `SilkRuntimeDisplayWindowTarget`, `RuntimeSettingsStartupTargets`, and + `RuntimeSettingsTargets` are App-layer adapters over borrowed runtime objects. + +## Automated gates + +- Construction loads every bag once and populates one immutable snapshot. +- Startup apply is ordered, exactly once, and independent of GL in controller + tests. +- Runtime target binding produces zero calls; unbinding prevents future target + calls while state and persistence still advance. +- Display Save ordering, quality transition, and restart-required MSAA logging. +- Quality target receives one complete resolved value per reapply. +- Draft preview/cancel, external FPS mutation, UI lock, loot permits, and combat + option synchronization. +- Toon activate/load/reset/save behavior with and without a Settings view model. +- Save failures preserve each established state/target boundary. +- Source-boundary test proves `GameWindow` no longer constructs or loads a + `SettingsStore`, owns persisted settings fields, or contains quality/display + feature bodies. +- Focused App tests, full App tests, Release solution build, and full Release + suite. + +## Review and commit gate + +After the focused tests pass, the existing retail-conformance, +architecture/integration, and adversarial reviewers inspect the complete diff +in parallel. Confirmed findings are corrected and the same reviewers repeat +until clean. Documentation, roadmap, issues, memory, `CLAUDE.md`, and +`AGENTS.md` are reconciled in the same bisectable Checkpoint G commit. + +## Implementation result + +- `RuntimeSettingsController` is constructed before `Window.Create`; all five + bags load once and `WindowOptions` consumes its immutable startup snapshot. +- Startup substeps commit independently, so an audio failure retries audio + without replaying already successful pacing/display/FOV work. +- Late target binding is inert and reversible; expected-instance SettingsVM + withdrawal cannot detach a replacement binding. +- The concrete quality adapter preserves all five application steps and the + unchanged MSAA restart boundary. +- Combat changes merge their three fields into a live Gameplay draft rather + than overwriting unrelated unsaved fields; successful external changes also + advance the SettingsVM persisted baseline so Cancel cannot resurrect stale + command state. +- UI-lock target, persistence, and baseline publication form one explicit + convergence state. A transient failure leaves the same requested value + retryable instead of letting the equality fast-path strand unfinished work. +- Enter/reset/toon changes load and publish Character state independently of + developer-tool availability. +- `GameWindow` no longer constructs or loads `SettingsStore`, owns persisted + settings mirrors, or contains display/quality/settings feature bodies. +- Candidate metrics: 3,663 raw lines / 162 fields / 37 methods, down 394 lines / + 36 fields / 17 methods from Checkpoint F and 12,060 lines (76.7%) from the + campaign baseline. +- Thirty-five focused App settings/boundary tests, all 43 `SettingsVMTests`, the + UI Abstractions Release suite (534 pass), and the App Release suite (3,183 + pass / 3 intentional skips) pass. All three independent corrected-diff + reviews are clean. The production App Release build has zero warnings/errors; + the solution Release build retains the 17 existing #228 test-project warnings; + and the complete Release suite passes 7,553 tests / 5 intentional skips. diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index 194b910a..61a38b97 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -25,7 +25,7 @@ audit. resize behind symmetric named subscriptions. - [x] F — extract the sole gameplay input-action router plus focused combat and diagnostic command owners. -- [ ] G — extract two-phase persisted settings/display/quality ownership. +- [x] G — extract two-phase persisted settings/display/quality ownership. - [ ] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots explicit single ownership and transfer seams. - [ ] I — group `OnLoad` into small ordered, fakeable composition phases with @@ -557,6 +557,29 @@ fields / 54 methods. - Preserve restart-required quality behavior and test transitions/previews without requiring GL. +**Implementation result:** one pre-window `RuntimeSettingsController` now owns +the concrete settings store, the immutable startup snapshot, current Display / +Audio / Gameplay / Chat / Character values, the active toon, resolved quality, +and the optional Settings draft. Startup applies pacing, window state, saved +FOV, and audio in order exactly once; a failed later substep retries without +replaying a successful earlier substep. Complete runtime targets bind only +after their borrowers exist and binding performs no replay. Display saves keep +the persistence → live window → snapshot → quality order; quality applies +alpha-to-coverage → anisotropy → render range → streaming → completion budget. +Combat preference changes merge only their three fields into a live draft, so +unrelated unsaved Gameplay edits survive. Saved FOV now applies without +devtools, correcting the old accidental SettingsVM gate. `/framerate` retains +the named-retail live toggle/notice and keeps acdream's shipped cross-launch +persistence as documented AP-121. UI-lock convergence requires target, +persistence, and Settings baseline publication; a failed substep keeps the +same-value command retryable. `GameWindow` is 3,663 raw lines / 162 fields / 37 +methods. Thirty-five focused App settings/boundary tests, all 43 +`SettingsVMTests`, the UI Abstractions Release suite (534 pass), and the App +Release suite (3,183 pass / 3 intentional skips) pass. All three independent +corrected-diff reviews are clean; the production App Release build has zero +warnings/errors, and the complete Release suite passes 7,553 tests / 5 +intentional skips (17 existing test-project warnings remain tracked by #228). + ### H — explicit single resource ownership and frame slots - Publish TerrainAtlas and the dedicated sky Shader as separate lifetime roots; diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index a5d1c192..de8cd214 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -3,9 +3,9 @@ ## Current state The behavior-preserving App ownership campaign is complete through Slice 7 and -Slice 8 checkpoints A–F. `GameWindow.cs` moved from the 2026-07-21 baseline of -15,723 lines / 278 fields / 205 methods to 4,057 lines / 198 fields / 54 -methods: 11,666 lines (74.2%) were removed without changing accepted gameplay +Slice 8 checkpoints A–G. `GameWindow.cs` moved from the 2026-07-21 baseline of +15,723 lines / 278 fields / 205 methods to 3,663 lines / 162 fields / 37 +methods: 12,060 lines (76.7%) were removed without changing accepted gameplay or rendering behavior. | Slice | Ownership moved out | Closeout size | @@ -19,6 +19,7 @@ or rendering behavior. | 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 | | 8 A–E | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 | | 8 F | sole gameplay action route, typed commands, retained-root callback lifetime | 4,057 / 198 / 54 | +| 8 G | pre-window/runtime settings ownership, one startup snapshot, inert late target binding | 3,663 / 162 / 37 | Slice 8 is an ordered checkpoint campaign rather than another feature-body move. A froze/pruned the shell boundary; B gave all nine native window callback @@ -55,6 +56,22 @@ review also corrected the carried default-combat mismatch: active combat short-circuits directly to peace, and an incompatible Held object prints the exact retail notice without a wire request or local mode mutation. +G adds `RuntimeSettingsController` as the sole concrete settings-store, +current-state, startup-snapshot, resolved-quality, active-toon, and optional +Settings-draft owner. It is constructed before `Window.Create`; startup applies +monitor pacing, window state, saved FOV, and audio in order once, with +substep-granular retry after failure. Complete runtime targets bind only after +UI/terrain/dispatcher/render-range/streaming exist and binding performs no +replay. The Settings save order and five-step quality application order remain +frozen. Combat mutations preserve unrelated unsaved Gameplay draft fields, and +successful external gameplay commands advance only their fields in the +SettingsVM persisted baseline so Cancel cannot resurrect stale command state. +UI-lock target, persistence, and baseline publication share an explicit +convergence flag so target/save failures remain retryable for the same value. +Saved FOV now applies without devtools, fixing the former accidental +SettingsVM gate. AP-121 records the intentional modern persistence added to +the named-retail `/framerate` live toggle/notice. + Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`, @@ -111,6 +128,12 @@ review rule live in `docs/architecture/code-structure.md`. ## Accepted verification +- Slice 8 Checkpoint G Release suite: 7,553 passed / 5 fixture or environment + skips; App 3,183/3 skips, UI Abstractions 534/0, 35 focused App + settings/boundary tests, and all 43 `SettingsVMTests` pass. The production App + build has zero warnings/errors; the complete solution retains the 17 existing + test-project warnings tracked by #228. All three independent corrected-diff + reviews are clean. - Slice 8 Checkpoint F Release suite: 7,524 passed / 5 fixture or environment skips; App 3,155/3 skips, 54 focused App ownership tests and 20 Core combat conformance tests pass, and all three independent corrected-diff reviews are @@ -147,8 +170,8 @@ review rule live in `docs/architecture/code-structure.md`. ## Next work -Slice 8 checkpoints G–L group the remaining settings, resource ownership, -startup, and shutdown wiring and reduce Silk callbacks to +Slice 8 checkpoints H–L group the remaining resource ownership, ordered +startup composition, and shutdown wiring and reduce Silk callbacks to narrow calls into input, update, render, resize, focus, and shutdown owners. It removes leftover feature state only where Slices 1–7 already established a canonical owner; it does not redesign gameplay, rendering, or resource lifetime. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e467ca3c..24818871 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,5 +1,6 @@ using AcDream.Core.Plugins; using AcDream.App.Physics; +using AcDream.App.Settings; using AcDream.App.World; using AcDream.Content; using DatReaderWriter; @@ -97,6 +98,7 @@ public sealed class GameWindow : IDisposable new(Console.WriteLine); private ResourceShutdownTransaction? _shutdown; private readonly DisplayFramePacingController _displayFramePacing; + private readonly RuntimeSettingsController _runtimeSettings; // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; @@ -124,14 +126,6 @@ public sealed class GameWindow : IDisposable get => _renderRange.FarRadius; set => _renderRange.FarRadius = value; } - // A.5 T22.5: resolved quality settings (preset + env-var overrides). - // Set once in OnLoad after LoadAndApplyPersistedSettings(); re-set on - // ReapplyQualityPreset(). Default matches QualityPreset.High so the field - // is valid before OnLoad fires (no GL calls are made before OnLoad anyway). - private AcDream.UI.Abstractions.Settings.QualitySettings _resolvedQuality = - AcDream.UI.Abstractions.Settings.QualitySettings.From( - AcDream.UI.Abstractions.Settings.QualityPreset.High); - // Phase B.3: physics engine — populated from the streaming pipeline. private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new(); @@ -563,6 +557,10 @@ public sealed class GameWindow : IDisposable _displayFramePacing = new DisplayFramePacingController( options.UncappedRendering, _frameProfiler); + _runtimeSettings = new RuntimeSettingsController( + new JsonRuntimeSettingsStorage( + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()), + log: Console.WriteLine); _selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _uiRegistry = uiRegistry; @@ -594,17 +592,13 @@ public sealed class GameWindow : IDisposable public void Run() { - // A.5 T22.5: resolve quality preset BEFORE creating the window so - // Samples (MSAA) is baked into WindowOptions correctly. GL context - // sample count cannot change at runtime; all other quality fields are - // applied again in OnLoad after the full settings load. - var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( - AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - var startupDisplay = startupStore.LoadDisplay(); - var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); - var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); + // The runtime-settings owner was constructed before Window.Create and + // exposes the one immutable startup snapshot. MSAA is a context + // attribute, so it must come from this same snapshot rather than a + // second settings load during OnLoad. + RuntimeSettingsSnapshot startup = _runtimeSettings.Startup; FramePacingPolicy startupPacing = - _displayFramePacing.InitializeStartup(startupDisplay.VSync); + _displayFramePacing.InitializeStartup(startup.Display.VSync); var options = WindowOptions.Default with { Size = new Vector2D(1280, 720), @@ -619,7 +613,7 @@ public sealed class GameWindow : IDisposable // Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES). // Cannot be changed at runtime; Quality changes mid-session that would // alter MsaaSamples are logged as a restart-required warning. - Samples = startupQuality.MsaaSamples, + Samples = startup.Quality.MsaaSamples, // #117 (2026-06-11): the aperture punch's depth gate needs a // stencil buffer (PortalDepthMaskRenderer two-pass mark+punch). // GLFW defaults to 8 stencil bits, but make the requirement @@ -843,28 +837,15 @@ public sealed class GameWindow : IDisposable } } - // L.0 follow-up — load + apply persisted Display / Audio settings - // BEFORE the DevToolsEnabled block. The settings.json values - // (resolution, vsync, FOV, master volume, etc) are runtime - // settings, not devtools settings — a user running without - // ACDREAM_DEVTOOLS=1 still expects their saved values to take - // effect. The Settings PANEL (editing UI) is gated on devtools; - // the persisted state is not. Caches values into fields so the - // SettingsVM construction in the devtools block reads them - // without re-loading. - LoadAndApplyPersistedSettings(); - - // A.5 T22.5: resolve quality preset immediately after settings load so - // _resolvedQuality is available for TerrainAtlas.SetAnisotropic, - // WbDrawDispatcher.AlphaToCoverage, and StreamingController wiring below. - { - var qBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(_persistedDisplay.Quality); - _resolvedQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(qBase); - if (!_resolvedQuality.Equals(qBase)) - Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} overridden by env vars: {_resolvedQuality}"); - else - Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} → {_resolvedQuality}"); - } + // Settings are runtime state, not devtools state. Apply the immutable + // pre-window snapshot once after camera/audio exist and before any + // optional frontend or world/render factory consumes it. + _runtimeSettings.ApplyStartup( + new RuntimeSettingsStartupTargets( + new SilkRuntimeDisplayWindowTarget(_window!), + _displayFramePacing, + _cameraController!, + _audioEngine)); // Phase D.2a — ImGui devtools overlay. Zero cost when the env var // isn't set: no context creation, no per-frame branches hit. @@ -872,6 +853,7 @@ public sealed class GameWindow : IDisposable if (DevToolsEnabled) { AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null; + AcDream.UI.Abstractions.Panels.Settings.SettingsVM? settingsVm = null; try { _devToolsInputContext = new AcDream.App.Input.QuiescentInputContext( @@ -984,19 +966,12 @@ public sealed class GameWindow : IDisposable // dispatcher because the dispatcher is built earlier in // the same OnLoad path (see _inputDispatcher field). AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? settingsPanel = null; - if (_inputDispatcher is not null && _settingsStore is not null) + if (_inputDispatcher is not null) { - // L.0 — SettingsStore + persisted-settings load + apply - // happened earlier in OnLoad via - // LoadAndApplyPersistedSettings (settings are runtime - // state, not devtools state — they take effect even - // when ACDREAM_DEVTOOLS=0). Here we just construct the - // Settings PANEL on top of the already-loaded values. - var settingsStore = _settingsStore; - _settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM( - persisted: _keyBindings, - dispatcher: _inputDispatcher, - onSave: bindings => + settingsVm = _runtimeSettings.CreateViewModel( + _keyBindings, + _inputDispatcher, + bindings => { _inputDispatcher.SetBindings(bindings); try @@ -1011,120 +986,9 @@ public sealed class GameWindow : IDisposable { Console.WriteLine($"keybinds: save failed: {ex.Message}"); } - }, - persistedDisplay: _persistedDisplay, - onSaveDisplay: display => - { - try - { - settingsStore.SaveDisplay(display); - Console.WriteLine( - "settings: display saved to " - + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - // Apply window-level changes that are too - // jarring to live-preview (resolution + - // fullscreen). VSync / FOV / ShowFps - // already track DisplayDraft via the - // per-frame push. - ApplyDisplayWindowState(display); - // A.5 T22.5: apply quality preset if it changed. - // MSAA changes log a restart-required warning - // inside ReapplyQualityPreset; all other fields - // apply immediately. - _persistedDisplay = display; - ReapplyQualityPreset(display.Quality); - } - catch (Exception ex) - { - Console.WriteLine($"settings: display save failed: {ex.Message}"); - } - }, - persistedAudio: _persistedAudio, - onSaveAudio: audio => - { - try - { - settingsStore.SaveAudio(audio); - Console.WriteLine( - "settings: audio saved to " - + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - } - catch (Exception ex) - { - Console.WriteLine($"settings: audio save failed: {ex.Message}"); - } - }, - persistedGameplay: _persistedGameplay, - onSaveGameplay: gameplay => - { - try - { - settingsStore.SaveGameplay(gameplay); - // Runtime consumers (including retained gmRadarUI) read - // the live persisted snapshot, not SettingsVM's draft. - _persistedGameplay = gameplay; - if (_uiHost is not null) - _uiHost.Root.UiLocked = gameplay.LockUI; - Console.WriteLine( - "settings: gameplay saved to " - + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - // Local-only this phase. Server-sync packet - // (CharacterOption bitmask) goes in here when - // the protocol round-trip is in place. - } - catch (Exception ex) - { - Console.WriteLine($"settings: gameplay save failed: {ex.Message}"); - } - }, - persistedChat: _persistedChat, - onSaveChat: chat => - { - try - { - settingsStore.SaveChat(chat); - Console.WriteLine( - "settings: chat saved to " - + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - // Channel filters affect client-side display - // only this phase. ChatPanel will read them - // off SettingsVM.ChatDraft when filtering is - // wired into the chat-line render path. - } - catch (Exception ex) - { - Console.WriteLine($"settings: chat save failed: {ex.Message}"); - } - }, - persistedCharacter: _persistedCharacter, - onSaveCharacter: character => - { - try - { - // _activeToonKey is updated by - // LiveSessionHost entered-world transition - // so saving character settings always - // writes under the chosen character's - // name (or "default" pre-login). - settingsStore.SaveCharacter(_activeToonKey, character); - if (string.Equals( - _activeToonKey, - "default", - StringComparison.OrdinalIgnoreCase)) - { - _persistedCharacter = character; - } - Console.WriteLine( - $"settings: character[{_activeToonKey}] saved to " - + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - } - catch (Exception ex) - { - Console.WriteLine($"settings: character save failed: {ex.Message}"); - } }); settingsPanel = - new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm); + new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(settingsVm); panelHost.Register(settingsPanel); } @@ -1185,7 +1049,7 @@ public sealed class GameWindow : IDisposable _vitalsVm = null; _combatFeedback.ViewModel = null; _debugVm = null; - _settingsVm = null; + _runtimeSettings.UnbindViewModel(settingsVm); } } @@ -1240,7 +1104,7 @@ public sealed class GameWindow : IDisposable // A.5 T22.5: apply anisotropic level from quality preset. Build() // hard-codes 16x; override here to match the resolved quality so Low // (4x) and Medium (8x) actually take effect. - terrainAtlas.SetAnisotropic(_resolvedQuality.AnisotropicLevel); + terrainAtlas.SetAnisotropic(_runtimeSettings.ResolvedQuality.AnisotropicLevel); _terrain = new TerrainModernRenderer( _gl, @@ -1302,7 +1166,7 @@ public sealed class GameWindow : IDisposable _combatTargetController = new AcDream.App.Combat.CombatTargetController( Combat, _selection, - autoTarget: () => _persistedGameplay.AutoTarget, + autoTarget: () => _runtimeSettings.Gameplay.AutoTarget, selectClosestTarget: () => SelectClosestCombatTarget(showToast: false)); _externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController( _externalContainers, @@ -1372,7 +1236,7 @@ public sealed class GameWindow : IDisposable _debugFont, _hostQuiescence); _retainedInputCapture.Root = _uiHost.Root; - _uiHost.Root.UiLocked = _persistedGameplay.LockUI; + _uiHost.Root.UiLocked = _runtimeSettings.Gameplay.LockUI; var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( _itemInteractionController, // Retail UpdateCursorState (0x00564630) keys target-mode @@ -1384,7 +1248,7 @@ public sealed class GameWindow : IDisposable _characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider( Objects, LocalPlayer, playerGuid: () => _playerServerGuid, - activeToonName: () => _activeToonKey, + activeToonName: () => _runtimeSettings.ActiveToonKey, fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter, canSendRaise: () => _liveSessionHost?.IsInWorld == true, sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost), @@ -1463,17 +1327,19 @@ public sealed class GameWindow : IDisposable playerYawRadians: () => _playerController?.Yaw ?? 0f, playerCellId: () => _playerController?.CellId ?? 0u, selectedGuid: () => _selection.SelectedObjectId, - coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar, - uiLocked: () => _persistedGameplay.LockUI, + coordinatesOnRadar: () => _runtimeSettings.Gameplay.CoordinatesOnRadar, + uiLocked: () => _runtimeSettings.Gameplay.LockUI, playerEntities: () => _entitiesByServerGuid, spatialQuery: () => _worldState); var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); _retailChatVm = retailChatVm; - AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null + AcDream.UI.Abstractions.Panels.Settings.SettingsStore? layoutStore = + _runtimeSettings.LayoutStore; + AcDream.App.UI.RetailUiPersistenceBindings? persistence = layoutStore is null ? null : new AcDream.App.UI.RetailUiPersistenceBindings( - _settingsStore, - CharacterKey: () => _activeToonKey, + layoutStore, + CharacterKey: () => _runtimeSettings.ActiveToonKey, ScreenSize: () => (_window.Size.X, _window.Size.Y)); void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message); @@ -1508,12 +1374,12 @@ public sealed class GameWindow : IDisposable Radar: new AcDream.App.UI.RadarRuntimeBindings( radarSnapshotProvider.BuildSnapshot, _selection, - SetRetailUiLocked), + _runtimeSettings.SetUiLocked), Combat: new AcDream.App.UI.CombatRuntimeBindings( Combat, _combatAttackController, - () => _persistedGameplay, - SetRetailCombatGameplay), + () => _runtimeSettings.Gameplay, + _runtimeSettings.SetCombatGameplay), Magic: new AcDream.App.UI.MagicRuntimeBindings( SpellBook, _magicRuntime.Casting, @@ -1549,12 +1415,11 @@ public sealed class GameWindow : IDisposable // adaptive distance degradation (tracked by TS-15), so its // effective multiplier is exactly 1.0. () => 1.0, - () => _settingsVm?.DisplayDraft.ShowFps - ?? _persistedDisplay.ShowFps), + () => _runtimeSettings.DisplayPreview.ShowFps), VividTarget: new AcDream.App.UI.Layout.VividTargetRuntimeBindings( _selection, () => _playerServerGuid, - () => _persistedGameplay.VividTargetingIndicator, + () => _runtimeSettings.Gameplay.VividTargetingIndicator, ResolveVividTargetInfo, GetSelectionCamera), Indicators: new AcDream.App.UI.IndicatorRuntimeBindings( @@ -2019,7 +1884,8 @@ public sealed class GameWindow : IDisposable _retainedUiGameplayBinding.Attach(); } // A.5 T22.5: apply A2C gate from quality preset. - _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; + _wbDrawDispatcher.AlphaToCoverage = + _runtimeSettings.ResolvedQuality.AlphaToCoverage; // Slice 2: now that the dispatcher exists, build the paperdoll doll RTT renderer and hand it to // the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only @@ -2163,12 +2029,11 @@ public sealed class GameWindow : IDisposable _options.UiProbeDump, resourceDiagnostics); - // A.5 T22.5: apply radii from the already-resolved _resolvedQuality. - // _resolvedQuality was set by the quality block immediately after - // LoadAndApplyPersistedSettings() above, absorbing all env-var overrides. + // Apply radii from the same immutable quality snapshot used for the + // window's MSAA, terrain anisotropy, and dispatcher A2C setup. // Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat. - _nearRadius = _resolvedQuality.NearRadius; - _farRadius = _resolvedQuality.FarRadius; + _nearRadius = _runtimeSettings.ResolvedQuality.NearRadius; + _farRadius = _runtimeSettings.ResolvedQuality.FarRadius; // Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and // ensures farRadius >= streamRadius. Parsed once into @@ -2233,7 +2098,17 @@ public sealed class GameWindow : IDisposable _streamingController, _liveWorldOrigin); // A.5 T22.5: apply max-completions from resolved quality. - _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; + _streamingController.MaxCompletionsPerFrame = + _runtimeSettings.ResolvedQuality.MaxCompletionsPerFrame; + _runtimeSettings.BindRuntimeTargets( + new RuntimeSettingsTargets( + new SilkRuntimeDisplayWindowTarget(_window!), + _wbDrawDispatcher!, + terrainAtlas, + _streamingController, + _renderRange, + _uiHost?.Root, + Console.WriteLine)); _worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator( isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident, isSpawnCellReady: _physicsEngine.IsSpawnCellReady, @@ -2397,7 +2272,7 @@ public sealed class GameWindow : IDisposable _liveEntities!, Objects, _localPlayerIdentity), - _gameplaySettings, + _runtimeSettings, _playerControllerSlot, _localPlayerOutbound, _liveSessionController, @@ -2452,8 +2327,7 @@ public sealed class GameWindow : IDisposable _scriptRunner!, _updateFrameClock, new AcDream.App.Update.SettingsParticleRangeSource( - _settingsVm, - _persistedDisplay.ParticleRange)); + _runtimeSettings)); var liveSpatialReconciler = new AcDream.App.Update.LiveSpatialPresentationReconciler( _entityEffects!, @@ -2659,7 +2533,7 @@ public sealed class GameWindow : IDisposable _worldReveal, _envCellFrustum), new AcDream.App.Rendering.RuntimeWorldFrameSettingsPreview( - _settingsVm, + _runtimeSettings, _audioEngine, _cameraController!, _displayFramePacing), @@ -2803,7 +2677,7 @@ public sealed class GameWindow : IDisposable localPlayerFrame, liveSpatialReconciler, new AcDream.App.Combat.CombatCameraTargetSource( - _gameplaySettings, + _runtimeSettings, Combat, _selection, _worldSelectionQuery!)); @@ -2929,10 +2803,10 @@ public sealed class GameWindow : IDisposable AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = id, ClearCombat: Combat.Clear), EnteredWorld: new( - SetActiveCharacter: name => _activeToonKey = name, + SetActiveCharacter: _runtimeSettings.SetActiveCharacter, RestoreLayout: () => _retailUiRuntime?.RestoreLayout(), SyncToolbar: SyncToolbarWindowButtons, - LoadCharacterSettings: LoadLiveSessionCharacterSettings, + LoadCharacterSettings: _runtimeSettings.LoadCharacterContext, ArmPlayerModeAutoEntry: () => _playerModeAutoEntry?.Arm()), Connecting: (host, port, user) => Chat.OnSystemMessage( @@ -2943,17 +2817,6 @@ public sealed class GameWindow : IDisposable "connected — character list received", chatType: 1))); - private void LoadLiveSessionCharacterSettings(string characterName) - { - if (_settingsStore is not null && _settingsVm is not null) - { - var toonBag = _settingsStore.LoadCharacter(characterName); - _settingsVm.LoadCharacterContext(toonBag); - Console.WriteLine( - $"settings: loaded character[{characterName}] preferences"); - } - } - private AcDream.App.Net.LiveSessionResetBindings CreateLiveSessionResetBindings() => new() { @@ -2962,8 +2825,7 @@ public sealed class GameWindow : IDisposable TeleportTransit = _localPlayerTeleportSink.ResetSession, SessionDialogs = () => _retailUiRuntime?.ResetSessionDialogs(), ChatCommandTargets = () => _retailChatVm?.ResetSessionTargets(), - SettingsCharacterContext = () => - _settingsVm?.LoadCharacterContext(_persistedCharacter), + SettingsCharacterContext = _runtimeSettings.RestoreDefaultCharacterContext, EquippedChildren = () => _equippedChildRenderer?.Clear(), ExternalContainer = () => _externalContainers.Reset(), InteractionAndSelection = ResetSessionInteraction, @@ -3011,7 +2873,7 @@ public sealed class GameWindow : IDisposable _vitalsVm?.SetLocalPlayerGuid(0u); Chat.ResetSessionIdentity(); AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u; - _activeToonKey = "default"; + _runtimeSettings.ResetActiveCharacterKey(); _characterOptions1 = AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; _localPlayerSkills.ResetSession(); @@ -3132,8 +2994,9 @@ public sealed class GameWindow : IDisposable TeleportToMansion: session.SendTeleportToMansion, QueryAge: session.SendQueryAge, QueryBirth: session.SendQueryBirth, - ToggleFrameRate: ToggleRetailFrameRate, - ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI), + ToggleFrameRate: _runtimeSettings.ToggleFrameRate, + ToggleUiLock: () => + _runtimeSettings.SetUiLocked(!_runtimeSettings.Gameplay.LockUI), ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u), ShowWeenieError: code => Chat.OnWeenieError(code, null), PlayerPublicWeenieBitfield: () => @@ -3155,8 +3018,8 @@ public sealed class GameWindow : IDisposable Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true, SetAway: away => SetRetailAway(session, away), SetAwayMessage: session.SendSetAfkMessage, - AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits, - SetAcceptLootPermits: SetRetailAcceptLootPermits, + AcceptLootPermits: () => _runtimeSettings.Gameplay.AcceptLootPermits, + SetAcceptLootPermits: _runtimeSettings.SetAcceptLootPermits, DisplayConsent: session.SendDisplayConsent, ClearConsent: session.SendClearConsent, RemoveConsent: session.SendRemoveConsent, @@ -3327,238 +3190,20 @@ public sealed class GameWindow : IDisposable private bool GetDebugPlayerOnGround() => _playerMode && _playerController is not null && !_playerController.IsAirborne; - // Phase K.3 settings state remains runtime-owned; the presenter owns the - // optional SettingsPanel and its visibility/input operations. - private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm; - // L.0: settings.json store + active toon key. The store is held as - // a field so LiveSessionHost can re-load the chosen toon's - // bag once we know its name (post-EnterWorld). Toon key starts as - // "default" and gets swapped to the actual character name on the - // first EnterWorld. - private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore; - private string _activeToonKey = "default"; - private void SyncToolbarWindowButtons() { _retailUiRuntime?.SyncToolbarWindowButtons(); } - private void SetRetailUiLocked(bool locked) - { - if (_persistedGameplay.LockUI == locked) - return; - - _persistedGameplay = _persistedGameplay with { LockUI = locked }; - if (_uiHost is not null) - _uiHost.Root.UiLocked = locked; - if (_settingsVm is not null) - _settingsVm.SetGameplay(_settingsVm.GameplayDraft with { LockUI = locked }); - - try - { - _settingsStore?.SaveGameplay(_persistedGameplay); - } - catch (Exception ex) - { - Console.WriteLine($"settings: radar lock save failed: {ex.Message}"); - } - } - private void SetRetailAway(AcDream.Core.Net.WorldSession session, bool away) { session.SendSetAfkMode(away); } - private void SetRetailAcceptLootPermits(bool enabled) - { - _persistedGameplay = _persistedGameplay with { AcceptLootPermits = enabled }; - _settingsVm?.SetGameplay( - _settingsVm.GameplayDraft with { AcceptLootPermits = enabled }); - _settingsStore?.SaveGameplay(_persistedGameplay); - } - - /// - /// Retail ClientCommunicationSystem::DoFrameRate @ 0x005707D0: - /// flip the live display flag and persist it through the same settings - /// path used by the Display panel. - /// - private void ToggleRetailFrameRate() - { - _persistedDisplay = _persistedDisplay with - { - ShowFps = !_persistedDisplay.ShowFps, - }; - if (_settingsVm is not null) - _settingsVm.ApplyExternalDisplayChange(display => display with - { - ShowFps = _persistedDisplay.ShowFps, - }); - - try - { - _settingsStore?.SaveDisplay(_persistedDisplay); - } - catch (Exception ex) - { - Console.WriteLine($"settings: framerate display save failed: {ex.Message}"); - } - } - - private void SetRetailCombatGameplay( - AcDream.UI.Abstractions.Panels.Settings.GameplaySettings gameplay) - { - _persistedGameplay = gameplay; - _settingsVm?.SetGameplay(gameplay); - - try - { - _settingsStore?.SaveGameplay(gameplay); - } - catch (Exception ex) - { - Console.WriteLine($"settings: combat option save failed: {ex.Message}"); - } - } - - // L.0 follow-up: persisted-settings cache populated by - // LoadAndApplyPersistedSettings (runs unconditionally in OnLoad, - // not gated on DevToolsEnabled). The Settings PANEL construction - // — which IS gated on devtools — reads these fields when wiring - // SettingsVM. Defaults are placeholders; LoadAndApplyPersistedSettings - // overwrites them with values from settings.json (or per-section - // defaults when the file is missing/corrupt). - private AcDream.UI.Abstractions.Panels.Settings.DisplaySettings _persistedDisplay - = AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default; - private AcDream.UI.Abstractions.Panels.Settings.AudioSettings _persistedAudio - = AcDream.UI.Abstractions.Panels.Settings.AudioSettings.Default; - private readonly AcDream.App.Combat.GameplaySettingsState - _gameplaySettings = new(); - private AcDream.UI.Abstractions.Panels.Settings.GameplaySettings _persistedGameplay - { - get => _gameplaySettings.Value; - set => _gameplaySettings.Value = value; - } - private AcDream.UI.Abstractions.Panels.Settings.ChatSettings _persistedChat - = AcDream.UI.Abstractions.Panels.Settings.ChatSettings.Default; - private AcDream.UI.Abstractions.Panels.Settings.CharacterSettings _persistedCharacter - = AcDream.UI.Abstractions.Panels.Settings.CharacterSettings.Default; - - /// - /// L.0 follow-up: load every section from settings.json + apply the - /// runtime-affecting ones (Display window state + Audio engine - /// volumes) at startup. Runs unconditionally — settings are runtime - /// state, not devtools state. Without this, a user running with - /// ACDREAM_DEVTOOLS=0 would silently get WindowOptions - /// defaults instead of their saved Display/Audio preferences. - /// - private void LoadAndApplyPersistedSettings() - { - _settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( - AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - _persistedDisplay = _settingsStore.LoadDisplay(); - _persistedAudio = _settingsStore.LoadAudio(); - _persistedGameplay = _settingsStore.LoadGameplay(); - _persistedChat = _settingsStore.LoadChat(); - // _activeToonKey is "default" pre-EnterWorld; the post-login - // LiveSessionHost swaps to the chosen toon's - // name and re-loads via SettingsVM.LoadCharacterContext. - _persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey); - - // Apply Display to the Silk.NET window. VSync and its bounded - // refresh-rate fallback go through the shared pacing owner; - // resolution + fullscreen use the on-Save path below. - if (_window is not null) - { - _displayFramePacing.RefreshActiveMonitor(); - _displayFramePacing.ApplyPreference(_persistedDisplay.VSync); - ApplyDisplayWindowState(_persistedDisplay); - } - - // Apply Audio to the OpenAL engine. Master + Sfx are wired - // through to the engine; Music + Ambient are stored but inert - // until R5 MIDI/ambient-loop engines exist (assigning them is - // harmless — the engine just doesn't read them yet). - if (_audioEngine is not null && _audioEngine.IsAvailable) - { - _audioEngine.MasterVolume = _persistedAudio.Master; - _audioEngine.MusicVolume = _persistedAudio.Music; - _audioEngine.SfxVolume = _persistedAudio.Sfx; - _audioEngine.AmbientVolume = _persistedAudio.Ambient; - } - } - - /// - /// A.5 T22.5: apply a new quality preset mid-session (called from the - /// Settings panel Save path when - /// changes). - /// - /// - /// What changes immediately: - /// - /// Streaming radii: transactionally reconciles the existing - /// against its published world; - /// the worker and controller retain their identity. - /// Anisotropic filtering: calls - /// TerrainAtlas.SetAnisotropic. - /// Alpha-to-coverage gate: sets - /// WbDrawDispatcher.AlphaToCoverage. - /// Max completions per frame: updates - /// StreamingController.MaxCompletionsPerFrame. - /// - /// - /// - /// - /// What requires a restart: - /// MSAA samples are baked into the GL context via WindowOptions.Samples - /// at window creation time and cannot change at runtime. If the new preset - /// would change MsaaSamples, a warning is logged and MSAA is left - /// at its current level until the next launch. - /// - /// - public void ReapplyQualityPreset(AcDream.UI.Abstractions.Settings.QualityPreset newPreset) - { - var newBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(newPreset); - var newResolved = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(newBase); - - Console.WriteLine($"[QUALITY] ReapplyQualityPreset: {newPreset} → {newResolved}"); - - // MSAA samples cannot change at runtime — warn if preset would differ. - if (newResolved.MsaaSamples != _resolvedQuality.MsaaSamples) - { - Console.WriteLine( - $"[QUALITY] MSAA samples change ({_resolvedQuality.MsaaSamples} → " + - $"{newResolved.MsaaSamples}) requires a restart — skipped for this session."); - } - - _resolvedQuality = newResolved; - - // A2C gate — immediate toggle, no GL context restart needed. - if (_wbDrawDispatcher is not null) - _wbDrawDispatcher.AlphaToCoverage = newResolved.AlphaToCoverage; - - // Anisotropic — immediate GL TexParameter call on the terrain atlas. - _terrain?.Atlas?.SetAnisotropic(newResolved.AnisotropicLevel); - - // Reconcile streaming radii against the current published world. This - // preserves resident blocks that remain in range and never rebuilds - // the worker merely to change a completion budget. - if (_streamingController is not null) - { - _nearRadius = newResolved.NearRadius; - _farRadius = newResolved.FarRadius; - _streamingController.ReconfigureRadii(_nearRadius, _farRadius); - _streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame; - - Console.WriteLine( - $"[QUALITY] Streaming reconciled: nearRadius={_nearRadius}, " + - $"farRadius={_farRadius}, maxCompletions={newResolved.MaxCompletionsPerFrame}"); - } - } - /// /// L.0 Display tab: framebuffer-resize handler — update GL viewport /// + camera aspect when the window is resized (by the user dragging - /// the corner OR by ApplyDisplayWindowState applying a saved + /// the corner OR by the runtime display target applying a saved /// Resolution). Without this, the viewport stays pinned at the /// startup size, producing a small render inside a big window. /// Also force-resets ImGui panel layout so panels that were @@ -3567,47 +3212,6 @@ public sealed class GameWindow : IDisposable private void OnFramebufferResize(Silk.NET.Maths.Vector2D newSize) => _framebufferResize.Resize(newSize); - /// - /// L.0 Display tab: apply the window-state-dependent settings - /// (Resolution + Fullscreen) from a - /// to the live Silk.NET window. Called at startup (with persisted - /// values) and on every Save (with the saved values). Resolution - /// parses "WIDTHxHEIGHT" (e.g. "1920x1080"); a malformed - /// or unparseable string is silently ignored to avoid crashing the - /// client mid-session. - /// - private void ApplyDisplayWindowState( - AcDream.UI.Abstractions.Panels.Settings.DisplaySettings display) - { - if (_window is null) return; - - // Resolution: parse and resize if changed. - if (TryParseResolution(display.Resolution, out int w, out int h)) - { - if (_window.Size.X != w || _window.Size.Y != h) - _window.Size = new Silk.NET.Maths.Vector2D(w, h); - } - - // Fullscreen: borderless via Silk.NET's WindowState.Fullscreen - // (no exclusive-mode DXGI dance needed). - var desiredState = display.Fullscreen - ? Silk.NET.Windowing.WindowState.Fullscreen - : Silk.NET.Windowing.WindowState.Normal; - if (_window.WindowState != desiredState) - _window.WindowState = desiredState; - } - - private static bool TryParseResolution(string spec, out int width, out int height) - { - width = height = 0; - if (string.IsNullOrWhiteSpace(spec)) return false; - var parts = spec.Split('x', 2); - if (parts.Length != 2) return false; - return int.TryParse(parts[0], out width) - && int.TryParse(parts[1], out height) - && width > 0 - && height > 0; - } /// /// Item-target-mode world pick at the current cursor. The renderer supplies the /// exact visible CPhysicsPart equivalents and RetailWorldPicker performs @@ -3763,6 +3367,7 @@ public sealed class GameWindow : IDisposable ]), new ResourceShutdownStage("input callback detach", [ + new("settings view model", () => _runtimeSettings.UnbindViewModel()), new("retained gameplay", () => { AcDream.App.Input.RetainedUiGameplayBinding? binding = @@ -3836,6 +3441,7 @@ public sealed class GameWindow : IDisposable // the later render-frontend stage still disposes the GL owners. new ResourceShutdownStage("frame borrowers", [ + new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets), new("world frame composition", () => { _renderFrameOrchestrator = null; diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index 471716fa..57d6b0a2 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -444,7 +444,8 @@ public sealed unsafe class TerrainAtlas : IDisposable /// /// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at - /// runtime (called by when + /// runtime (called by + /// when /// the user changes Quality preset mid-session). Idempotent — calling with /// the same level as the current setting is safe and produces no visual /// change. The texture must not be resident-bindless when its parameters diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index cb230e04..f46d47fe 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -378,7 +378,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// Default true matches T20 behavior. Set false for Low/Medium presets that /// have MsaaSamples=0 (A2C is a no-op without MSAA, but turning it off /// avoids the unnecessary GL state thrash and is cleaner diagnostics). - /// Can be toggled mid-session via . + /// Can be toggled mid-session via + /// . /// public bool AlphaToCoverage { get; set; } = true; diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index 06e1632b..35ccbc20 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -4,13 +4,13 @@ using AcDream.App.Input; using AcDream.App.Rendering.Selection; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; +using AcDream.App.Settings; using AcDream.App.Streaming; using AcDream.App.World; using AcDream.Core.Lighting; using AcDream.Core.Physics; using AcDream.Core.Rendering; using AcDream.Core.World; -using AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.App.Rendering; @@ -348,18 +348,18 @@ internal sealed class RuntimeWorldFrameVisibilityPreparation internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPreview { - private readonly SettingsVM? _settings; + private readonly IRuntimeSettingsPreviewSource _settings; private readonly OpenAlAudioEngine? _audio; private readonly CameraController _cameras; private readonly DisplayFramePacingController _pacing; public RuntimeWorldFrameSettingsPreview( - SettingsVM? settings, + IRuntimeSettingsPreviewSource settings, OpenAlAudioEngine? audio, CameraController cameras, DisplayFramePacingController pacing) { - _settings = settings; + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); _audio = audio; _cameras = cameras ?? throw new ArgumentNullException(nameof(cameras)); _pacing = pacing ?? throw new ArgumentNullException(nameof(pacing)); @@ -367,23 +367,15 @@ internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPrev public void Apply(in WorldCameraFrame camera) { - if (_audio is { IsAvailable: true } && _settings is not null) + if (_settings.HasDraftPreview) { - var audio = _settings.AudioDraft; - _audio.MasterVolume = audio.Master; - _audio.MusicVolume = audio.Music; - _audio.SfxVolume = audio.Sfx; - _audio.AmbientVolume = audio.Ambient; - } - - if (_settings is not null) - { - var display = _settings.DisplayDraft; - float fieldOfView = display.FieldOfView * (MathF.PI / 180f); - _cameras.Orbit.FovY = fieldOfView; - _cameras.Fly.FovY = fieldOfView; - if (_cameras.Chase is not null) - _cameras.Chase.FovY = fieldOfView; + RuntimeSettingsStartupTargets.ApplyAudio( + _audio, + _settings.AudioPreview); + var display = _settings.DisplayPreview; + RuntimeSettingsStartupTargets.ApplyFieldOfView( + _cameras, + display.FieldOfView); _pacing.ApplyPreference(display.VSync); } diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs new file mode 100644 index 00000000..e4e573e8 --- /dev/null +++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs @@ -0,0 +1,471 @@ +using AcDream.App.Combat; +using AcDream.UI.Abstractions.Input; +using AcDream.UI.Abstractions.Panels.Settings; +using AcDream.UI.Abstractions.Settings; + +namespace AcDream.App.Settings; + +internal interface IRuntimeSettingsStorage +{ + SettingsStore? LayoutStore { get; } + + string Location { get; } + + DisplaySettings LoadDisplay(); + + AudioSettings LoadAudio(); + + GameplaySettings LoadGameplay(); + + ChatSettings LoadChat(); + + CharacterSettings LoadCharacter(string toonKey); + + void SaveDisplay(DisplaySettings display); + + void SaveAudio(AudioSettings audio); + + void SaveGameplay(GameplaySettings gameplay); + + void SaveChat(ChatSettings chat); + + void SaveCharacter(string toonKey, CharacterSettings character); +} + +internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage +{ + private readonly SettingsStore _store; + + public JsonRuntimeSettingsStorage(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + Location = path; + _store = new SettingsStore(path); + } + + public SettingsStore LayoutStore => _store; + + public string Location { get; } + + public DisplaySettings LoadDisplay() => _store.LoadDisplay(); + + public AudioSettings LoadAudio() => _store.LoadAudio(); + + public GameplaySettings LoadGameplay() => _store.LoadGameplay(); + + public ChatSettings LoadChat() => _store.LoadChat(); + + public CharacterSettings LoadCharacter(string toonKey) => + _store.LoadCharacter(toonKey); + + public void SaveDisplay(DisplaySettings display) => _store.SaveDisplay(display); + + public void SaveAudio(AudioSettings audio) => _store.SaveAudio(audio); + + public void SaveGameplay(GameplaySettings gameplay) => + _store.SaveGameplay(gameplay); + + public void SaveChat(ChatSettings chat) => _store.SaveChat(chat); + + public void SaveCharacter(string toonKey, CharacterSettings character) => + _store.SaveCharacter(toonKey, character); +} + +internal sealed record RuntimeSettingsSnapshot( + DisplaySettings Display, + AudioSettings Audio, + GameplaySettings Gameplay, + ChatSettings Chat, + CharacterSettings Character, + QualitySettings Quality); + +internal interface IRuntimeSettingsStartupTarget +{ + void ApplyDisplay(DisplaySettings display); + + void ApplyAudio(AudioSettings audio); +} + +internal interface IRuntimeSettingsTargets +{ + void ApplyDisplayWindowState(DisplaySettings display); + + void ApplyQuality(QualitySettings quality); + + void ApplyUiLock(bool locked); +} + +internal interface IRuntimeSettingsPreviewSource +{ + bool HasDraftPreview { get; } + + DisplaySettings DisplayPreview { get; } + + AudioSettings AudioPreview { get; } +} + +/// +/// Owns the one persisted settings snapshot, its live mutations, and the active +/// character context. Runtime objects are borrowed through typed targets and +/// are never constructed or disposed here. +/// +internal sealed class RuntimeSettingsController : + IRuntimeSettingsPreviewSource, + ICombatGameplaySettingsSource +{ + private const string DefaultToonKey = "default"; + + private readonly IRuntimeSettingsStorage _storage; + private readonly Func _resolveQuality; + private readonly Action _log; + private IRuntimeSettingsTargets? _runtimeTargets; + private SettingsVM? _viewModel; + private CharacterSettings _defaultCharacter; + private bool _startupDisplayApplied; + private bool _startupAudioApplied; + private bool _startupApplied; + private bool _uiLockConverged = true; + + public RuntimeSettingsController( + IRuntimeSettingsStorage storage, + Func? resolveQuality = null, + Action? log = null) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _resolveQuality = resolveQuality ?? ResolveQuality; + _log = log ?? Console.WriteLine; + + Display = _storage.LoadDisplay(); + Audio = _storage.LoadAudio(); + Gameplay = _storage.LoadGameplay(); + Chat = _storage.LoadChat(); + _defaultCharacter = _storage.LoadCharacter(DefaultToonKey); + Character = _defaultCharacter; + ResolvedQuality = _resolveQuality(Display.Quality); + Startup = new RuntimeSettingsSnapshot( + Display, + Audio, + Gameplay, + Chat, + Character, + ResolvedQuality); + } + + public RuntimeSettingsSnapshot Startup { get; } + + public SettingsStore? LayoutStore => _storage.LayoutStore; + + public string ActiveToonKey { get; private set; } = DefaultToonKey; + + public DisplaySettings Display { get; private set; } + + public AudioSettings Audio { get; private set; } + + public GameplaySettings Gameplay { get; private set; } + + public ChatSettings Chat { get; private set; } + + public CharacterSettings Character { get; private set; } + + public QualitySettings ResolvedQuality { get; private set; } + + public bool HasDraftPreview => _viewModel is not null; + + public DisplaySettings DisplayPreview => _viewModel?.DisplayDraft ?? Display; + + public AudioSettings AudioPreview => _viewModel?.AudioDraft ?? Audio; + + public bool AutoTarget => Gameplay.AutoTarget; + + public bool AutoRepeatAttack => Gameplay.AutoRepeatAttack; + + public bool ViewCombatTarget => Gameplay.ViewCombatTarget; + + public void ApplyStartup(IRuntimeSettingsStartupTarget target) + { + ArgumentNullException.ThrowIfNull(target); + if (_startupApplied) + throw new InvalidOperationException("Runtime settings startup was already applied."); + + if (!_startupDisplayApplied) + { + target.ApplyDisplay(Startup.Display); + _startupDisplayApplied = true; + } + if (!_startupAudioApplied) + { + target.ApplyAudio(Startup.Audio); + _startupAudioApplied = true; + } + _startupApplied = true; + + QualitySettings baseQuality = QualitySettings.From(Startup.Display.Quality); + _log(Startup.Quality.Equals(baseQuality) + ? $"[QUALITY] Preset {Startup.Display.Quality} -> {Startup.Quality}" + : $"[QUALITY] Preset {Startup.Display.Quality} overridden by env vars: {Startup.Quality}"); + } + + /// + /// Installs the complete future-change target. Deliberately does not replay + /// startup display, quality, or UI-lock values; those were consumed by the + /// factories that created the borrowed target objects. + /// + public void BindRuntimeTargets(IRuntimeSettingsTargets targets) + { + ArgumentNullException.ThrowIfNull(targets); + if (_runtimeTargets is not null) + throw new InvalidOperationException("Runtime settings targets are already bound."); + _runtimeTargets = targets; + } + + public void UnbindRuntimeTargets() => _runtimeTargets = null; + + public SettingsVM CreateViewModel( + KeyBindings persistedBindings, + InputDispatcher dispatcher, + Action saveBindings) + { + ArgumentNullException.ThrowIfNull(persistedBindings); + ArgumentNullException.ThrowIfNull(dispatcher); + ArgumentNullException.ThrowIfNull(saveBindings); + if (_viewModel is not null) + throw new InvalidOperationException("A settings view model is already bound."); + + _viewModel = new SettingsVM( + persistedBindings, + dispatcher, + saveBindings, + Display, + SaveDisplay, + Audio, + SaveAudio, + Gameplay, + SaveGameplay, + Chat, + SaveChat, + Character, + SaveCharacter); + return _viewModel; + } + + public void UnbindViewModel(SettingsVM? expected = null) + { + if (expected is null || ReferenceEquals(_viewModel, expected)) + _viewModel = null; + } + + public void SetUiLocked(bool locked) + { + if (Gameplay.LockUI == locked && _uiLockConverged) + return; + + _uiLockConverged = false; + Gameplay = Gameplay with { LockUI = locked }; + _runtimeTargets?.ApplyUiLock(locked); + _viewModel?.SetGameplay( + _viewModel.GameplayDraft with { LockUI = locked }); + + try + { + _storage.SaveGameplay(Gameplay); + _viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with + { + LockUI = locked, + }); + _uiLockConverged = true; + } + catch (Exception ex) + { + _log($"settings: radar lock save failed: {ex.Message}"); + } + } + + public void SetAcceptLootPermits(bool enabled) + { + Gameplay = Gameplay with { AcceptLootPermits = enabled }; + _viewModel?.SetGameplay( + _viewModel.GameplayDraft with { AcceptLootPermits = enabled }); + _storage.SaveGameplay(Gameplay); + _viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with + { + AcceptLootPermits = enabled, + }); + } + + /// + /// Retail ClientCommunicationSystem::DoFrameRate @ 0x005707D0 + /// flips the live flag and sends the framerate-display UI notice. acdream + /// additionally persists that live value through its modern Display bag + /// (registered as AP-121). + /// + public void ToggleFrameRate() + { + Display = Display with { ShowFps = !Display.ShowFps }; + _viewModel?.ApplyExternalDisplayChange(display => display with + { + ShowFps = Display.ShowFps, + }); + + try + { + _storage.SaveDisplay(Display); + } + catch (Exception ex) + { + _log($"settings: framerate display save failed: {ex.Message}"); + } + } + + public void SetCombatGameplay(GameplaySettings gameplay) + { + Gameplay = gameplay ?? throw new ArgumentNullException(nameof(gameplay)); + if (_viewModel is not null) + { + _viewModel.SetGameplay(_viewModel.GameplayDraft with + { + AutoTarget = gameplay.AutoTarget, + AutoRepeatAttack = gameplay.AutoRepeatAttack, + ViewCombatTarget = gameplay.ViewCombatTarget, + }); + } + + try + { + _storage.SaveGameplay(gameplay); + _viewModel?.ApplyExternalGameplayChange(current => current with + { + AutoTarget = gameplay.AutoTarget, + AutoRepeatAttack = gameplay.AutoRepeatAttack, + ViewCombatTarget = gameplay.ViewCombatTarget, + }); + } + catch (Exception ex) + { + _log($"settings: combat option save failed: {ex.Message}"); + } + } + + public void SetActiveCharacter(string characterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + ActiveToonKey = characterName; + } + + public void LoadCharacterContext(string characterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + ActiveToonKey = characterName; + Character = _storage.LoadCharacter(characterName); + _viewModel?.LoadCharacterContext(Character); + _log($"settings: loaded character[{characterName}] preferences"); + } + + public void RestoreDefaultCharacterContext() + { + Character = _defaultCharacter; + _viewModel?.LoadCharacterContext(Character); + } + + public void ResetActiveCharacterKey() => ActiveToonKey = DefaultToonKey; + + public void ReapplyQualityPreset(QualityPreset preset) + { + QualitySettings resolved = _resolveQuality(preset); + _log($"[QUALITY] ReapplyQualityPreset: {preset} -> {resolved}"); + + if (resolved.MsaaSamples != ResolvedQuality.MsaaSamples) + { + _log( + $"[QUALITY] MSAA samples change ({ResolvedQuality.MsaaSamples} -> " + + $"{resolved.MsaaSamples}) requires a restart - skipped for this session."); + } + + ResolvedQuality = resolved; + _runtimeTargets?.ApplyQuality(resolved); + } + + private void SaveDisplay(DisplaySettings display) + { + try + { + _storage.SaveDisplay(display); + _log($"settings: display saved to {_storage.Location}"); + _runtimeTargets?.ApplyDisplayWindowState(display); + Display = display; + ReapplyQualityPreset(display.Quality); + } + catch (Exception ex) + { + _log($"settings: display save failed: {ex.Message}"); + } + } + + private void SaveAudio(AudioSettings audio) + { + try + { + _storage.SaveAudio(audio); + Audio = audio; + _log($"settings: audio saved to {_storage.Location}"); + } + catch (Exception ex) + { + _log($"settings: audio save failed: {ex.Message}"); + } + } + + private void SaveGameplay(GameplaySettings gameplay) + { + try + { + _storage.SaveGameplay(gameplay); + Gameplay = gameplay; + _uiLockConverged = false; + _runtimeTargets?.ApplyUiLock(gameplay.LockUI); + _uiLockConverged = true; + _log($"settings: gameplay saved to {_storage.Location}"); + } + catch (Exception ex) + { + _log($"settings: gameplay save failed: {ex.Message}"); + } + } + + private void SaveChat(ChatSettings chat) + { + try + { + _storage.SaveChat(chat); + Chat = chat; + _log($"settings: chat saved to {_storage.Location}"); + } + catch (Exception ex) + { + _log($"settings: chat save failed: {ex.Message}"); + } + } + + private void SaveCharacter(CharacterSettings character) + { + try + { + _storage.SaveCharacter(ActiveToonKey, character); + Character = character; + if (string.Equals( + ActiveToonKey, + DefaultToonKey, + StringComparison.OrdinalIgnoreCase)) + { + _defaultCharacter = character; + } + _log($"settings: character[{ActiveToonKey}] saved to {_storage.Location}"); + } + catch (Exception ex) + { + _log($"settings: character save failed: {ex.Message}"); + } + } + + private static QualitySettings ResolveQuality(QualityPreset preset) => + QualitySettings.WithEnvOverrides(QualitySettings.From(preset)); +} diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs new file mode 100644 index 00000000..f94d0d94 --- /dev/null +++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs @@ -0,0 +1,255 @@ +using AcDream.App.Audio; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.App.UI; +using AcDream.UI.Abstractions.Panels.Settings; +using AcDream.UI.Abstractions.Settings; +using Silk.NET.Maths; +using Silk.NET.Windowing; + +namespace AcDream.App.Settings; + +internal interface IRuntimeDisplayWindowTarget +{ + void Apply(DisplaySettings display); +} + +internal interface IRuntimeQualityApplicationTarget +{ + void SetAlphaToCoverage(bool enabled); + + void SetAnisotropic(int level); + + void PublishRenderRange(int nearRadius, int farRadius); + + void ReconfigureStreamingRadii(int nearRadius, int farRadius); + + void SetCompletionBudget(int maxCompletionsPerFrame); +} + +internal interface IRuntimeUiLockTarget +{ + void Apply(bool locked); +} + +internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarget +{ + private readonly IWindow _window; + + public SilkRuntimeDisplayWindowTarget(IWindow window) + { + _window = window ?? throw new ArgumentNullException(nameof(window)); + } + + public void Apply(DisplaySettings display) + { + ArgumentNullException.ThrowIfNull(display); + if (TryParseResolution(display.Resolution, out int width, out int height) + && (_window.Size.X != width || _window.Size.Y != height)) + { + _window.Size = new Vector2D(width, height); + } + + WindowState desired = display.Fullscreen + ? WindowState.Fullscreen + : WindowState.Normal; + if (_window.WindowState != desired) + _window.WindowState = desired; + } + + internal static bool TryParseResolution( + string spec, + out int width, + out int height) + { + width = height = 0; + if (string.IsNullOrWhiteSpace(spec)) + return false; + string[] parts = spec.Split('x', 2); + return parts.Length == 2 + && int.TryParse(parts[0], out width) + && int.TryParse(parts[1], out height) + && width > 0 + && height > 0; + } +} + +internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTarget +{ + private readonly IRuntimeDisplayWindowTarget _displayWindow; + private readonly DisplayFramePacingController _pacing; + private readonly CameraController _cameras; + private readonly OpenAlAudioEngine? _audio; + + public RuntimeSettingsStartupTargets( + IRuntimeDisplayWindowTarget displayWindow, + DisplayFramePacingController pacing, + CameraController cameras, + OpenAlAudioEngine? audio) + { + _displayWindow = displayWindow + ?? throw new ArgumentNullException(nameof(displayWindow)); + _pacing = pacing ?? throw new ArgumentNullException(nameof(pacing)); + _cameras = cameras ?? throw new ArgumentNullException(nameof(cameras)); + _audio = audio; + } + + public void ApplyDisplay(DisplaySettings display) + { + ArgumentNullException.ThrowIfNull(display); + _pacing.RefreshActiveMonitor(); + _pacing.ApplyPreference(display.VSync); + _displayWindow.Apply(display); + ApplyFieldOfView(_cameras, display.FieldOfView); + } + + public void ApplyAudio(AudioSettings audio) => ApplyAudio(_audio, audio); + + internal static void ApplyFieldOfView( + CameraController cameras, + float degrees) + { + float radians = degrees * (MathF.PI / 180f); + cameras.Orbit.FovY = radians; + cameras.Fly.FovY = radians; + if (cameras.Chase is not null) + cameras.Chase.FovY = radians; + } + + internal static void ApplyAudio( + OpenAlAudioEngine? engine, + AudioSettings audio) + { + ArgumentNullException.ThrowIfNull(audio); + if (engine is not { IsAvailable: true }) + return; + engine.MasterVolume = audio.Master; + engine.MusicVolume = audio.Music; + engine.SfxVolume = audio.Sfx; + engine.AmbientVolume = audio.Ambient; + } +} + +internal sealed class RuntimeQualityApplicationTarget + : IRuntimeQualityApplicationTarget +{ + private readonly WbDrawDispatcher _dispatcher; + private readonly TerrainAtlas _terrainAtlas; + private readonly StreamingController _streaming; + private readonly WorldRenderRangeState _renderRange; + + public RuntimeQualityApplicationTarget( + WbDrawDispatcher dispatcher, + TerrainAtlas terrainAtlas, + StreamingController streaming, + WorldRenderRangeState renderRange) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _terrainAtlas = terrainAtlas ?? throw new ArgumentNullException(nameof(terrainAtlas)); + _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming)); + _renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange)); + } + + public void SetAlphaToCoverage(bool enabled) => + _dispatcher.AlphaToCoverage = enabled; + + public void SetAnisotropic(int level) => _terrainAtlas.SetAnisotropic(level); + + public void PublishRenderRange(int nearRadius, int farRadius) + { + _renderRange.NearRadius = nearRadius; + _renderRange.FarRadius = farRadius; + } + + public void ReconfigureStreamingRadii(int nearRadius, int farRadius) => + _streaming.ReconfigureRadii(nearRadius, farRadius); + + public void SetCompletionBudget(int maxCompletionsPerFrame) => + _streaming.MaxCompletionsPerFrame = maxCompletionsPerFrame; +} + +internal sealed class RuntimeUiLockTarget(UiRoot root) : IRuntimeUiLockTarget +{ + private readonly UiRoot _root = root ?? throw new ArgumentNullException(nameof(root)); + + public void Apply(bool locked) => _root.UiLocked = locked; +} + +internal sealed class NullRuntimeUiLockTarget : IRuntimeUiLockTarget +{ + public static NullRuntimeUiLockTarget Instance { get; } = new(); + + private NullRuntimeUiLockTarget() + { + } + + public void Apply(bool locked) + { + } +} + +/// +/// Complete late-bound target for changes made after startup. Construction and +/// binding are inert; only an explicit controller command mutates borrowers. +/// +internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets +{ + private readonly IRuntimeDisplayWindowTarget _displayWindow; + private readonly IRuntimeQualityApplicationTarget _quality; + private readonly IRuntimeUiLockTarget _uiLock; + private readonly Action _log; + + public RuntimeSettingsTargets( + IRuntimeDisplayWindowTarget displayWindow, + WbDrawDispatcher dispatcher, + TerrainAtlas terrainAtlas, + StreamingController streaming, + WorldRenderRangeState renderRange, + UiRoot? uiRoot, + Action? log = null) + : this( + displayWindow, + new RuntimeQualityApplicationTarget( + dispatcher, + terrainAtlas, + streaming, + renderRange), + uiRoot is null + ? NullRuntimeUiLockTarget.Instance + : new RuntimeUiLockTarget(uiRoot), + log) + { + } + + internal RuntimeSettingsTargets( + IRuntimeDisplayWindowTarget displayWindow, + IRuntimeQualityApplicationTarget quality, + IRuntimeUiLockTarget uiLock, + Action? log = null) + { + _displayWindow = displayWindow + ?? throw new ArgumentNullException(nameof(displayWindow)); + _quality = quality ?? throw new ArgumentNullException(nameof(quality)); + _uiLock = uiLock ?? throw new ArgumentNullException(nameof(uiLock)); + _log = log ?? Console.WriteLine; + } + + public void ApplyDisplayWindowState(DisplaySettings display) => + _displayWindow.Apply(display); + + public void ApplyQuality(QualitySettings quality) + { + _quality.SetAlphaToCoverage(quality.AlphaToCoverage); + _quality.SetAnisotropic(quality.AnisotropicLevel); + _quality.PublishRenderRange(quality.NearRadius, quality.FarRadius); + _quality.ReconfigureStreamingRadii(quality.NearRadius, quality.FarRadius); + _quality.SetCompletionBudget(quality.MaxCompletionsPerFrame); + _log( + $"[QUALITY] Streaming reconciled: nearRadius={quality.NearRadius}, " + + $"farRadius={quality.FarRadius}, " + + $"maxCompletions={quality.MaxCompletionsPerFrame}"); + } + + public void ApplyUiLock(bool locked) => _uiLock.Apply(locked); +} diff --git a/src/AcDream.App/Update/LiveObjectFrameController.cs b/src/AcDream.App/Update/LiveObjectFrameController.cs index 21b3739c..6b76745e 100644 --- a/src/AcDream.App/Update/LiveObjectFrameController.cs +++ b/src/AcDream.App/Update/LiveObjectFrameController.cs @@ -3,6 +3,7 @@ using AcDream.App.Input; using AcDream.App.Interaction; using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; +using AcDream.App.Settings; using AcDream.App.World; using AcDream.Core.Physics; using AcDream.Core.Rendering; @@ -39,17 +40,15 @@ internal interface IParticleRangeSource internal sealed class SettingsParticleRangeSource : IParticleRangeSource { - private readonly SettingsVM? _settings; - private readonly ParticleRange _fallback; + private readonly IRuntimeSettingsPreviewSource _settings; - public SettingsParticleRangeSource(SettingsVM? settings, ParticleRange fallback) + public SettingsParticleRangeSource(IRuntimeSettingsPreviewSource settings) { - _settings = settings; - _fallback = fallback; + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); } public float RangeMultiplier => - (_settings?.DisplayDraft.ParticleRange ?? _fallback) == ParticleRange.Extended + _settings.DisplayPreview.ParticleRange == ParticleRange.Extended ? ParticleVisibilityController.ExtendedRangeMultiplier : 1f; } diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index be14d6a4..da2f59f3 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -189,6 +189,21 @@ public sealed class SettingsVM _gameplayDraft = value ?? throw new ArgumentNullException(nameof(value)); } + /// + /// Apply one externally persisted gameplay-option change to both snapshots. + /// Existing unsaved edits to unrelated fields remain drafts rather than + /// being accidentally promoted to persisted state. + /// + public void ApplyExternalGameplayChange( + Func update) + { + ArgumentNullException.ThrowIfNull(update); + _gameplayPersisted = update(_gameplayPersisted) + ?? throw new InvalidOperationException("Gameplay update returned null."); + _gameplayDraft = update(_gameplayDraft) + ?? throw new InvalidOperationException("Gameplay update returned null."); + } + /// /// Replace the entire Chat draft with . /// Local-only this phase — values persist on Save but the Hear*Chat diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 42c200a6..1241f58f 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -15,11 +15,10 @@ public sealed class GameWindowSlice8BoundaryTests AssertAppearsInOrder( body, - "startupStore.LoadDisplay()", - "QualitySettings.WithEnvOverrides(startupBase)", - "_displayFramePacing.InitializeStartup(startupDisplay.VSync)", + "RuntimeSettingsSnapshot startup = _runtimeSettings.Startup", + "_displayFramePacing.InitializeStartup(startup.Display.VSync)", "VSync = startupPacing.UseVSync", - "Samples = startupQuality.MsaaSamples", + "Samples = startup.Quality.MsaaSamples", "Window.Create(options)", "_displayFramePacing.BindSurface(", "_windowCallbacks = SilkWindowCallbackBinding.Create(", @@ -66,7 +65,8 @@ public sealed class GameWindowSlice8BoundaryTests "_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(", "_cameraPointerInput.AttachRaw();", "_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);", - "LoadAndApplyPersistedSettings();", + "_runtimeSettings.ApplyStartup(", + "new RuntimeSettingsStartupTargets(", "_uiHost = new AcDream.App.UI.UiHost(", "_uiHost.WireMouse(m)", "_uiHost.WireKeyboard(kb)", @@ -192,7 +192,7 @@ public sealed class GameWindowSlice8BoundaryTests "private AcDream.App.Net.LiveSessionHost"); string body = MethodBody( "private void OnFramebufferResize(", - "private void ApplyDisplayWindowState("); + "private uint? PickWorldGuidAtCursor("); Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal); Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal); @@ -209,6 +209,68 @@ public sealed class GameWindowSlice8BoundaryTests StringComparison.Ordinal); } + [Fact] + public void RuntimeSettings_IsOneTwoPhaseOwnerWithoutWindowStateMirrors() + { + string source = GameWindowSource(); + string run = MethodBody("public void Run()", "private void OnLoad()"); + string load = MethodBody( + "private void OnLoad()", + "private AcDream.App.Net.LiveSessionHost"); + string shutdown = MethodBody( + "private ResourceShutdownTransaction CreateShutdownTransaction()", + "private void OnFocusChanged(bool focused)"); + + Assert.Contains( + "private readonly RuntimeSettingsController _runtimeSettings;", + source, + StringComparison.Ordinal); + Assert.Contains( + "_runtimeSettings = new RuntimeSettingsController(", + source, + StringComparison.Ordinal); + Assert.DoesNotContain("new SettingsStore(", source, StringComparison.Ordinal); + Assert.DoesNotContain("new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(", source, StringComparison.Ordinal); + Assert.DoesNotContain(".LoadDisplay()", source, StringComparison.Ordinal); + Assert.DoesNotContain(".LoadAudio()", source, StringComparison.Ordinal); + Assert.DoesNotContain(".LoadGameplay()", source, StringComparison.Ordinal); + Assert.DoesNotContain(".LoadChat()", source, StringComparison.Ordinal); + Assert.DoesNotContain(".LoadCharacter(", source, StringComparison.Ordinal); + Assert.DoesNotContain("_persistedDisplay", source, StringComparison.Ordinal); + Assert.DoesNotContain("_persistedAudio", source, StringComparison.Ordinal); + Assert.DoesNotContain("_persistedGameplay", source, StringComparison.Ordinal); + Assert.DoesNotContain("_persistedChat", source, StringComparison.Ordinal); + Assert.DoesNotContain("_persistedCharacter", source, StringComparison.Ordinal); + Assert.DoesNotContain("_activeToonKey", source, StringComparison.Ordinal); + Assert.DoesNotContain("_settingsStore", source, StringComparison.Ordinal); + Assert.DoesNotContain("_settingsVm", source, StringComparison.Ordinal); + Assert.DoesNotContain("LoadAndApplyPersistedSettings", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void ApplyDisplayWindowState", source, StringComparison.Ordinal); + Assert.DoesNotContain("public void ReapplyQualityPreset", source, StringComparison.Ordinal); + + AssertAppearsInOrder( + run, + "RuntimeSettingsSnapshot startup = _runtimeSettings.Startup", + "_displayFramePacing.InitializeStartup(startup.Display.VSync)", + "Samples = startup.Quality.MsaaSamples", + "Window.Create(options)"); + AssertAppearsInOrder( + load, + "_runtimeSettings.ApplyStartup(", + "if (DevToolsEnabled)", + "TerrainAtlas.Build(", + "_runtimeSettings.BindRuntimeTargets(", + "_liveSessionHost.Start(_options)"); + AssertAppearsInOrder( + shutdown, + "_runtimeSettings.UnbindViewModel()", + "_runtimeSettings.UnbindRuntimeTargets", + "_retailUiRuntime?.Dispose()", + "_streamer?.Dispose()", + "_wbDrawDispatcher?.Dispose()", + "_terrain?.Dispose()"); + } + [Fact] public void UpdateRenderFocusAndCloseRemainNarrowHostEdges() { diff --git a/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs b/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs index 4d384a67..318ee615 100644 --- a/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs @@ -152,7 +152,7 @@ public sealed class PrivatePresentationRendererTests typeof(AcDream.UI.Abstractions.Panels.Debug.DebugVM), FieldTypes(typeof(DevToolsPanelSet))); Assert.Contains( - typeof(AcDream.UI.Abstractions.Panels.Settings.SettingsVM), + typeof(AcDream.App.Settings.IRuntimeSettingsPreviewSource), FieldTypes(typeof(RuntimeWorldFrameSettingsPreview))); Assert.Contains( typeof(AcDream.App.Streaming.LocalPlayerTeleportController), diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs new file mode 100644 index 00000000..3ff2177d --- /dev/null +++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs @@ -0,0 +1,1159 @@ +using AcDream.App.Diagnostics; +using AcDream.App.Rendering; +using AcDream.App.Settings; +using AcDream.UI.Abstractions.Input; +using AcDream.UI.Abstractions.Panels.Settings; +using AcDream.UI.Abstractions.Settings; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Settings; + +public sealed class RuntimeSettingsControllerTests +{ + [Fact] + public void ConstructionLoadsEachBagOnceAndPublishesOneStartupSnapshot() + { + var storage = new FakeStorage + { + DisplayValue = DisplaySettings.Default with + { + VSync = false, + Quality = QualityPreset.Ultra, + }, + GameplayValue = GameplaySettings.Default with + { + AutoTarget = true, + AutoRepeatAttack = true, + ViewCombatTarget = true, + }, + }; + var resolved = new QualitySettings(7, 18, 8, 16, true, 9); + int resolveCount = 0; + + var controller = new RuntimeSettingsController( + storage, + preset => + { + resolveCount++; + Assert.Equal(QualityPreset.Ultra, preset); + return resolved; + }, + static _ => { }); + + Assert.Equal(1, storage.DisplayLoads); + Assert.Equal(1, storage.AudioLoads); + Assert.Equal(1, storage.GameplayLoads); + Assert.Equal(1, storage.ChatLoads); + Assert.Equal(1, storage.CharacterLoads); + Assert.Equal(1, resolveCount); + Assert.Equal("default", storage.LastLoadedCharacter); + Assert.Same(storage.DisplayValue, controller.Startup.Display); + Assert.Same(storage.AudioValue, controller.Startup.Audio); + Assert.Same(storage.GameplayValue, controller.Startup.Gameplay); + Assert.Same(storage.ChatValue, controller.Startup.Chat); + Assert.Same(storage.DefaultCharacterValue, controller.Startup.Character); + Assert.Equal(resolved, controller.Startup.Quality); + Assert.Equal(resolved, controller.ResolvedQuality); + Assert.True(controller.AutoTarget); + Assert.True(controller.AutoRepeatAttack); + Assert.True(controller.ViewCombatTarget); + Assert.Equal("default", controller.ActiveToonKey); + } + + [Fact] + public void StartupApplyIsOrderedExactlyOnceAndRuntimeBindingDoesNotReplay() + { + var events = new List(); + var controller = CreateController(events: events); + var startup = new FakeStartupTarget(events); + + controller.ApplyStartup(startup); + + Assert.Equal(["startup-display", "startup-audio"], events); + Assert.Throws(() => controller.ApplyStartup(startup)); + + events.Clear(); + var runtime = new FakeRuntimeTargets(events); + controller.BindRuntimeTargets(runtime); + + Assert.Empty(events); + Assert.Throws(() => + controller.BindRuntimeTargets(new FakeRuntimeTargets(events))); + } + + [Fact] + public void StartupRetryResumesAfterLastSuccessfulStage() + { + var displayEvents = new List(); + var displayController = CreateController(); + var displayTarget = new FakeStartupTarget(displayEvents) + { + RemainingDisplayFailures = 1, + }; + + Assert.Throws(() => + displayController.ApplyStartup(displayTarget)); + displayController.ApplyStartup(displayTarget); + + Assert.Equal( + ["startup-display", "startup-display", "startup-audio"], + displayEvents); + + var audioEvents = new List(); + var audioController = CreateController(); + var audioTarget = new FakeStartupTarget(audioEvents) + { + RemainingAudioFailures = 1, + }; + + Assert.Throws(() => + audioController.ApplyStartup(audioTarget)); + audioController.ApplyStartup(audioTarget); + + Assert.Equal( + ["startup-display", "startup-audio", "startup-audio"], + audioEvents); + } + + [Fact] + public void ConcreteStartupTargetAppliesPacingThenWindowThenPersistedFov() + { + using var profiler = new FrameProfiler(); + using var pacing = new DisplayFramePacingController( + uncappedRendering: false, + profiler, + new FramePacingController(new FakeClock(), new NullWaiter())); + var surface = new FakePacingSurface + { + VSync = true, + ActiveMonitorRefreshHz = 144, + }; + pacing.InitializeStartup(requestedVSync: true); + pacing.BindSurface(surface); + var cameras = new CameraController(new OrbitCamera(), new FlyCamera()); + float originalFov = cameras.Orbit.FovY; + var displayWindow = new InspectingDisplayWindowTarget(display => + { + Assert.False(pacing.RequestedVSync); + Assert.Equal(originalFov, cameras.Orbit.FovY); + Assert.Equal("1600x900", display.Resolution); + }); + var target = new RuntimeSettingsStartupTargets( + displayWindow, + pacing, + cameras, + audio: null); + + target.ApplyDisplay(DisplaySettings.Default with + { + Resolution = "1600x900", + VSync = false, + FieldOfView = 83f, + }); + + Assert.Equal(1, displayWindow.ApplyCount); + Assert.Equal(1, surface.RefreshReadCount); + Assert.Equal(new FramePacingPolicy(false, 144d), pacing.Policy); + Assert.Equal(83f * (MathF.PI / 180f), cameras.Orbit.FovY, precision: 5); + Assert.Equal(83f * (MathF.PI / 180f), cameras.Fly.FovY, precision: 5); + } + + [Fact] + public void ConcreteRuntimeTargetAppliesEveryQualityDimensionInOrder() + { + var events = new List(); + var qualityTarget = new RecordingQualityApplicationTarget(events); + var displayTarget = new InspectingDisplayWindowTarget( + _ => events.Add("display")); + var uiTarget = new RecordingUiLockTarget(events); + var target = new RuntimeSettingsTargets( + displayTarget, + qualityTarget, + uiTarget, + static _ => { }); + var quality = new QualitySettings(6, 17, 4, 12, true, 7); + + target.ApplyQuality(quality); + + Assert.Equal( + [ + "a2c:True", + "aniso:12", + "range:6:17", + "stream:6:17", + "budget:7", + ], + events); + Assert.Equal(quality, qualityTarget.Observed); + } + + [Fact] + public void ConcreteRuntimeQualityTargetStopsAtTheThrowingStep() + { + string[] allSteps = + [ + "a2c", + "aniso", + "range", + "stream", + "budget", + ]; + + for (int failureIndex = 0; failureIndex < allSteps.Length; failureIndex++) + { + var events = new List(); + var target = new RuntimeSettingsTargets( + new InspectingDisplayWindowTarget(static _ => { }), + new FailingQualityApplicationTarget(events, failureIndex), + new RecordingUiLockTarget(events), + static _ => { }); + + Assert.Throws(() => + target.ApplyQuality(QualitySettings.From(QualityPreset.High))); + Assert.Equal(allSteps[..(failureIndex + 1)], events); + } + } + + [Fact] + public void SettingsViewModelSavePreservesSectionAndTargetOrder() + { + var events = new List(); + var storage = new FakeStorage(events); + var resolved = new QualitySettings(5, 15, 4, 16, true, 6); + var controller = new RuntimeSettingsController( + storage, + _ => resolved, + static _ => { }); + storage.ClearEvents(); + var targets = new FakeRuntimeTargets(events); + controller.BindRuntimeTargets(targets); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + _ => events.Add("save-bindings")); + + viewModel.SetDisplay(viewModel.DisplayDraft with + { + Resolution = "1920x1080", + Quality = QualityPreset.Ultra, + }); + viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.25f }); + viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true }); + viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true }); + viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true }); + + viewModel.Save(); + + Assert.Equal( + [ + "save-bindings", + "save-display", + "target-display", + "target-quality", + "save-audio", + "save-gameplay", + "target-ui-lock:True", + "save-chat", + "save-character:default", + ], + events); + Assert.Equal("1920x1080", controller.Display.Resolution); + Assert.Equal(0.25f, controller.Audio.Master); + Assert.True(controller.Gameplay.LockUI); + Assert.True(controller.Chat.ShowTimestamps); + Assert.True(controller.Character.AutoAttack); + Assert.Equal(resolved, controller.ResolvedQuality); + } + + [Fact] + public void DraftPreviewAndExternalCommandsShareCanonicalState() + { + var events = new List(); + var storage = new FakeStorage(events); + var controller = CreateController(storage, events: events); + storage.ClearEvents(); + var targets = new FakeRuntimeTargets(events); + controller.BindRuntimeTargets(targets); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + viewModel.SetDisplay(viewModel.DisplayDraft with + { + FieldOfView = 91f, + ParticleRange = ParticleRange.Retail, + }); + viewModel.SetAudio(viewModel.AudioDraft with { Sfx = 0.33f }); + viewModel.SetGameplay(viewModel.GameplayDraft with { AutoTarget = true }); + + Assert.True(controller.HasDraftPreview); + Assert.Equal(91f, controller.DisplayPreview.FieldOfView); + Assert.Equal(ParticleRange.Retail, controller.DisplayPreview.ParticleRange); + Assert.Equal(0.33f, controller.AudioPreview.Sfx); + + controller.ToggleFrameRate(); + controller.SetUiLocked(true); + controller.SetAcceptLootPermits(true); + + Assert.True(controller.Display.ShowFps); + Assert.True(controller.DisplayPreview.ShowFps); + Assert.Equal(91f, controller.DisplayPreview.FieldOfView); + Assert.True(controller.Gameplay.LockUI); + Assert.True(controller.Gameplay.AcceptLootPermits); + Assert.True(viewModel.GameplayDraft.AutoTarget); + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.True(viewModel.GameplayDraft.AcceptLootPermits); + Assert.Contains("target-ui-lock:True", events); + Assert.Equal(2, storage.GameplaySaves); + Assert.Equal(1, storage.DisplaySaves); + } + + [Fact] + public void DraftCancelRestoresPreviewAndCombatTogglePreservesUnrelatedDrafts() + { + var storage = new FakeStorage(); + var controller = CreateController(storage); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + viewModel.SetDisplay(viewModel.DisplayDraft with { FieldOfView = 99f }); + viewModel.SetGameplay(viewModel.GameplayDraft with + { + ShowTooltips = false, + CoordinatesOnRadar = false, + }); + + controller.SetCombatGameplay(controller.Gameplay with { AutoTarget = false }); + controller.SetUiLocked(true); + controller.SetAcceptLootPermits(true); + + Assert.False(controller.Gameplay.AutoTarget); + Assert.False(viewModel.GameplayDraft.AutoTarget); + Assert.False(viewModel.GameplayDraft.ShowTooltips); + Assert.False(viewModel.GameplayDraft.CoordinatesOnRadar); + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.True(viewModel.GameplayDraft.AcceptLootPermits); + Assert.Equal(99f, controller.DisplayPreview.FieldOfView); + + viewModel.Cancel(); + + Assert.Equal(controller.Display.FieldOfView, controller.DisplayPreview.FieldOfView); + Assert.Equal(controller.Gameplay.ShowTooltips, viewModel.GameplayDraft.ShowTooltips); + Assert.Equal( + controller.Gameplay.CoordinatesOnRadar, + viewModel.GameplayDraft.CoordinatesOnRadar); + Assert.False(viewModel.GameplayDraft.AutoTarget); + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.True(viewModel.GameplayDraft.AcceptLootPermits); + + viewModel.SetGameplay(viewModel.GameplayDraft with { ShowHelm = false }); + viewModel.Save(); + + Assert.False(storage.GameplayValue.AutoTarget); + Assert.True(storage.GameplayValue.LockUI); + Assert.True(storage.GameplayValue.AcceptLootPermits); + Assert.False(storage.GameplayValue.ShowHelm); + } + + [Fact] + public void FailedExternalGameplayPersistenceDoesNotPromoteViewModelBaseline() + { + var storage = new FakeStorage { ThrowOnGameplaySave = true }; + var logs = new List(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + logs.Add); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + + controller.SetUiLocked(true); + controller.SetCombatGameplay(controller.Gameplay with { AutoTarget = false }); + Assert.Throws(() => controller.SetAcceptLootPermits(true)); + + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.False(viewModel.GameplayDraft.AutoTarget); + Assert.True(viewModel.GameplayDraft.AcceptLootPermits); + + viewModel.Cancel(); + + Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI); + Assert.Equal( + GameplaySettings.Default.AutoTarget, + viewModel.GameplayDraft.AutoTarget); + Assert.Equal( + GameplaySettings.Default.AcceptLootPermits, + viewModel.GameplayDraft.AcceptLootPermits); + Assert.Contains(logs, line => + line.Contains("radar lock save failed", StringComparison.Ordinal)); + Assert.Contains(logs, line => + line.Contains("combat option save failed", StringComparison.Ordinal)); + } + + [Fact] + public void CharacterContextLoadsWithoutViewModelAndSynchronizesWhenBound() + { + var storage = new FakeStorage(); + storage.Characters["Alice"] = CharacterSettings.Default with + { + DefaultChatChannel = "Trade", + }; + storage.Characters["Bob"] = CharacterSettings.Default with + { + ConfirmSalvage = false, + }; + var controller = CreateController(storage); + + controller.SetActiveCharacter("Alice"); + controller.LoadCharacterContext("Alice"); + + Assert.Equal("Alice", controller.ActiveToonKey); + Assert.Equal("Trade", controller.Character.DefaultChatChannel); + + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + controller.LoadCharacterContext("Bob"); + + Assert.Equal("Bob", controller.ActiveToonKey); + Assert.False(viewModel.CharacterDraft.ConfirmSalvage); + + controller.RestoreDefaultCharacterContext(); + controller.ResetActiveCharacterKey(); + + Assert.Equal("default", controller.ActiveToonKey); + Assert.Same(storage.DefaultCharacterValue, controller.Character); + Assert.Same(storage.DefaultCharacterValue, viewModel.CharacterDraft); + } + + [Fact] + public void CharacterSaveUsesActiveToonAndDefaultSaveBecomesResetContext() + { + var storage = new FakeStorage(); + var controller = CreateController(storage); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + var newDefault = CharacterSettings.Default with { AutoAttack = true }; + viewModel.SetCharacter(newDefault); + viewModel.Save(); + + controller.LoadCharacterContext("Alice"); + var alice = CharacterSettings.Default with { DefaultChatChannel = "Trade" }; + viewModel.SetCharacter(alice); + viewModel.Save(); + + Assert.Same(newDefault, storage.Characters["default"]); + Assert.Same(alice, storage.Characters["Alice"]); + controller.RestoreDefaultCharacterContext(); + Assert.Same(newDefault, controller.Character); + Assert.Same(newDefault, viewModel.CharacterDraft); + } + + [Fact] + public void ViewModelAndRuntimeTargetLoansCanBeWithdrawnAndReboundPrecisely() + { + var events = new List(); + var controller = CreateController(events: events); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + SettingsVM foreign = CreateStandaloneViewModel(dispatcher); + + controller.UnbindViewModel(foreign); + Assert.True(controller.HasDraftPreview); + Assert.Throws(() => + controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { })); + + controller.UnbindViewModel(viewModel); + Assert.False(controller.HasDraftPreview); + controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + + var first = new FakeRuntimeTargets(events); + controller.BindRuntimeTargets(first); + controller.UnbindRuntimeTargets(); + var second = new FakeRuntimeTargets(events); + controller.BindRuntimeTargets(second); + controller.SetUiLocked(true); + + Assert.Equal(0, first.UiLockCalls); + Assert.Equal(1, second.UiLockCalls); + } + + [Fact] + public void DisplayPersistenceFailureDoesNotPublishStateOrTargets() + { + var events = new List(); + var storage = new FakeStorage(events) { ThrowOnDisplaySave = true }; + var logs = new List(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + logs.Add); + storage.ClearEvents(); + controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + DisplaySettings original = controller.Display; + viewModel.SetDisplay(viewModel.DisplayDraft with + { + Resolution = "2560x1440", + Quality = QualityPreset.Ultra, + }); + + viewModel.Save(); + + Assert.Same(original, controller.Display); + Assert.DoesNotContain("target-display", events); + Assert.DoesNotContain("target-quality", events); + Assert.Contains(logs, line => line.Contains("display save failed", StringComparison.Ordinal)); + } + + [Fact] + public void DisplayTargetFailurePreservesEstablishedStoreThenPublishBoundary() + { + var events = new List(); + var storage = new FakeStorage(events); + var logs = new List(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + logs.Add); + storage.ClearEvents(); + controller.BindRuntimeTargets(new FakeRuntimeTargets(events) + { + ThrowOnDisplay = true, + }); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + DisplaySettings original = controller.Display; + viewModel.SetDisplay(viewModel.DisplayDraft with + { + Resolution = "3840x2160", + Quality = QualityPreset.Ultra, + }); + + viewModel.Save(); + + Assert.Equal(1, storage.DisplaySaves); + Assert.Same(original, controller.Display); + Assert.DoesNotContain("target-quality", events); + Assert.Contains(logs, line => line.Contains("display save failed", StringComparison.Ordinal)); + } + + [Fact] + public void NonDisplayPersistenceFailuresContinueAndPreserveControllerState() + { + var storage = new FakeStorage + { + ThrowOnAudioSave = true, + ThrowOnGameplaySave = true, + ThrowOnChatSave = true, + ThrowOnCharacterSave = true, + }; + var logs = new List(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + logs.Add); + AudioSettings originalAudio = controller.Audio; + GameplaySettings originalGameplay = controller.Gameplay; + ChatSettings originalChat = controller.Chat; + CharacterSettings originalCharacter = controller.Character; + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.1f }); + viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true }); + viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true }); + viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true }); + + viewModel.Save(); + + Assert.Same(originalAudio, controller.Audio); + Assert.Same(originalGameplay, controller.Gameplay); + Assert.Same(originalChat, controller.Chat); + Assert.Same(originalCharacter, controller.Character); + Assert.Contains(logs, line => line.Contains("audio save failed", StringComparison.Ordinal)); + Assert.Contains(logs, line => line.Contains("gameplay save failed", StringComparison.Ordinal)); + Assert.Contains(logs, line => line.Contains("chat save failed", StringComparison.Ordinal)); + Assert.Contains(logs, line => line.Contains("character save failed", StringComparison.Ordinal)); + } + + [Fact] + public void MsaaChangeIsRestartRequiredWhileOtherQualityStateAdvances() + { + var logs = new List(); + var events = new List(); + var controller = new RuntimeSettingsController( + new FakeStorage(), + static preset => QualitySettings.From(preset), + logs.Add); + controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); + + controller.ReapplyQualityPreset(QualityPreset.Low); + + Assert.Equal(QualitySettings.From(QualityPreset.Low), controller.ResolvedQuality); + Assert.Equal(["target-quality"], events); + Assert.Contains(logs, line => + line.Contains("MSAA samples change (4 -> 0) requires a restart", StringComparison.Ordinal)); + } + + [Fact] + public void QualityTargetFailurePublishesResolvedQualityThenPropagates() + { + var events = new List(); + var controller = CreateController(events: events); + controller.BindRuntimeTargets(new FakeRuntimeTargets(events) + { + ThrowOnQuality = true, + }); + QualitySettings requested = QualitySettings.From(QualityPreset.Ultra); + + Assert.Throws(() => + controller.ReapplyQualityPreset(QualityPreset.Ultra)); + + Assert.Equal(requested, controller.ResolvedQuality); + Assert.Equal(["target-quality"], events); + } + + [Fact] + public void UiLockTargetFailureCanRetryTheSameRequestedValue() + { + var events = new List(); + var storage = new FakeStorage(events); + var controller = CreateController(storage, events); + storage.ClearEvents(); + var targets = new FakeRuntimeTargets(events) + { + RemainingUiLockFailures = 1, + }; + controller.BindRuntimeTargets(targets); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + + Assert.Throws(() => controller.SetUiLocked(true)); + + Assert.True(controller.Gameplay.LockUI); + Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI); + Assert.Equal(0, storage.GameplaySaves); + Assert.Equal(["target-ui-lock:True"], events); + + events.Clear(); + controller.SetUiLocked(true); + + Assert.True(controller.Gameplay.LockUI); + Assert.True(viewModel.GameplayDraft.LockUI); + viewModel.Cancel(); + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.Equal(1, storage.GameplaySaves); + Assert.Equal(["target-ui-lock:True", "save-gameplay"], events); + } + + [Fact] + public void UiLockPersistenceFailureCanRetryTheSameRequestedValue() + { + var events = new List(); + var storage = new FakeStorage(events) + { + RemainingGameplaySaveFailures = 1, + }; + var logs = new List(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + logs.Add); + storage.ClearEvents(); + controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + + controller.SetUiLocked(true); + + Assert.True(controller.Gameplay.LockUI); + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.Equal(1, storage.GameplaySaves); + Assert.Contains(logs, line => + line.Contains("radar lock save failed", StringComparison.Ordinal)); + + events.Clear(); + controller.SetUiLocked(true); + + Assert.True(controller.Gameplay.LockUI); + viewModel.Cancel(); + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.Equal(2, storage.GameplaySaves); + Assert.Equal(["target-ui-lock:True", "save-gameplay"], events); + } + + [Fact] + public void UnbindingLoansStopsCallsButStateAndPersistenceContinue() + { + var events = new List(); + var storage = new FakeStorage(events); + var controller = CreateController(storage, events: events); + storage.ClearEvents(); + controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); + controller.UnbindRuntimeTargets(); + + controller.SetUiLocked(true); + controller.ReapplyQualityPreset(QualityPreset.Ultra); + + Assert.True(controller.Gameplay.LockUI); + Assert.Equal( + QualitySettings.From(QualityPreset.Ultra), + controller.ResolvedQuality); + Assert.DoesNotContain(events, value => value.StartsWith("target-", StringComparison.Ordinal)); + Assert.Equal(1, storage.GameplaySaves); + } + + [Theory] + [InlineData("1920x1080", true, 1920, 1080)] + [InlineData(" 800x600 ", true, 800, 600)] + [InlineData("", false, 0, 0)] + [InlineData("1920", false, 0, 0)] + [InlineData("0x1080", false, 0, 1080)] + [InlineData("1920x-1", false, 1920, -1)] + public void ResolutionParserMatchesWindowTargetPolicy( + string spec, + bool expected, + int expectedWidth, + int expectedHeight) + { + bool parsed = SilkRuntimeDisplayWindowTarget.TryParseResolution( + spec, + out int width, + out int height); + + Assert.Equal(expected, parsed); + Assert.Equal(expectedWidth, width); + Assert.Equal(expectedHeight, height); + } + + private static RuntimeSettingsController CreateController( + FakeStorage? storage = null, + List? events = null) + { + storage ??= new FakeStorage(events); + return new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + static _ => { }); + } + + private static InputDispatcher CreateDispatcher() + { + InputDispatcher dispatcher = InputDispatcher.CreateDetached( + new NullKeyboardSource(), + new NullMouseSource(), + new KeyBindings()); + dispatcher.Attach(); + return dispatcher; + } + + private static SettingsVM CreateStandaloneViewModel(InputDispatcher dispatcher) => + new( + new KeyBindings(), + dispatcher, + static _ => { }, + DisplaySettings.Default, + static _ => { }, + AudioSettings.Default, + static _ => { }, + GameplaySettings.Default, + static _ => { }, + ChatSettings.Default, + static _ => { }, + CharacterSettings.Default, + static _ => { }); + + private sealed class FakeStartupTarget(List events) + : IRuntimeSettingsStartupTarget + { + public int RemainingDisplayFailures { get; set; } + + public int RemainingAudioFailures { get; set; } + + public void ApplyDisplay(DisplaySettings display) + { + events.Add("startup-display"); + + if (RemainingDisplayFailures > 0) + { + RemainingDisplayFailures--; + throw new InvalidOperationException("display startup failed"); + } + } + + public void ApplyAudio(AudioSettings audio) + { + events.Add("startup-audio"); + if (RemainingAudioFailures > 0) + { + RemainingAudioFailures--; + throw new InvalidOperationException("audio startup failed"); + } + } + } + + private sealed class FakeRuntimeTargets(List events) + : IRuntimeSettingsTargets + { + public bool ThrowOnDisplay { get; init; } + + public bool ThrowOnQuality { get; init; } + + public int RemainingUiLockFailures { get; set; } + + public int UiLockCalls { get; private set; } + + public void ApplyDisplayWindowState(DisplaySettings display) + { + events.Add("target-display"); + if (ThrowOnDisplay) + throw new InvalidOperationException("display target failed"); + } + + public void ApplyQuality(QualitySettings quality) + { + events.Add("target-quality"); + if (ThrowOnQuality) + throw new InvalidOperationException("quality target failed"); + } + + public void ApplyUiLock(bool locked) + { + UiLockCalls++; + events.Add($"target-ui-lock:{locked}"); + if (RemainingUiLockFailures > 0) + { + RemainingUiLockFailures--; + throw new InvalidOperationException("UI-lock target failed"); + } + } + } + + private sealed class InspectingDisplayWindowTarget( + Action apply) + : IRuntimeDisplayWindowTarget + { + public int ApplyCount { get; private set; } + + public void Apply(DisplaySettings display) + { + ApplyCount++; + apply(display); + } + } + + private sealed class RecordingQualityApplicationTarget(List events) + : IRuntimeQualityApplicationTarget + { + private bool _alphaToCoverage; + private int _anisotropic; + private int _near; + private int _far; + private int _streamNear; + private int _streamFar; + private int _budget; + + public QualitySettings Observed => new( + _near, + _far, + 4, + _anisotropic, + _alphaToCoverage, + _budget); + + public void SetAlphaToCoverage(bool enabled) + { + _alphaToCoverage = enabled; + events.Add($"a2c:{enabled}"); + } + + public void SetAnisotropic(int level) + { + _anisotropic = level; + events.Add($"aniso:{level}"); + } + + public void PublishRenderRange(int nearRadius, int farRadius) + { + _near = nearRadius; + _far = farRadius; + events.Add($"range:{nearRadius}:{farRadius}"); + } + + public void ReconfigureStreamingRadii(int nearRadius, int farRadius) + { + _streamNear = nearRadius; + _streamFar = farRadius; + events.Add($"stream:{nearRadius}:{farRadius}"); + } + + public void SetCompletionBudget(int maxCompletionsPerFrame) + { + Assert.Equal(_near, _streamNear); + Assert.Equal(_far, _streamFar); + _budget = maxCompletionsPerFrame; + events.Add($"budget:{maxCompletionsPerFrame}"); + } + } + + private sealed class FailingQualityApplicationTarget( + List events, + int failureIndex) + : IRuntimeQualityApplicationTarget + { + private int _step; + + public void SetAlphaToCoverage(bool enabled) => Record("a2c"); + + public void SetAnisotropic(int level) => Record("aniso"); + + public void PublishRenderRange(int nearRadius, int farRadius) => Record("range"); + + public void ReconfigureStreamingRadii(int nearRadius, int farRadius) => + Record("stream"); + + public void SetCompletionBudget(int maxCompletionsPerFrame) => Record("budget"); + + private void Record(string step) + { + events.Add(step); + if (_step++ == failureIndex) + throw new InvalidOperationException($"{step} failed"); + } + } + + private sealed class RecordingUiLockTarget(List events) + : IRuntimeUiLockTarget + { + public void Apply(bool locked) => events.Add($"ui:{locked}"); + } + + private sealed class FakeStorage(List? events = null) + : IRuntimeSettingsStorage + { + private readonly List _events = events ?? []; + + public DisplaySettings DisplayValue { get; set; } = DisplaySettings.Default; + + public AudioSettings AudioValue { get; set; } = AudioSettings.Default; + + public GameplaySettings GameplayValue { get; set; } = GameplaySettings.Default; + + public ChatSettings ChatValue { get; set; } = ChatSettings.Default; + + public CharacterSettings DefaultCharacterValue { get; set; } = + CharacterSettings.Default; + + public Dictionary Characters { get; } = + new(StringComparer.OrdinalIgnoreCase); + + public SettingsStore? LayoutStore => null; + + public string Location => "memory://settings"; + + public int DisplayLoads { get; private set; } + + public int AudioLoads { get; private set; } + + public int GameplayLoads { get; private set; } + + public int ChatLoads { get; private set; } + + public int CharacterLoads { get; private set; } + + public int DisplaySaves { get; private set; } + + public int GameplaySaves { get; private set; } + + public string? LastLoadedCharacter { get; private set; } + + public bool ThrowOnDisplaySave { get; init; } + + public bool ThrowOnAudioSave { get; init; } + + public bool ThrowOnGameplaySave { get; init; } + + public int RemainingGameplaySaveFailures { get; set; } + + public bool ThrowOnChatSave { get; init; } + + public bool ThrowOnCharacterSave { get; init; } + + public DisplaySettings LoadDisplay() + { + DisplayLoads++; + return DisplayValue; + } + + public AudioSettings LoadAudio() + { + AudioLoads++; + return AudioValue; + } + + public GameplaySettings LoadGameplay() + { + GameplayLoads++; + return GameplayValue; + } + + public ChatSettings LoadChat() + { + ChatLoads++; + return ChatValue; + } + + public CharacterSettings LoadCharacter(string toonKey) + { + CharacterLoads++; + LastLoadedCharacter = toonKey; + return Characters.TryGetValue(toonKey, out CharacterSettings? value) + ? value + : DefaultCharacterValue; + } + + public void SaveDisplay(DisplaySettings display) + { + DisplaySaves++; + _events.Add("save-display"); + if (ThrowOnDisplaySave) + throw new IOException("display persistence failed"); + DisplayValue = display; + } + + public void SaveAudio(AudioSettings audio) + { + _events.Add("save-audio"); + if (ThrowOnAudioSave) + throw new IOException("audio persistence failed"); + AudioValue = audio; + } + + public void SaveGameplay(GameplaySettings gameplay) + { + GameplaySaves++; + _events.Add("save-gameplay"); + if (RemainingGameplaySaveFailures > 0) + { + RemainingGameplaySaveFailures--; + throw new IOException("gameplay persistence failed"); + } + if (ThrowOnGameplaySave) + throw new IOException("gameplay persistence failed"); + GameplayValue = gameplay; + } + + public void SaveChat(ChatSettings chat) + { + _events.Add("save-chat"); + if (ThrowOnChatSave) + throw new IOException("chat persistence failed"); + ChatValue = chat; + } + + public void SaveCharacter(string toonKey, CharacterSettings character) + { + _events.Add($"save-character:{toonKey}"); + if (ThrowOnCharacterSave) + throw new IOException("character persistence failed"); + Characters[toonKey] = character; + if (string.Equals(toonKey, "default", StringComparison.OrdinalIgnoreCase)) + DefaultCharacterValue = character; + } + + public void ClearEvents() => _events.Clear(); + } + + private sealed class FakeClock : IFramePacingClock + { + public long Frequency => 1_000; + + public long GetTimestamp() => 0; + } + + private sealed class NullWaiter : IFramePacingWaiter + { + public void Wait(long durationTicks, long clockFrequency) + { + } + } + + private sealed class FakePacingSurface : IDisplayFramePacingSurface + { + private int? _refreshRate; + + public bool VSync { get; set; } + + public int RefreshReadCount { get; private set; } + + public int? ActiveMonitorRefreshHz + { + get + { + RefreshReadCount++; + return _refreshRate; + } + set => _refreshRate = value; + } + } + + private sealed class NullKeyboardSource : IKeyboardSource + { +#pragma warning disable CS0067 + public event Action? KeyDown; + public event Action? KeyUp; +#pragma warning restore CS0067 + public bool IsHeld(Key key) => false; + public ModifierMask CurrentModifiers => ModifierMask.None; + } + + private sealed class NullMouseSource : IMouseSource + { +#pragma warning disable CS0067 + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool IsHeld(MouseButton button) => false; + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => false; + } +} diff --git a/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs b/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs index eed4b3d1..c46427b1 100644 --- a/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs @@ -1,8 +1,7 @@ +using AcDream.App.Settings; using AcDream.App.Rendering.Vfx; using AcDream.App.Update; -using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Settings; -using Silk.NET.Input; namespace AcDream.App.Tests.World; @@ -13,73 +12,45 @@ public sealed class LiveObjectFrameControllerTests [InlineData( ParticleRange.Extended, ParticleVisibilityController.ExtendedRangeMultiplier)] - public void ParticleRange_NullSettingsUsesConfiguredFallback( - ParticleRange fallback, + public void ParticleRange_UsesCurrentSettingsPreview( + ParticleRange range, float expected) { - var source = new SettingsParticleRangeSource(null, fallback); + var preview = new FakeSettingsPreview( + DisplaySettings.Default with { ParticleRange = range }); + var source = new SettingsParticleRangeSource(preview); Assert.Equal(expected, source.RangeMultiplier); } [Fact] - public void ParticleRange_LiveSettingsDraftOverridesFallbackAndUpdatesImmediately() + public void ParticleRange_UpdatesImmediatelyWhenPreviewChanges() { - SettingsVM settings = CreateSettings(); - var source = new SettingsParticleRangeSource(settings, ParticleRange.Retail); + var preview = new FakeSettingsPreview(DisplaySettings.Default); + var source = new SettingsParticleRangeSource(preview); - settings.SetDisplay(settings.DisplayDraft with { ParticleRange = ParticleRange.Retail }); + preview.DisplayPreview = preview.DisplayPreview with + { + ParticleRange = ParticleRange.Retail, + }; Assert.Equal(1f, source.RangeMultiplier); - settings.SetDisplay(settings.DisplayDraft with { ParticleRange = ParticleRange.Extended }); + preview.DisplayPreview = preview.DisplayPreview with + { + ParticleRange = ParticleRange.Extended, + }; Assert.Equal( ParticleVisibilityController.ExtendedRangeMultiplier, source.RangeMultiplier); } - private static SettingsVM CreateSettings() + private sealed class FakeSettingsPreview(DisplaySettings display) + : IRuntimeSettingsPreviewSource { - var dispatcher = InputDispatcher.CreateDetached( - new NullKeyboardSource(), - new NullMouseSource(), - new KeyBindings()); - dispatcher.Attach(); - return new SettingsVM( - new KeyBindings(), - dispatcher, - static _ => { }, - DisplaySettings.Default, - static _ => { }, - AudioSettings.Default, - static _ => { }, - GameplaySettings.Default, - static _ => { }, - ChatSettings.Default, - static _ => { }, - CharacterSettings.Default, - static _ => { }); - } + public bool HasDraftPreview => true; - private sealed class NullKeyboardSource : IKeyboardSource - { -#pragma warning disable CS0067 - public event Action? KeyDown; - public event Action? KeyUp; -#pragma warning restore CS0067 - public bool IsHeld(Key key) => false; - public ModifierMask CurrentModifiers => ModifierMask.None; - } + public DisplaySettings DisplayPreview { get; set; } = display; - private sealed class NullMouseSource : IMouseSource - { -#pragma warning disable CS0067 - public event Action? MouseDown; - public event Action? MouseUp; - public event Action? MouseMove; - public event Action? Scroll; -#pragma warning restore CS0067 - public bool IsHeld(MouseButton button) => false; - public bool WantCaptureMouse => false; - public bool WantCaptureKeyboard => false; + public AudioSettings AudioPreview => AudioSettings.Default; } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index 2c75537d..b4a15961 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -434,6 +434,44 @@ public sealed class SettingsVMTests Assert.True(vm.HasUnsavedChanges); } + [Fact] + public void ApplyExternalGameplayChange_updates_both_snapshots_and_preserves_other_drafts() + { + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + bool persistedAutoTarget = !GameplaySettings.Default.AutoTarget; + + vm.SetGameplay(vm.GameplayDraft with + { + ShowTooltips = !GameplaySettings.Default.ShowTooltips, + CoordinatesOnRadar = !GameplaySettings.Default.CoordinatesOnRadar, + }); + + vm.ApplyExternalGameplayChange(gameplay => gameplay with + { + AutoTarget = persistedAutoTarget, + }); + + Assert.Equal(persistedAutoTarget, vm.GameplayDraft.AutoTarget); + Assert.Equal( + !GameplaySettings.Default.ShowTooltips, + vm.GameplayDraft.ShowTooltips); + Assert.Equal( + !GameplaySettings.Default.CoordinatesOnRadar, + vm.GameplayDraft.CoordinatesOnRadar); + Assert.True(vm.HasUnsavedChanges); + + vm.Cancel(); + + Assert.Equal(persistedAutoTarget, vm.GameplayDraft.AutoTarget); + Assert.Equal( + GameplaySettings.Default.ShowTooltips, + vm.GameplayDraft.ShowTooltips); + Assert.Equal( + GameplaySettings.Default.CoordinatesOnRadar, + vm.GameplayDraft.CoordinatesOnRadar); + Assert.False(vm.HasUnsavedChanges); + } + [Fact] public void Save_invokes_gameplay_callback_with_draft() { From c87b15303d9930a7361f5d93a1d30f998310bcdc Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 14:42:14 +0200 Subject: [PATCH 98/98] refactor(lifetime): own render resource prefixes Give terrain, sky, retained UI, portal preparation, and the update/render frame pair explicit single owners. Make shader, texture, text, bindless, and GL construction prefixes checked and retryable so partial failure cannot lose or replay resource ownership. Co-authored-by: Codex --- AGENTS.md | 14 +- CLAUDE.md | 14 +- docs/architecture/acdream-architecture.md | 11 +- docs/architecture/code-structure.md | 16 +- docs/plans/2026-04-11-roadmap.md | 17 +- docs/plans/2026-05-12-milestones.md | 13 +- ...slice-8-checkpoint-h-resource-ownership.md | 208 +++++++++ ...amewindow-slice-8-composition-lifecycle.md | 18 +- memory/project_gamewindow_decomposition.md | 29 +- .../Rendering/BindlessTextureMutationGuard.cs | 68 +++ .../Rendering/BindlessTexturePair.cs | 94 +++++ .../Rendering/GameFrameGraphSlot.cs | 67 +++ .../Rendering/GameRenderResourceLifetime.cs | 21 + src/AcDream.App/Rendering/GameWindow.cs | 126 +++--- .../Rendering/GlConstructionCleanupLedger.cs | 102 +++++ .../Rendering/GlResourceCommand.cs | 152 +++++++ .../GlTextureConstructionTransaction.cs | 85 ++++ .../Rendering/RenderFrameOrchestrator.cs | 2 +- .../Rendering/ResourceCleanupGroup.cs | 63 +++ .../RestoredTextureBindingMutation.cs | 83 ++++ .../Rendering/RuntimeResourceSlots.cs | 171 ++++++++ src/AcDream.App/Rendering/Shader.cs | 40 +- .../Rendering/ShaderProgramConstruction.cs | 325 ++++++++++++++ src/AcDream.App/Rendering/TerrainAtlas.cs | 336 +++++++++------ src/AcDream.App/Rendering/TextRenderer.cs | 177 +++++--- .../Rendering/TrackedTextureConstruction.cs | 31 ++ .../Rendering/Wb/BindlessSupport.cs | 42 +- .../Rendering/Wb/TrackedGlResource.cs | 80 ++-- src/AcDream.App/UI/RetailUiRuntime.cs | 13 +- src/AcDream.App/UI/RetailUiRuntimeLease.cs | 263 ++++++++++++ src/AcDream.App/UI/UiHost.cs | 4 +- .../Update/UpdateFrameOrchestrator.cs | 2 +- .../GameWindowRenderLeafCompositionTests.cs | 9 +- .../GameWindowSlice8BoundaryTests.cs | 79 +++- .../Rendering/GlTextureOwnershipTests.cs | 396 ++++++++++++++++++ .../Rendering/ResourceCleanupGroupTests.cs | 122 ++++++ .../Rendering/RuntimeResourceSlotTests.cs | 271 ++++++++++++ .../ShaderProgramConstructionTests.cs | 197 +++++++++ .../Rendering/WorldRenderFrameBuilderTests.cs | 2 +- .../UI/RetailUiRuntimeLeaseTests.cs | 352 ++++++++++++++++ .../World/UpdateFrameOrchestratorTests.cs | 8 +- 41 files changed, 3768 insertions(+), 355 deletions(-) create mode 100644 docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-h-resource-ownership.md create mode 100644 src/AcDream.App/Rendering/BindlessTextureMutationGuard.cs create mode 100644 src/AcDream.App/Rendering/BindlessTexturePair.cs create mode 100644 src/AcDream.App/Rendering/GameFrameGraphSlot.cs create mode 100644 src/AcDream.App/Rendering/GameRenderResourceLifetime.cs create mode 100644 src/AcDream.App/Rendering/GlConstructionCleanupLedger.cs create mode 100644 src/AcDream.App/Rendering/GlResourceCommand.cs create mode 100644 src/AcDream.App/Rendering/GlTextureConstructionTransaction.cs create mode 100644 src/AcDream.App/Rendering/ResourceCleanupGroup.cs create mode 100644 src/AcDream.App/Rendering/RestoredTextureBindingMutation.cs create mode 100644 src/AcDream.App/Rendering/RuntimeResourceSlots.cs create mode 100644 src/AcDream.App/Rendering/ShaderProgramConstruction.cs create mode 100644 src/AcDream.App/Rendering/TrackedTextureConstruction.cs create mode 100644 src/AcDream.App/UI/RetailUiRuntimeLease.cs create mode 100644 tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ShaderProgramConstructionTests.cs create mode 100644 tests/AcDream.App.Tests/UI/RetailUiRuntimeLeaseTests.cs diff --git a/AGENTS.md b/AGENTS.md index b5d4a495..13c1c3e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,8 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,524 tests pass / 5 skip. +Release builds; Checkpoint H passes 61 focused ownership tests, 3,236 App tests +/ 3 skips, and 7,606 complete-solution tests / 5 skips. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -127,7 +128,7 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–G are also complete: dead host residue is removed, +Slice 8 checkpoints A–H are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT @@ -146,8 +147,13 @@ application, and inert late target binding. `GameWindow` is now 3,663 raw lines 162 fields / 37 methods, down 12,060 lines (76.7%) from the 15,723-line campaign baseline. The 7,553-test / 5-skip Checkpoint G Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop soak pass. -Slice 8 checkpoints H–L—resource ownership, ordered composition, -retryable shutdown, soak snapshots, and final connected gates—remain active. +Checkpoint H adds sole terrain/sky render-resource roots, one retained +Host/runtime lease, an atomic update/render frame-root pair, prepared portal +fallback/transfer, and checked retryable GL construction/state boundaries. +`GameWindow` is 3,689 raw lines / 162 fields / 37 methods, down 12,034 lines +(76.5%) from baseline. All three final corrected-diff reviews are clean. Slice +8 checkpoints I–L—ordered composition, retryable shutdown, canonical soak +snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, #116, remaining diff --git a/CLAUDE.md b/CLAUDE.md index 518ad669..8fae80e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,8 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; 7,524 tests pass / 5 skip. +Release builds; Checkpoint H passes 61 focused ownership tests, 3,236 App tests +/ 3 skips, and 7,606 complete-solution tests / 5 skips. **Active prerequisite before new M4 subsystem work:** continue the behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -125,7 +126,7 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–G are also complete: dead host residue is removed, +Slice 8 checkpoints A–H are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT @@ -144,8 +145,13 @@ application, and inert late target binding. `GameWindow` is now 3,663 raw lines 162 fields / 37 methods, down 12,060 lines (76.7%) from the 15,723-line campaign baseline. The 7,553-test / 5-skip Checkpoint G Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop soak pass. -Slice 8 checkpoints H–L—resource ownership, ordered composition, -retryable shutdown, soak snapshots, and final connected gates—remain active. +Checkpoint H adds sole terrain/sky render-resource roots, one retained +Host/runtime lease, an atomic update/render frame-root pair, prepared portal +fallback/transfer, and checked retryable GL construction/state boundaries. +`GameWindow` is 3,689 raw lines / 162 fields / 37 methods, down 12,034 lines +(76.5%) from baseline. All three final corrected-diff reviews are clean. Slice +8 checkpoints I–L—ordered composition, retryable shutdown, canonical soak +snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, #116, remaining diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index c2605a03..4179c50b 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -829,15 +829,18 @@ Current movement/collision ownership: routers; Slice 4 moved hydration, teardown, inbound authority, network updates, and reusable motion-runtime policy over canonical `LiveEntityRuntime`. Slices 5–7 complete landblock presentation and the - update/render frame graphs. Slice 8 checkpoints A–G now own the native + update/render frame graphs. Slice 8 checkpoints A–H now own the native callback shell, live-session composition, world environment, reversible input/pointer lifetime, physical framebuffer publication, the sole gameplay action-priority graph, typed commands, the retained-root item-drop edge, and - two-phase runtime settings. `RuntimeSettingsController` is the sole settings + two-phase runtime settings, sole terrain/sky render-resource lifetime, + retained Host/runtime lease, atomic frame-root pair, and prepared portal + fallback/transfer. `RuntimeSettingsController` is the sole settings persistence/current-state owner, supplies the immutable pre-window snapshot, applies startup pacing/display/FOV/audio once, and later borrows complete - runtime targets without replay. Explicit resource ownership and frame slots - are the next checkpoint. The reconciled ledger and sequence live in + runtime targets without replay. Checked GL construction/state boundaries + retain exact cleanup obligations across failure. Ordered production + composition is the next checkpoint. The reconciled ledger and sequence live in `docs/architecture/code-structure.md`. This decision does not reclassify the remaining L.2 conformance work or Track LH. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 70e4a417..de7332ef 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -694,7 +694,7 @@ slices. `OnClosing` delegates to the existing retryable shutdown transaction. Silk callbacks become narrow calls into the input, update, render, resize, focus, and shutdown owners. -Checkpoints E, F, and G are complete. `CameraPointerInputController` owns raw pointer, +Checkpoints E, F, G, and H are complete. `CameraPointerInputController` owns raw pointer, camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController` alone publishes physical framebuffer changes. All Silk, dispatcher, retained UI, and optional devtools device edges are reversible, transactional, and @@ -715,10 +715,16 @@ then borrows late-bound runtime targets without replay. Saved FOV now applies at startup even when devtools are disabled; this is a deliberate correction of the old accidental SettingsVM gate. The controller also preserves unrelated unsaved Gameplay draft fields when combat preferences change. `GameWindow` is now 3,663 -raw lines / 162 fields / 37 methods. Checkpoints H–L remain active, with explicit -resource ownership and frame slots next. The Checkpoint G production App build -is warning-free, all three corrected-diff reviews are clean, and the complete -Release suite passes 7,553 tests / 5 intentional skips. +raw lines / 162 fields / 37 methods at G. H adds sole lifetime roots for the +terrain atlas and dedicated sky shader, one retained Host/runtime lease, an +atomic update/render frame-root slot, and a prepare-aware portal fallback and +transfer slot. GL construction and state mutations now use checked commit +boundaries with exact retry ownership for failed names, bindless residency, +and texture-binding restoration. `GameWindow` is 3,689 raw lines / 162 fields / +37 methods. Checkpoints I–L remain active, with ordered production composition +next. The Checkpoint H production App build is warning-free, its 61 focused +ownership tests pass, the complete Release suite passes 7,606 tests / 5 skips, +and all three final corrected-diff reviews are clean. ### 4.4 Exit criteria diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index dd23dcdb..1e0d45d9 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-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–G are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** 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. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–H are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. 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. --- @@ -79,7 +79,7 @@ second synchronized soak pass with graceful exits, and the six stable PNG checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse process-residency gate's diagnostic blind spot; no tolerance was loosened. -Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–G now own +Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–H now own the frozen shell boundary, all nine native callback edges, live-session reset/selection/entry/route composition, and world clock/sky/day/weather/ AdminEnvirons state. Checkpoint E adds reversible, transactional Silk, @@ -94,10 +94,15 @@ retained-root item-drop binding. `GameWindow` is now 4,057 lines / 198 fields / Checkpoint G adds the pre-window `RuntimeSettingsController`, one immutable startup snapshot, ordered startup pacing/display/FOV/audio application, late runtime-target binding without replay, and one current/toon settings owner. -`GameWindow` is now 3,663 lines / 162 fields / 37 methods. Resource transfer, -ordered composition, shutdown, soak, and closeout checkpoints H–L remain. The -Checkpoint G Release suite passes 7,553 tests / 5 intentional skips, and all -three corrected-diff reviews are clean. +`GameWindow` is 3,663 lines / 162 fields / 37 methods at G. Checkpoint H adds +sole terrain-atlas and sky-shader roots, one retained Host/runtime lease, an +atomic update/render frame-root pair, prepared portal fallback/transfer, and +checked retryable GL leaf construction and state mutation. `GameWindow` is now +3,689 lines / 162 fields / 37 methods. Ordered composition, shutdown, canonical +soak snapshots, and closeout checkpoints I–L remain. Checkpoint H passes 61 +focused ownership tests and 3,236 App tests / 3 intentional skips; the complete +Release suite passes 7,606 tests / 5 skips and all three final corrected-diff +reviews are clean. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 5cd9a405..5b8fe6ee 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -39,7 +39,7 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized nine-destination soak pass with graceful exits, and six stable PNG checkpoints -preserve Slice 6 presentation. Final Slice 8 checkpoints A–G are complete: +preserve Slice 6 presentation. Final Slice 8 checkpoints A–H are complete: native callbacks and live-session composition now have explicit retryable owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky, day group, weather, and AdminEnvirons state. Checkpoint E adds terminal, @@ -50,10 +50,15 @@ gameplay action-priority owner, typed combat/diagnostic/general commands, and a reversible retained-root item-drop binding. Checkpoint G adds the sole pre-window/runtime settings owner, one startup snapshot, ordered one-shot startup application, and inert late target binding. `GameWindow` is 3,663 -lines / 162 fields / 37 methods. Checkpoints H–L remain active. Issue #232 +lines / 162 fields / 37 methods at G. Checkpoint H adds sole terrain/sky +resource roots, one retained Host/runtime lease, an atomic update/render root +pair, a prepared portal fallback/transfer transaction, and checked retryable GL +leaf construction. `GameWindow` is 3,689 lines / 162 fields / 37 methods. +Checkpoints I–L remain active. Issue #232 tracks process-residency variance in the soak without loosening its leak -threshold. The Checkpoint G Release suite passes 7,553 tests / 5 skips and all -three corrected-diff reviews are clean. +threshold. Checkpoint H passes 61 focused ownership tests and 3,236 App tests / +3 skips; the complete Release suite passes 7,606 tests / 5 skips and all three +final corrected-diff reviews are clean. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-h-resource-ownership.md b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-h-resource-ownership.md new file mode 100644 index 00000000..f6485182 --- /dev/null +++ b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-h-resource-ownership.md @@ -0,0 +1,208 @@ +# GameWindow Slice 8 — Checkpoint H Resource Ownership Plan + +## Objective + +Replace the remaining implicit, constructor-local, and nullable-field resource +ownership in `GameWindow` with explicit single-owner transactions. This is a +behavior-preserving lifetime cutover: draw/update order, portal presentation, +retained UI behavior, settings behavior, and the frozen shutdown order do not +change. + +Checkpoint H does not move the complete shutdown manifest; that is Checkpoint +J. It supplies the typed roots and retry semantics that J will consume. + +## Frozen invariants + +1. Every GL/kernel-owning object is published to exactly one owner immediately + after successful construction. +2. Renderers borrow the terrain atlas and dedicated sky shader. They never + dispose those resources. The renderer borrower retires before the sole + resource owner releases the borrowed atlas/shader. +3. A leaf factory that throws before returning must release every GL name it + created. Outer phase/lifetime rollback cannot recover a name that was never + published. +4. Bindless residency is a resource prefix: partial handle acquisition rolls + back, successful non-residency is not replayed after a later failure, and + textures are not deleted while a resident handle remains. +5. Retained UI input is quiesced before the live-session barrier and physically + detached afterward, independently of runtime/host disposal. +6. Exactly one retained-UI ownership chain exists. Before runtime construction + the lease owns the `UiHost`; after a runtime object is published the lease + retains that exact partial/full runtime and delegates final disposal to it. + Constructor or initialization failure never leaves a host in a local only. +7. Update and render roots publish atomically as one frame graph. Silk callbacks + resolve only through the slot; an empty/withdrawn slot is safely inert. +8. Portal tunnel ownership starts in one fallback slot. Transfer clears the + fallback only after the complete local-teleport controller factory returns; + any earlier failure leaves the same tunnel reachable by shutdown. +9. Successful disposal is never replayed. Failed disposal remains retryable. + Terminal abandonment is explicit and retains references for native/process + fallback rather than silently losing ownership. + +## Typed owners + +### `OwnedResourceSlot` and `GameRenderResourceLifetime` + +- `OwnedResourceSlot` acquires through a factory, rejects duplicate + publication, exposes a typed borrower, and clears ownership only after + `Dispose` succeeds. +- `GameRenderResourceLifetime` contains separate slots for `TerrainAtlas` and + the dedicated sky `Shader`. +- `GameWindow` acquires both through this owner. Shutdown releases the sky + renderer and terrain renderer first, then the two resource slots, then GL. + +### `RetailUiRuntimeLease` + +- The lease exists before `OnLoad` and acquires the `UiHost` through a factory. +- Runtime construction and initialization are separate. The runtime is stored + in the lease before initialization begins. +- Initialization failure attempts immediate cleanup. Successful cleanup + rethrows the original initialization error; cleanup failure reports both and + leaves the exact runtime in the lease for lifetime retry. +- The lease exposes idempotent `QuiesceInput` and retryable `DeactivateInput` + operations against the retained host. +- If no runtime was constructed, lease disposal directly disposes the host. If + a runtime exists, runtime disposal remains the one path that disposes the + host. There are never two host disposers. +- Explicit terminal abandonment is allowed only after a failed disposal pass; + it makes later disposal inert while retaining the unresolved references for + the native/process fallback handled by Checkpoint J. + +### `GameFrameGraphSlot` + +- `IGameUpdateFrameRoot` and `IGameRenderFrameRoot` are the narrow frame seams. +- `UpdateFrameOrchestrator` and `RenderFrameOrchestrator` implement those seams. +- `Publish(update, render)` is atomic and one-shot until withdrawal. +- `Tick`/`Render` are inert before publication and after withdrawal. +- Shutdown withdraws the pair only after live-session convergence and before + any frame borrower retires. + +### `TransferableResourceSlot` + +- `Acquire` owns one fallback resource. +- `Transfer(factory)` retains fallback ownership while the complete destination + factory runs and clears it only after that factory returns successfully. +- Failed acquisition, resource preparation, destination construction, binding, + and normal shutdown prefixes are covered without double disposal. + +## Exception-safe GL leaves + +### Shader program construction + +`ShaderProgramConstruction` uses a narrow `IShaderProgramBuildApi`. It tracks +the vertex shader, fragment shader, and linked program as soon as each name is +returned. Compile, attach, link, detach, or cleanup failure attempts every +remaining rollback operation. A clean rollback preserves the original failure; +rollback failures are aggregated with it. `Shader.Dispose` becomes idempotent +and retryable. + +### Terrain atlas construction and residency + +`GlTextureConstructionTransaction` tracks every texture name allocated by +terrain, alpha, and fallback paths. `TerrainAtlas.Build`, `BuildAlphaAtlas`, and +`BuildFallback` allocate only through that transaction. The transaction commits +only after the complete `TerrainAtlas` object exists; failure rolls back all +names in reverse acquisition order and reports cleanup failures with the +original error. + +`BindlessTexturePair` owns the terrain/alpha residency pair. Partial acquisition +releases the first handle. Release attempts both handles, remembers successful +substeps, and is retryable. `TerrainAtlas.Dispose` releases residency first, +then independently deletes both texture names through the existing retryable +shutdown transaction. + +## Production integration order + +1. Construct render-resource lifetime, UI lease, frame slot, and portal + fallback slot before `Window.Create`/`OnLoad` acquisitions. +2. Acquire the atlas through `GameRenderResourceLifetime`; construct + `TerrainModernRenderer` as a borrower. +3. Acquire `UiHost` through `RetailUiRuntimeLease`, wire input/assets, construct + and publish `RetailUiRuntime` before initialization, then expose host/runtime + only as borrower aliases. +4. Acquire/prepare portal presentation in the fallback slot. +5. Acquire the dedicated sky shader through the render-resource lifetime, then + construct `SkyRenderer` as a borrower. +6. Transfer the portal fallback only through the complete + `LocalPlayerTeleportController` factory. +7. Construct update/render roots as locals and publish them atomically through + `GameFrameGraphSlot`. +8. Update/render callbacks resolve only through the slot. +9. Shutdown quiesces/deactivates UI through the lease, withdraws frame roots, + disposes the UI lease and renderer borrowers, releases atlas/sky roots, then + reaches GL. + +## Automated gates + +- owned-resource acquire/borrow/release, duplicate acquire, transient failure, + retry, and no replay; +- UI host acquisition, runtime constructor failure, Initialize failure with + successful cleanup, transient cleanup then lifetime retry, persistent cleanup + then explicit abandonment, separate input quiescence/deactivation, and exact + host disposal count; +- frame pair atomic publication, duplicate publication, update/render routing, + withdrawal, pre-publication/post-withdrawal silence, and republish policy; +- portal fallback acquisition/preparation/transfer failures plus successful + transfer and every shutdown prefix, with exact one-resource disposal; +- vertex/fragment compile failure, program creation/link failure, and cleanup + failure after every created shader/program name; +- terrain/alpha/fallback texture allocation and upload failure after every + created name, reverse rollback, cleanup aggregation, and committed-name + non-deletion; +- bindless first/second acquisition failure, release partial failure/retry, and + no deletion before residency release; +- source boundary proves direct frame-root fields, constructor-local atlas/sky + ownership, direct UI host/runtime ownership, and nullable portal fallback are + removed from `GameWindow`; +- focused App tests, production App Release build, solution Release build, and + complete Release suite. + +## Review and commit gate + +The existing architecture/integration, retail-conformance, and adversarial +reviewers inspect the complete tracked and untracked diff independently. Every +confirmed finding is corrected and all three repeat until clean. Architecture, +roadmap, milestones, issues, memory, `CLAUDE.md`, and `AGENTS.md` are reconciled +in the same bisectable Checkpoint H commit. + +## Implementation result — 2026-07-22 + +Checkpoint H landed the planned ownership graph without changing the accepted +update/render order or gameplay behavior: + +- `GameRenderResourceLifetime` owns the terrain atlas and dedicated sky shader; + renderers borrow them and retire first. +- `RetailUiRuntimeLease` owns Host/runtime construction, initialization, input + cutoff, retryable disposal, and explicit terminal abandonment as one chain. +- `GameFrameGraphSlot` publishes update/render roots atomically and makes empty + or withdrawn callbacks inert. +- `TransferableResourceSlot.AcquirePrepared` publishes the portal presentation + before mesh preparation, retries the same partial resource, and refuses + transfer until preparation has committed. +- GL name creation records the returned name before post-command validation. + Shader/program, texture, text-renderer, buffer/VAO, bindless-residency, and + texture-binding operations advance ownership only after an always-on checked + GL commit boundary. +- Failed construction cleanup is retained by `GlConstructionCleanupLedger`; + exact pending names and binding-restore obligations remain retryable, while + successful cleanup stages never replay. + +Four corrected-diff cycles closed issues found by the architecture, retail, +and adversarial reviewers: partial bindless mutation, lost GL rollback names, +constructor-local text resources, reentrant UI ownership, portal preparation, +unchecked production GL calls, leaked texture binding, and the bindless post- +commit verification gap. All three final reviews are clean. + +Automated acceptance: + +- focused Checkpoint H ownership/lifetime gate: 61 passed; +- App Release suite: 3,236 passed / 3 intentional skips; +- production App Release build: zero warnings and zero errors; +- complete solution Release suite: 7,606 passed / 5 intentional skips; solution + build has zero errors and the 17 existing test-project warnings tracked by + #228; +- `GameWindow.cs`: 3,689 raw lines / 162 fields / 37 methods, versus 15,723 / + 278 / 205 at the campaign baseline. + +No AC-specific algorithm or intended retail behavior changed, so Checkpoint H +adds no retail-divergence row. Checkpoint I is the next active unit. diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index 61a38b97..bf9c5000 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -26,7 +26,7 @@ audit. - [x] F — extract the sole gameplay input-action router plus focused combat and diagnostic command owners. - [x] G — extract two-phase persisted settings/display/quality ownership. -- [ ] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots +- [x] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots explicit single ownership and transfer seams. - [ ] I — group `OnLoad` into small ordered, fakeable composition phases with transactional partial-acquisition rollback. @@ -598,6 +598,22 @@ intentional skips (17 existing test-project warnings remain tracked by #228). Inject GL compile/link/texture-allocation failure after every internal name and prove exact rollback/residency release. +Checkpoint H is complete. `GameRenderResourceLifetime` is the sole terrain- +atlas and dedicated-sky-shader owner; renderers borrow and retire before those +roots. `RetailUiRuntimeLease` retains the Host/runtime chain through partial +construction, input cutoff, retryable disposal, and explicit terminal +abandonment. `GameFrameGraphSlot` publishes update/render roots atomically, and +the portal tunnel now uses one prepare-aware fallback/transfer transaction. +Shader, terrain, text-renderer, buffer/VAO, bindless, and texture-binding +construction/mutation paths use always-on checked GL commit boundaries. A +failed cleanup retains the exact name/handle/binding obligation for retry, and +successful substeps never replay. `GameWindow` is 3,689 raw lines / 162 fields +/ 37 methods. The focused ownership gate passes 61 tests, the App Release suite +passes 3,236 tests / 3 intentional skips, and all three independent final +corrected-diff reviews are clean. The complete Release suite passes 7,606 tests +/ 5 intentional skips; the 17 existing test-project warnings remain tracked by +#228. + ### I — ordered production composition - Split the 2,289-line `OnLoad` body at §3.6 boundaries using the fixed typed diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index de8cd214..cca15fc2 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -3,9 +3,9 @@ ## Current state The behavior-preserving App ownership campaign is complete through Slice 7 and -Slice 8 checkpoints A–G. `GameWindow.cs` moved from the 2026-07-21 baseline of -15,723 lines / 278 fields / 205 methods to 3,663 lines / 162 fields / 37 -methods: 12,060 lines (76.7%) were removed without changing accepted gameplay +Slice 8 checkpoints A–H. `GameWindow.cs` moved from the 2026-07-21 baseline of +15,723 lines / 278 fields / 205 methods to 3,689 lines / 162 fields / 37 +methods: 12,034 lines (76.5%) were removed without changing accepted gameplay or rendering behavior. | Slice | Ownership moved out | Closeout size | @@ -20,6 +20,7 @@ or rendering behavior. | 8 A–E | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 | | 8 F | sole gameplay action route, typed commands, retained-root callback lifetime | 4,057 / 198 / 54 | | 8 G | pre-window/runtime settings ownership, one startup snapshot, inert late target binding | 3,663 / 162 / 37 | +| 8 H | render/UI/frame/portal resource roots plus checked retryable GL leaves | 3,689 / 162 / 37 | Slice 8 is an ordered checkpoint campaign rather than another feature-body move. A froze/pruned the shell boundary; B gave all nine native window callback @@ -72,6 +73,18 @@ Saved FOV now applies without devtools, fixing the former accidental SettingsVM gate. AP-121 records the intentional modern persistence added to the named-retail `/framerate` live toggle/notice. +H adds explicit ownership for the terrain atlas, dedicated sky shader, +retained Host/runtime chain, atomic update/render root pair, and portal tunnel +fallback/preparation/transfer. Every GL construction or mutation prefix added +to the checkpoint advances CPU ownership only after an always-on checked +driver commit. Failed shader/program/texture/buffer/VAO/text construction +retains exact names through the construction ledger; bindless residency and +the prior texture binding retain retry obligations across transient failure. +UI input cutoff and resource slots reject reentrant ownership mutation, and +portal preparation retries the same published fallback instead of replaying +the factory. This is a lifetime-only cutover; no AC algorithm or accepted +presentation order changed. + Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`, @@ -128,6 +141,12 @@ review rule live in `docs/architecture/code-structure.md`. ## Accepted verification +- Slice 8 Checkpoint H: 61 focused ownership/lifetime tests pass; App Release + passes 3,236 tests / 3 intentional skips. Four corrected-diff cycles closed + every architecture, retail, and adversarial finding; all three final reviews + are clean. The production App build has zero warnings/errors. The complete + Release suite passes 7,606 tests / 5 intentional skips; the 17 existing test- + project warnings remain tracked by #228. - Slice 8 Checkpoint G Release suite: 7,553 passed / 5 fixture or environment skips; App 3,183/3 skips, UI Abstractions 534/0, 35 focused App settings/boundary tests, and all 43 `SettingsVMTests` pass. The production App @@ -170,8 +189,8 @@ review rule live in `docs/architecture/code-structure.md`. ## Next work -Slice 8 checkpoints H–L group the remaining resource ownership, ordered -startup composition, and shutdown wiring and reduce Silk callbacks to +Slice 8 checkpoints I–L group the remaining ordered startup composition, +shutdown wiring, canonical soak snapshots, and closeout and reduce Silk callbacks to narrow calls into input, update, render, resize, focus, and shutdown owners. It removes leftover feature state only where Slices 1–7 already established a canonical owner; it does not redesign gameplay, rendering, or resource lifetime. diff --git a/src/AcDream.App/Rendering/BindlessTextureMutationGuard.cs b/src/AcDream.App/Rendering/BindlessTextureMutationGuard.cs new file mode 100644 index 00000000..f3b5d2f0 --- /dev/null +++ b/src/AcDream.App/Rendering/BindlessTextureMutationGuard.cs @@ -0,0 +1,68 @@ +using System.Runtime.ExceptionServices; + +namespace AcDream.App.Rendering; + +/// +/// Temporarily removes bindless residency around texture-state mutation while +/// retaining the obligation to restore the pair across failed mutation or +/// failed reacquisition attempts. +/// +internal sealed class BindlessTextureMutationGuard(BindlessTexturePair pair) +{ + private bool _restoreRequired; + private bool _operationActive; + + internal bool RestoreRequired => _restoreRequired; + + public void Execute(Action mutation) + { + ArgumentNullException.ThrowIfNull(mutation); + if (_operationActive) + throw new InvalidOperationException("A bindless texture mutation is already active."); + + _operationActive = true; + Exception? mutationFailure = null; + Exception? restoreFailure = null; + try + { + _restoreRequired |= pair.HasAnyResident; + if (pair.HasAnyResident) + pair.Release(); + + mutation(); + } + catch (Exception failure) + { + mutationFailure = failure; + } + finally + { + if (_restoreRequired) + { + try + { + _ = pair.Acquire(); + _restoreRequired = false; + } + catch (Exception failure) + { + restoreFailure = failure; + } + } + + _operationActive = false; + } + + if (mutationFailure is not null && restoreFailure is not null) + { + throw new AggregateException( + "Texture-state mutation failed and bindless residency could not be restored.", + mutationFailure, + restoreFailure); + } + if (mutationFailure is not null) + ExceptionDispatchInfo.Capture(mutationFailure).Throw(); + if (restoreFailure is not null) + ExceptionDispatchInfo.Capture(restoreFailure).Throw(); + } +} diff --git a/src/AcDream.App/Rendering/BindlessTexturePair.cs b/src/AcDream.App/Rendering/BindlessTexturePair.cs new file mode 100644 index 00000000..2b0cd50b --- /dev/null +++ b/src/AcDream.App/Rendering/BindlessTexturePair.cs @@ -0,0 +1,94 @@ +namespace AcDream.App.Rendering; + +/// +/// Owns the two independent residency edges used by TerrainAtlas. Acquisition +/// and release publish each successful edge immediately, so a partial failure +/// retries only work that is still pending. +/// +internal sealed class BindlessTexturePair( + uint firstTexture, + uint secondTexture, + Func acquire, + Action release) +{ + private ulong _firstHandle; + private ulong _secondHandle; + private bool _firstResident; + private bool _secondResident; + + public bool IsFullyResident => _firstResident && _secondResident; + public bool HasAnyResident => _firstResident || _secondResident; + + public (ulong First, ulong Second) Acquire() + { + if (!_firstResident) + { + _firstHandle = acquire(firstTexture); + _firstResident = true; + } + + if (!_secondResident) + { + try + { + _secondHandle = acquire(secondTexture); + _secondResident = true; + } + catch (Exception acquisitionFailure) + { + try + { + Release(); + } + catch (Exception cleanupFailure) + { + throw new AggregateException( + "Second bindless texture acquisition failed and the resident prefix did not cleanly roll back.", + acquisitionFailure, + cleanupFailure); + } + + throw; + } + } + + return (_firstHandle, _secondHandle); + } + + public void Release() + { + List? failures = null; + if (_firstResident) + { + try + { + release(_firstHandle); + _firstResident = false; + _firstHandle = 0; + } + catch (Exception failure) + { + (failures ??= []).Add(failure); + } + } + + if (_secondResident) + { + try + { + release(_secondHandle); + _secondResident = false; + _secondHandle = 0; + } + catch (Exception failure) + { + (failures ??= []).Add(failure); + } + } + + if (failures is not null) + throw new AggregateException( + "One or more bindless texture handles could not be made non-resident.", + failures); + } +} diff --git a/src/AcDream.App/Rendering/GameFrameGraphSlot.cs b/src/AcDream.App/Rendering/GameFrameGraphSlot.cs new file mode 100644 index 00000000..bde9adc0 --- /dev/null +++ b/src/AcDream.App/Rendering/GameFrameGraphSlot.cs @@ -0,0 +1,67 @@ +using AcDream.App.Update; + +namespace AcDream.App.Rendering; + +internal interface IGameUpdateFrameRoot +{ + void Tick(UpdateFrameInput input); +} + +internal interface IGameRenderFrameRoot +{ + RenderFrameOutcome Render(RenderFrameInput input); +} + +/// +/// Sole atomic publication point for the current update/render frame pair. +/// Native callbacks are deliberately inert before publication and after +/// withdrawal so a partial load or converging shutdown cannot enter half a +/// frame graph. +/// +internal sealed class GameFrameGraphSlot +{ + private sealed record FrameGraphPair( + IGameUpdateFrameRoot Update, + IGameRenderFrameRoot Render); + + private FrameGraphPair? _pair; + + public bool IsPublished => Volatile.Read(ref _pair) is not null; + + public void Publish(IGameUpdateFrameRoot update, IGameRenderFrameRoot render) + { + ArgumentNullException.ThrowIfNull(update); + ArgumentNullException.ThrowIfNull(render); + var pair = new FrameGraphPair(update, render); + if (Interlocked.CompareExchange(ref _pair, pair, null) is not null) + throw new InvalidOperationException("A game frame graph is already published."); + } + + public bool Tick(UpdateFrameInput input) + { + FrameGraphPair? pair = Volatile.Read(ref _pair); + if (pair is null) + return false; + + pair.Update.Tick(input); + return true; + } + + public bool Render(RenderFrameInput input, out RenderFrameOutcome outcome) + { + FrameGraphPair? pair = Volatile.Read(ref _pair); + if (pair is null) + { + outcome = default; + return false; + } + + outcome = pair.Render.Render(input); + return true; + } + + public void Withdraw() + { + Interlocked.Exchange(ref _pair, null); + } +} diff --git a/src/AcDream.App/Rendering/GameRenderResourceLifetime.cs b/src/AcDream.App/Rendering/GameRenderResourceLifetime.cs new file mode 100644 index 00000000..e24575f5 --- /dev/null +++ b/src/AcDream.App/Rendering/GameRenderResourceLifetime.cs @@ -0,0 +1,21 @@ +namespace AcDream.App.Rendering; + +/// +/// Sole lifetime owner for render resources that are borrowed by, but not +/// owned by, their renderers. +/// +internal sealed class GameRenderResourceLifetime +{ + private readonly OwnedResourceSlot _terrainAtlas = new(); + private readonly OwnedResourceSlot _skyShader = new(); + + public TerrainAtlas AcquireTerrainAtlas(Func factory) => + _terrainAtlas.Acquire(factory); + + public Shader AcquireSkyShader(Func factory) => + _skyShader.Acquire(factory); + + public void ReleaseTerrainAtlas() => _terrainAtlas.Release(); + + public void ReleaseSkyShader() => _skyShader.Release(); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 24818871..fa02ff8a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -92,8 +92,11 @@ public sealed class GameWindow : IDisposable private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots; private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; - private AcDream.App.Rendering.RenderFrameOrchestrator? - _renderFrameOrchestrator; + private readonly AcDream.App.Rendering.GameFrameGraphSlot _frameGraphs = new(); + private readonly AcDream.App.Rendering.GameRenderResourceLifetime + _renderResourceLifetime = new(); + private readonly AcDream.App.Rendering.GlConstructionCleanupLedger + _glConstructionCleanup = new(); private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment = new(Console.WriteLine); private ResourceShutdownTransaction? _shutdown; @@ -148,7 +151,6 @@ public sealed class GameWindow : IDisposable private readonly AcDream.App.Input.MovementTruthDiagnosticController _movementTruthDiagnostics; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; - private AcDream.App.Update.UpdateFrameOrchestrator _updateFrameOrchestrator = null!; private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new(); private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; // Step 7 projectile presentation. The controller owns no identity map; @@ -202,7 +204,8 @@ public sealed class GameWindow : IDisposable // R1 (render redesign): portal-view draw owners are retained transitively // by the frame orchestrator rather than duplicated as GameWindow roots. private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; - private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel; + private readonly AcDream.App.Rendering.TransferableResourceSlot< + AcDream.App.Rendering.PortalTunnelPresentation> _portalTunnelFallback = new(); // Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain // UBO). In U.3 a single ClipFrame.NoClip() instance is created lazily (??=) and @@ -342,6 +345,7 @@ public sealed class GameWindow : IDisposable // Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1. private AcDream.App.UI.UiHost? _uiHost; private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime; + private readonly AcDream.App.UI.RetailUiRuntimeLease _retailUiLease = new(); private readonly AcDream.App.Combat.CombatAttackOperationsSlot _combatAttackOperations = new(); private readonly AcDream.App.Combat.CombatFeedbackSlot @@ -638,7 +642,15 @@ public sealed class GameWindow : IDisposable _displayFramePacing, _hostQuiescence); _windowCallbacks.Attach(); - _window.Run(); + try + { + _window.Run(); + } + catch (Exception failure) + { + _glConstructionCleanup.RetainFrom(failure); + throw; + } } private void OnLoad() @@ -1100,7 +1112,11 @@ public sealed class GameWindow : IDisposable // Build the terrain atlas once from the Region dat. Phase N.5b: the // atlas exposes bindless handles for the modern terrain path, so // BindlessSupport is threaded through. - var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats, _bindlessSupport); + var terrainAtlas = _renderResourceLifetime.AcquireTerrainAtlas( + () => AcDream.App.Rendering.TerrainAtlas.Build( + _gl, + _dats, + _bindlessSupport)); // A.5 T22.5: apply anisotropic level from quality preset. Build() // hard-codes 16x; override here to match the resolved quality so Low // (4x) and Medium (8x) actually take effect. @@ -1230,11 +1246,12 @@ public sealed class GameWindow : IDisposable if (_options.RetailUi) { _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); - _uiHost = new AcDream.App.UI.UiHost( - _gl, - shadersDir, - _debugFont, - _hostQuiescence); + _uiHost = _retailUiLease.AcquireHost( + () => new AcDream.App.UI.UiHost( + _gl, + shadersDir, + _debugFont, + _hostQuiescence)); _retainedInputCapture.Root = _uiHost.Root; _uiHost.Root.UiLocked = _runtimeSettings.Gameplay.LockUI; var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( @@ -1360,8 +1377,7 @@ public sealed class GameWindow : IDisposable UiProbeLog); } - _retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount( - new AcDream.App.UI.RetailUiRuntimeBindings( + var retailUiBindings = new AcDream.App.UI.RetailUiRuntimeBindings( Host: _uiHost, Assets: new AcDream.App.UI.RetailUiAssets( _dats!, _datLock, ResolveChrome, ResolveDatFont, @@ -1511,7 +1527,10 @@ public sealed class GameWindow : IDisposable action => _inputDispatcher?.TryInvokeAutomationAction(action) == true, (action, held) => _inputDispatcher?.TrySetAutomationActionHeld(action, held) == true, - _worldLifecycleAutomation))); + _worldLifecycleAutomation)); + _retailUiRuntime = _retailUiLease.Mount( + () => AcDream.App.UI.RetailUiRuntime.CreateUninitialized( + retailUiBindings)); } @@ -1966,26 +1985,29 @@ public sealed class GameWindow : IDisposable // T1: invisible portal depth writes (seal/punch) — retail // DrawPortalPolyInternal (Ghidra 0x0059bc90). _portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl); - _portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired( - _gl, - _dats!, - _animLoader!, - _hookRouter, - _wbDrawDispatcher, - _sceneLightingUbo!, - _wbMeshAdapter!); + _portalTunnelFallback.AcquirePrepared( + () => AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired( + _gl, + _dats!, + _animLoader!, + _hookRouter, + _wbDrawDispatcher, + _sceneLightingUbo!, + _wbMeshAdapter!), + static portalTunnel => portalTunnel.PrepareResources()); // Publish the presentation before touching the mesh-reference // backend. Partial acquisition remains reachable by staged // shutdown instead of becoming constructor-local leaked state. - _portalTunnel.PrepareResources(); } // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) // with depth writes off + far plane 1e6 so celestial meshes // never clip. Shares the TextureCache with the static pipeline. - var skyShader = new Shader(_gl, - Path.Combine(shadersDir, "sky.vert"), - Path.Combine(shadersDir, "sky.frag")); + var skyShader = _renderResourceLifetime.AcquireSkyShader( + () => new Shader( + _gl, + Path.Combine(shadersDir, "sky.vert"), + Path.Combine(shadersDir, "sky.frag"))); _skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer( _gl, _dats, skyShader, _textureCache!, _samplerCache); @@ -2422,13 +2444,8 @@ public sealed class GameWindow : IDisposable _localPlayerMode, _playerModeController)); _playerModeController.BindAutoEntry(_playerModeAutoEntry); - var localTeleportPresentation = - new AcDream.App.Streaming.LocalPlayerTeleportPresentation( - _portalTunnel - ?? throw new InvalidOperationException( - "Portal presentation was not composed before local teleport.")); - _localPlayerTeleport = - new AcDream.App.Streaming.LocalPlayerTeleportController( + _localPlayerTeleport = _portalTunnelFallback.Transfer( + portalTunnel => new AcDream.App.Streaming.LocalPlayerTeleportController( new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority( _liveEntities, _localPlayerIdentity), @@ -2451,11 +2468,8 @@ public sealed class GameWindow : IDisposable liveSpatialReconciler), new AcDream.App.Streaming.LocalPlayerTeleportSession( _liveSessionController), - localTeleportPresentation); - // Ownership transferred to LocalPlayerTeleportController. This field - // remains only as the staged-startup fallback used by shutdown when - // composition fails before the transfer. - _portalTunnel = null; + new AcDream.App.Streaming.LocalPlayerTeleportPresentation( + portalTunnel))); _localPlayerTeleportSink.Bind(_localPlayerTeleport); var teleportRenderState = new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource( @@ -2652,7 +2666,7 @@ public sealed class GameWindow : IDisposable renderFrameResources, _devToolsFramePresenter, renderWeatherFrame); - _renderFrameOrchestrator = + var renderFrameOrchestrator = new AcDream.App.Rendering.RenderFrameOrchestrator( _gpuFrameFlights!, framePreparation, @@ -2681,7 +2695,7 @@ public sealed class GameWindow : IDisposable Combat, _selection, _worldSelectionQuery!)); - _updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator( + var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator( new AcDream.App.Update.LiveEntityTeardownFramePhase( _liveEntities), new AcDream.App.Update.ConsoleUpdateFrameFailureSink(), @@ -2697,6 +2711,7 @@ public sealed class GameWindow : IDisposable new AcDream.App.Update.PlayerModeAutoEntryFramePhase( _playerModeAutoEntry), cameraFrame); + _frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator); _liveSessionHost = CreateLiveSessionHost(); AcDream.UI.Abstractions.Panels.Debug.DebugVM? debugVm = _debugVm; @@ -3055,8 +3070,7 @@ public sealed class GameWindow : IDisposable { using var _updStage = _frameProfiler.BeginStage( AcDream.App.Diagnostics.FrameStage.Update); - _updateFrameOrchestrator.Tick( - new AcDream.App.Update.UpdateFrameInput(dt)); + _frameGraphs.Tick(new AcDream.App.Update.UpdateFrameInput(dt)); } // Performance overlay state — updated every ~0.5s and written to the @@ -3064,11 +3078,12 @@ public sealed class GameWindow : IDisposable private void OnRender(double deltaSeconds) { Vector2D size = _window!.Size; - _renderFrameOrchestrator!.Render( + _frameGraphs.Render( new AcDream.App.Rendering.RenderFrameInput( deltaSeconds, size.X, - size.Y)); + size.Y), + out _); } private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot @@ -3336,7 +3351,7 @@ public sealed class GameWindow : IDisposable new("dispatcher", () => _inputDispatcher?.Deactivate()), new("mouse source", () => _mouseSource?.Deactivate()), new("keyboard source", () => _kbSource?.Deactivate()), - new("retained UI input", () => _uiHost?.QuiesceInput()), + new("retained UI input", _retailUiLease.QuiesceInput), new("developer tools input", () => _devToolsInputContext?.Deactivate()), ]), // Live-session reset retires the complete streaming window. Every @@ -3390,7 +3405,7 @@ public sealed class GameWindow : IDisposable "Gameplay action callback removal remains pending."); _gameplayInputActions = null; }), - new("retained UI input", () => _uiHost?.DeactivateInput()), + new("retained UI input", _retailUiLease.DeactivateInput), new("developer tools input", () => _devToolsInputContext?.Dispose()), new("camera pointer", () => { @@ -3444,7 +3459,7 @@ public sealed class GameWindow : IDisposable new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets), new("world frame composition", () => { - _renderFrameOrchestrator = null; + _frameGraphs.Withdraw(); }), ]), new ResourceShutdownStage("session dependents", @@ -3461,7 +3476,10 @@ public sealed class GameWindow : IDisposable }), new("retail UI", () => { - _retailUiRuntime?.Dispose(); + _retailUiLease.Dispose(); + if (!_retailUiLease.IsDisposalComplete) + throw new InvalidOperationException( + "The retained UI ownership lease did not complete disposal."); _retailUiRuntime = null; _retainedInputCapture.Root = null; _uiHost = null; @@ -3544,8 +3562,7 @@ public sealed class GameWindow : IDisposable { _localPlayerTeleport?.Dispose(); _localPlayerTeleport = null; - _portalTunnel?.Dispose(); - _portalTunnel = null; + _portalTunnelFallback.ReleaseFallback(); }), new("paperdoll viewport", () => { @@ -3589,6 +3606,15 @@ public sealed class GameWindow : IDisposable new("frame pacing", _displayFramePacing.Dispose), new("frame profiler", _frameProfiler.Dispose), ]), + new ResourceShutdownStage("dedicated render resources", + [ + new("sky shader", _renderResourceLifetime.ReleaseSkyShader), + new("terrain atlas", _renderResourceLifetime.ReleaseTerrainAtlas), + ]), + new ResourceShutdownStage("failed render construction cleanup", + [ + new("GL construction ledger", _glConstructionCleanup.Dispose), + ]), new ResourceShutdownStage("frame flight owner", [ new("frame flights", () => diff --git a/src/AcDream.App/Rendering/GlConstructionCleanupLedger.cs b/src/AcDream.App/Rendering/GlConstructionCleanupLedger.cs new file mode 100644 index 00000000..e1bc73ef --- /dev/null +++ b/src/AcDream.App/Rendering/GlConstructionCleanupLedger.cs @@ -0,0 +1,102 @@ +namespace AcDream.App.Rendering; + +internal interface IRetryableResourceCleanup +{ + bool IsCleanupComplete { get; } + void RetryCleanup(); +} + +internal sealed class GlResourceConstructionException : AggregateException, + IRetryableResourceCleanup +{ + private readonly IRetryableResourceCleanup _cleanup; + + public GlResourceConstructionException( + string message, + IRetryableResourceCleanup cleanup, + IEnumerable failures) + : base(message, failures) + { + _cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup)); + } + + public bool IsCleanupComplete => _cleanup.IsCleanupComplete; + + public void RetryCleanup() => _cleanup.RetryCleanup(); +} + +/// +/// Lifetime root for cleanup work that could not finish before a throwing GL +/// factory returned control. The original exception remains the retry owner; +/// this ledger prevents it and its exact pending names from becoming local-only. +/// +internal sealed class GlConstructionCleanupLedger : IDisposable +{ + private readonly List _pending = []; + private bool _disposing; + + public bool IsComplete => _pending.Count == 0; + + public bool RetainFrom(Exception failure) + { + ArgumentNullException.ThrowIfNull(failure); + bool retained = false; + Visit(failure); + return retained; + + void Visit(Exception current) + { + if (current is IRetryableResourceCleanup cleanup) + { + if (!cleanup.IsCleanupComplete && !_pending.Contains(cleanup)) + _pending.Add(cleanup); + retained = true; + } + + if (current is AggregateException aggregate) + { + foreach (Exception inner in aggregate.InnerExceptions) + Visit(inner); + } + else if (current.InnerException is { } inner) + { + Visit(inner); + } + } + } + + public void Dispose() + { + if (_disposing || _pending.Count == 0) + return; + + _disposing = true; + List? failures = null; + try + { + for (int i = _pending.Count - 1; i >= 0; i--) + { + IRetryableResourceCleanup cleanup = _pending[i]; + try + { + cleanup.RetryCleanup(); + if (cleanup.IsCleanupComplete) + _pending.RemoveAt(i); + } + catch (Exception failure) + { + (failures ??= []).Add(failure); + } + } + } + finally + { + _disposing = false; + } + + if (failures is not null) + throw new AggregateException( + "One or more failed GL construction transactions remain pending.", + failures); + } +} diff --git a/src/AcDream.App/Rendering/GlResourceCommand.cs b/src/AcDream.App/Rendering/GlResourceCommand.cs new file mode 100644 index 00000000..57478bb5 --- /dev/null +++ b/src/AcDream.App/Rendering/GlResourceCommand.cs @@ -0,0 +1,152 @@ +using System.Runtime.ExceptionServices; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Always-on commit boundary for GL resource commands. OpenGL reports ordinary +/// command failures through its error flag, so an ownership state machine may +/// advance only after the post-command check succeeds. +/// +internal static class GlResourceCommand +{ + public static void Execute(GL gl, string context, Action command) + { + ArgumentNullException.ThrowIfNull(gl); + ArgumentException.ThrowIfNullOrWhiteSpace(context); + ArgumentNullException.ThrowIfNull(command); + + GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); + command(); + GLHelpers.ThrowOnResourceError(gl, context); + } + + public static T Execute(GL gl, string context, Func command) + { + ArgumentNullException.ThrowIfNull(gl); + ArgumentException.ThrowIfNullOrWhiteSpace(context); + ArgumentNullException.ThrowIfNull(command); + + GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); + T result = command(); + GLHelpers.ThrowOnResourceError(gl, context); + return result; + } + + public static uint CreateName( + GL gl, + string resourceName, + Func create, + Action delete) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + ArgumentNullException.ThrowIfNull(create); + ArgumentNullException.ThrowIfNull(delete); + + return CreateNameCore( + resourceName, + () => GLHelpers.ThrowOnResourceError( + gl, + $"create {resourceName} (precondition)"), + create, + () => GLHelpers.ThrowOnResourceError(gl, $"create {resourceName}"), + ownedName => Execute( + gl, + $"rollback {resourceName} name {ownedName}", + () => delete(ownedName))); + } + + internal static uint CreateNameCore( + string resourceName, + Action precondition, + Func create, + Action postcondition, + Action deleteChecked) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + ArgumentNullException.ThrowIfNull(precondition); + ArgumentNullException.ThrowIfNull(create); + ArgumentNullException.ThrowIfNull(postcondition); + ArgumentNullException.ThrowIfNull(deleteChecked); + + uint name = 0; + Exception? creationFailure = null; + try + { + precondition(); + name = create(); + if (name == 0) + throw new InvalidOperationException($"OpenGL returned no {resourceName} name."); + postcondition(); + return name; + } + catch (Exception failure) + { + creationFailure = failure; + } + + if (name != 0) + { + var cleanup = new SingleNameCleanup(name, deleteChecked); + try + { + cleanup.RetryCleanup(); + } + catch (Exception cleanupFailure) + { + throw new GlResourceConstructionException( + $"Creating {resourceName} failed and its returned GL name could not be released.", + cleanup, + [creationFailure, cleanupFailure]); + } + } + + ExceptionDispatchInfo.Capture(creationFailure).Throw(); + throw new InvalidOperationException("Unreachable GL resource-creation path."); + } + + public static uint CreateTexture(GL gl, string context) => + CreateName(gl, context, gl.GenTexture, gl.DeleteTexture); + + public static void DeleteTexture(GL gl, uint texture, string context) => + Execute(gl, context, () => gl.DeleteTexture(texture)); + + public static void DeleteShader(GL gl, uint shader, string context) => + Execute(gl, context, () => gl.DeleteShader(shader)); + + public static void DeleteProgram(GL gl, uint program, string context) => + Execute(gl, context, () => gl.DeleteProgram(program)); + + public static void DeleteBuffer(GL gl, uint buffer, string context) => + Execute(gl, context, () => gl.DeleteBuffer(buffer)); + + public static void DeleteVertexArray(GL gl, uint vertexArray, string context) => + Execute(gl, context, () => gl.DeleteVertexArray(vertexArray)); + + private sealed class SingleNameCleanup(uint name, Action delete) + : IRetryableResourceCleanup + { + private uint _name = name; + private bool _running; + + public bool IsCleanupComplete => _name == 0; + + public void RetryCleanup() + { + if (_running || _name == 0) + return; + + _running = true; + try + { + delete(_name); + _name = 0; + } + finally + { + _running = false; + } + } + } +} diff --git a/src/AcDream.App/Rendering/GlTextureConstructionTransaction.cs b/src/AcDream.App/Rendering/GlTextureConstructionTransaction.cs new file mode 100644 index 00000000..a7a04e69 --- /dev/null +++ b/src/AcDream.App/Rendering/GlTextureConstructionTransaction.cs @@ -0,0 +1,85 @@ +namespace AcDream.App.Rendering; + +internal interface IGlTextureNameApi +{ + uint GenTexture(); + void DeleteTexture(uint texture); +} + +internal sealed class GlTextureNameApi(Silk.NET.OpenGL.GL gl) : IGlTextureNameApi +{ + public uint GenTexture() => + GlResourceCommand.CreateTexture(gl, "terrain construction texture"); + + public void DeleteTexture(uint texture) => + GlResourceCommand.DeleteTexture( + gl, + texture, + $"delete terrain construction texture {texture}"); +} + +/// +/// Retains every texture name immediately after allocation until the complete +/// aggregate owner has been constructed. A failed factory rolls names back in +/// reverse acquisition order and attempts every deletion. +/// +internal sealed class GlTextureConstructionTransaction(IGlTextureNameApi api) + : IRetryableResourceCleanup +{ + private readonly List _ownedNames = []; + private bool _finished; + + public bool IsCleanupComplete => _finished; + + public uint Allocate() + { + if (_finished) + throw new InvalidOperationException("The texture construction transaction is finished."); + + uint texture = api.GenTexture(); + if (texture == 0) + throw new InvalidOperationException("OpenGL returned no texture name."); + _ownedNames.Add(texture); + return texture; + } + + public void Commit() + { + if (_finished) + throw new InvalidOperationException("The texture construction transaction is finished."); + _ownedNames.Clear(); + _finished = true; + } + + public void Rollback() + { + if (_finished) + return; + + List? failures = null; + for (int i = _ownedNames.Count - 1; i >= 0; i--) + { + uint texture = _ownedNames[i]; + try + { + api.DeleteTexture(texture); + _ownedNames.RemoveAt(i); + } + catch (Exception failure) + { + (failures ??= []).Add(new InvalidOperationException( + $"Texture construction rollback could not delete OpenGL name {texture}.", + failure)); + } + } + + if (failures is not null) + throw new AggregateException( + "Texture construction rollback did not release every allocated OpenGL name.", + failures); + + _finished = true; + } + + public void RetryCleanup() => Rollback(); +} diff --git a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs index 9f688d99..0d95c3a0 100644 --- a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs +++ b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs @@ -95,7 +95,7 @@ internal sealed class NullRenderFrameFailureRecovery : IRenderFrameFailureRecove /// and close failure are reported together, while either failure alone /// propagates directly. /// -internal sealed class RenderFrameOrchestrator +internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot { private readonly IRenderFrameLifetime _lifetime; private readonly IRenderFrameResourcePhase _resources; diff --git a/src/AcDream.App/Rendering/ResourceCleanupGroup.cs b/src/AcDream.App/Rendering/ResourceCleanupGroup.cs new file mode 100644 index 00000000..fe8ba308 --- /dev/null +++ b/src/AcDream.App/Rendering/ResourceCleanupGroup.cs @@ -0,0 +1,63 @@ +namespace AcDream.App.Rendering; + +/// +/// Reverse-order, all-attempted cleanup owner used while a composite resource +/// is still under construction and after it becomes the aggregate owner. +/// +internal sealed class ResourceCleanupGroup : IRetryableResourceCleanup +{ + private sealed record Entry(string Name, Action Release) + { + public bool Complete { get; set; } + } + + private readonly List _entries = []; + private bool _running; + + public bool IsCleanupComplete => _entries.All(static entry => entry.Complete); + + public void Add(string name, Action release) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(release); + if (_running || IsCleanupComplete && _entries.Count != 0) + throw new InvalidOperationException("The resource cleanup group is no longer accepting ownership."); + _entries.Add(new Entry(name, release)); + } + + public void RetryCleanup() + { + if (_running || IsCleanupComplete) + return; + + _running = true; + List? failures = null; + try + { + for (int i = _entries.Count - 1; i >= 0; i--) + { + Entry entry = _entries[i]; + if (entry.Complete) + continue; + try + { + entry.Release(); + entry.Complete = true; + } + catch (Exception failure) + { + (failures ??= []).Add(new InvalidOperationException( + $"Resource cleanup operation '{entry.Name}' failed.", + failure)); + } + } + } + finally + { + _running = false; + } + + if (failures is not null) + throw new AggregateException("Composite resource cleanup remains incomplete.", failures); + } +} diff --git a/src/AcDream.App/Rendering/RestoredTextureBindingMutation.cs b/src/AcDream.App/Rendering/RestoredTextureBindingMutation.cs new file mode 100644 index 00000000..5fcc4342 --- /dev/null +++ b/src/AcDream.App/Rendering/RestoredTextureBindingMutation.cs @@ -0,0 +1,83 @@ +using System.Runtime.ExceptionServices; + +namespace AcDream.App.Rendering; + +/// +/// Runs a texture mutation with the caller's prior binding restored on every +/// exit path. The concrete GL adapter supplies checked commands; tests can +/// fault each edge without requiring a graphics context. +/// +internal sealed class RestoredTextureBindingMutation +{ + private uint? _pendingRestore; + private bool _operationActive; + + internal bool HasPendingRestore => _pendingRestore.HasValue; + + public void Execute( + Func readBinding, + Action bind, + uint mutationBinding, + Action mutation) + { + ArgumentNullException.ThrowIfNull(readBinding); + ArgumentNullException.ThrowIfNull(bind); + ArgumentNullException.ThrowIfNull(mutation); + if (_operationActive) + throw new InvalidOperationException("A texture-binding mutation is already active."); + + _operationActive = true; + try + { + ExecuteCore(readBinding, bind, mutationBinding, mutation); + } + finally + { + _operationActive = false; + } + } + + private void ExecuteCore( + Func readBinding, + Action bind, + uint mutationBinding, + Action mutation) + { + if (_pendingRestore is uint pending) + { + bind(pending); + _pendingRestore = null; + } + + uint previousBinding = readBinding(); + _pendingRestore = previousBinding; + Exception? mutationFailure = null; + try + { + bind(mutationBinding); + mutation(); + } + catch (Exception failure) + { + mutationFailure = failure; + } + + try + { + bind(previousBinding); + _pendingRestore = null; + } + catch (Exception restoreFailure) + { + if (mutationFailure is not null) + throw new AggregateException( + "Texture mutation and binding restoration both failed.", + mutationFailure, + restoreFailure); + throw; + } + + if (mutationFailure is not null) + ExceptionDispatchInfo.Capture(mutationFailure).Throw(); + } +} diff --git a/src/AcDream.App/Rendering/RuntimeResourceSlots.cs b/src/AcDream.App/Rendering/RuntimeResourceSlots.cs new file mode 100644 index 00000000..1dda7c26 --- /dev/null +++ b/src/AcDream.App/Rendering/RuntimeResourceSlots.cs @@ -0,0 +1,171 @@ +namespace AcDream.App.Rendering; + +/// Retryable one-owner slot for a disposable runtime resource. +internal sealed class OwnedResourceSlot + where T : class, IDisposable +{ + private T? _resource; + private bool _operationActive; + + public bool HasResource => _resource is not null; + + public T Acquire(Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + if (_operationActive || _resource is not null) + throw new InvalidOperationException($"A {typeof(T).Name} is already owned."); + + _operationActive = true; + try + { + T resource = factory() + ?? throw new InvalidOperationException($"The {typeof(T).Name} factory returned null."); + _resource = resource; + return resource; + } + finally + { + _operationActive = false; + } + } + + public T Borrow() + { + if (_operationActive) + throw new InvalidOperationException($"The {typeof(T).Name} owner is changing state."); + return _resource + ?? throw new InvalidOperationException($"No {typeof(T).Name} is currently owned."); + } + + public void Release() + { + T? resource = _resource; + if (resource is null) + return; + if (_operationActive) + throw new InvalidOperationException($"The {typeof(T).Name} owner is changing state."); + + _operationActive = true; + try + { + resource.Dispose(); + if (ReferenceEquals(_resource, resource)) + _resource = null; + } + finally + { + _operationActive = false; + } + } + + internal void TransferOwnership(T expected) + { + ArgumentNullException.ThrowIfNull(expected); + if (_operationActive) + throw new InvalidOperationException($"The {typeof(T).Name} owner is changing state."); + if (!ReferenceEquals(_resource, expected)) + throw new InvalidOperationException( + $"The expected {typeof(T).Name} is not owned by this slot."); + _resource = null; + } +} + +/// +/// Owns a fallback resource until a complete destination owner factory +/// returns. A throwing factory leaves fallback ownership unchanged. +/// +internal sealed class TransferableResourceSlot + where T : class, IDisposable +{ + private readonly OwnedResourceSlot _fallback = new(); + private bool _operationActive; + private bool _prepared; + + public bool HasFallback => _fallback.HasResource; + + public T Acquire(Func factory) => Execute(() => + { + T resource = _fallback.Acquire(factory); + _prepared = true; + return resource; + }); + + public T AcquirePrepared(Func factory, Action prepare) => Execute(() => + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(prepare); + + T resource; + if (_fallback.HasResource) + { + resource = _fallback.Borrow(); + } + else + { + resource = _fallback.Acquire(factory); + _prepared = false; + } + + if (!_prepared) + { + prepare(resource); + _prepared = true; + } + + return resource; + }); + + public T Borrow() + { + if (_operationActive) + throw new InvalidOperationException("The transferable resource owner is changing state."); + return _fallback.Borrow(); + } + + public TOwner Transfer(Func destinationFactory) + where TOwner : class + { + return Execute(() => + { + ArgumentNullException.ThrowIfNull(destinationFactory); + if (!_prepared) + throw new InvalidOperationException( + "The transferable resource has not completed preparation."); + T resource = _fallback.Borrow(); + TOwner owner = destinationFactory(resource) + ?? throw new InvalidOperationException("The destination owner factory returned null."); + + // The destination factory contract transfers ownership of resource. + // Clear without disposing only after the complete owner exists. + _fallback.TransferOwnership(resource); + _prepared = false; + return owner; + }); + } + + public void ReleaseFallback() => Execute( + () => + { + _fallback.Release(); + if (!_fallback.HasResource) + _prepared = false; + return true; + }); + + private TResult Execute(Func operation) + { + ArgumentNullException.ThrowIfNull(operation); + if (_operationActive) + throw new InvalidOperationException("The transferable resource owner is changing state."); + + _operationActive = true; + try + { + return operation(); + } + finally + { + _operationActive = false; + } + } +} diff --git a/src/AcDream.App/Rendering/Shader.cs b/src/AcDream.App/Rendering/Shader.cs index fea7ff19..278a768b 100644 --- a/src/AcDream.App/Rendering/Shader.cs +++ b/src/AcDream.App/Rendering/Shader.cs @@ -7,36 +7,17 @@ public sealed class Shader : IDisposable { private readonly GL _gl; private readonly Dictionary _uniformLocations = new(StringComparer.Ordinal); - public uint Program { get; } + public uint Program { get; private set; } public Shader(GL gl, string vertexPath, string fragmentPath) { _gl = gl; - uint vert = Compile(File.ReadAllText(vertexPath), ShaderType.VertexShader); - uint frag = Compile(File.ReadAllText(fragmentPath), ShaderType.FragmentShader); - - Program = _gl.CreateProgram(); - _gl.AttachShader(Program, vert); - _gl.AttachShader(Program, frag); - _gl.LinkProgram(Program); - _gl.GetProgram(Program, ProgramPropertyARB.LinkStatus, out int linked); - if (linked == 0) - throw new Exception("program link failed: " + _gl.GetProgramInfoLog(Program)); - _gl.DetachShader(Program, vert); - _gl.DetachShader(Program, frag); - _gl.DeleteShader(vert); - _gl.DeleteShader(frag); - } - - private uint Compile(string source, ShaderType type) - { - uint id = _gl.CreateShader(type); - _gl.ShaderSource(id, source); - _gl.CompileShader(id); - _gl.GetShader(id, ShaderParameterName.CompileStatus, out int ok); - if (ok == 0) - throw new Exception($"{type} compile failed: " + _gl.GetShaderInfoLog(id)); - return id; + string vertexSource = File.ReadAllText(vertexPath); + string fragmentSource = File.ReadAllText(fragmentPath); + Program = ShaderProgramConstruction.Build( + new GlShaderProgramBuildApi(gl), + vertexSource, + fragmentSource); } public void Use() => _gl.UseProgram(Program); @@ -90,7 +71,12 @@ public sealed class Shader : IDisposable public void Dispose() { + uint program = Program; + if (program == 0) + return; + _uniformLocations.Clear(); - _gl.DeleteProgram(Program); + GlResourceCommand.DeleteProgram(_gl, program, $"delete Shader program {program}"); + Program = 0; } } diff --git a/src/AcDream.App/Rendering/ShaderProgramConstruction.cs b/src/AcDream.App/Rendering/ShaderProgramConstruction.cs new file mode 100644 index 00000000..a01f81ab --- /dev/null +++ b/src/AcDream.App/Rendering/ShaderProgramConstruction.cs @@ -0,0 +1,325 @@ +using System.Runtime.ExceptionServices; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +internal interface IShaderProgramBuildApi +{ + uint CreateShader(ShaderType type); + void ShaderSource(uint shader, string source); + void CompileShader(uint shader); + int GetShaderCompileStatus(uint shader); + string GetShaderInfoLog(uint shader); + uint CreateProgram(); + void AttachShader(uint program, uint shader); + void LinkProgram(uint program); + int GetProgramLinkStatus(uint program); + string GetProgramInfoLog(uint program); + void DetachShader(uint program, uint shader); + void DeleteShader(uint shader); + void DeleteProgram(uint program); +} + +internal sealed class GlShaderProgramBuildApi(GL gl) : IShaderProgramBuildApi +{ + public uint CreateShader(ShaderType type) => + GlResourceCommand.CreateName( + gl, + $"{type} shader", + () => gl.CreateShader(type), + gl.DeleteShader); + + public void ShaderSource(uint shader, string source) => + GlResourceCommand.Execute( + gl, + $"upload shader source {shader}", + () => gl.ShaderSource(shader, source)); + + public void CompileShader(uint shader) => + GlResourceCommand.Execute( + gl, + $"compile shader {shader}", + () => gl.CompileShader(shader)); + + public void GetShader(uint shader, ShaderParameterName name, out int value) => + gl.GetShader(shader, name, out value); + + public int GetShaderCompileStatus(uint shader) + { + return GlResourceCommand.Execute( + gl, + $"read shader {shader} compile status", + () => + { + gl.GetShader(shader, ShaderParameterName.CompileStatus, out int value); + return value; + }); + } + + public string GetShaderInfoLog(uint shader) => + GlResourceCommand.Execute( + gl, + $"read shader {shader} info log", + () => gl.GetShaderInfoLog(shader)); + + public uint CreateProgram() => + GlResourceCommand.CreateName( + gl, + "shader program", + gl.CreateProgram, + gl.DeleteProgram); + + public void AttachShader(uint program, uint shader) => + GlResourceCommand.Execute( + gl, + $"attach shader {shader} to program {program}", + () => gl.AttachShader(program, shader)); + + public void LinkProgram(uint program) => + GlResourceCommand.Execute( + gl, + $"link shader program {program}", + () => gl.LinkProgram(program)); + + public int GetProgramLinkStatus(uint program) + { + return GlResourceCommand.Execute( + gl, + $"read shader program {program} link status", + () => + { + gl.GetProgram(program, ProgramPropertyARB.LinkStatus, out int value); + return value; + }); + } + + public string GetProgramInfoLog(uint program) => + GlResourceCommand.Execute( + gl, + $"read shader program {program} info log", + () => gl.GetProgramInfoLog(program)); + + public void DetachShader(uint program, uint shader) => + GlResourceCommand.Execute( + gl, + $"detach shader {shader} from program {program}", + () => gl.DetachShader(program, shader)); + + public void DeleteShader(uint shader) => + GlResourceCommand.DeleteShader(gl, shader, $"delete shader {shader}"); + + public void DeleteProgram(uint program) => + GlResourceCommand.DeleteProgram(gl, program, $"delete shader program {program}"); +} + +internal sealed class ShaderProgramCleanup(IShaderProgramBuildApi api) + : IRetryableResourceCleanup +{ + public uint Vertex { get; set; } + public uint Fragment { get; set; } + public uint Program { get; set; } + public bool VertexAttached { get; set; } + public bool FragmentAttached { get; set; } + + public bool IsCleanupComplete => Vertex == 0 && Fragment == 0 && Program == 0; + + public uint TransferProgram() + { + if (Vertex != 0 || Fragment != 0 || Program == 0) + throw new InvalidOperationException("The linked program is not ready for ownership transfer."); + uint program = Program; + Program = 0; + return program; + } + + public List Release(bool includeProgram) + { + var failures = new List(); + Try( + () => + { + if (Program != 0 && Vertex != 0 && VertexAttached) + { + api.DetachShader(Program, Vertex); + VertexAttached = false; + } + }, + failures); + Try( + () => + { + if (Program != 0 && Fragment != 0 && FragmentAttached) + { + api.DetachShader(Program, Fragment); + FragmentAttached = false; + } + }, + failures); + Try( + () => + { + if (Vertex != 0) + { + api.DeleteShader(Vertex); + Vertex = 0; + VertexAttached = false; + } + }, + failures); + Try( + () => + { + if (Fragment != 0) + { + api.DeleteShader(Fragment); + Fragment = 0; + FragmentAttached = false; + } + }, + failures); + if (includeProgram) + { + Try( + () => + { + if (Program != 0) + { + api.DeleteProgram(Program); + Program = 0; + VertexAttached = false; + FragmentAttached = false; + } + }, + failures); + } + + return failures; + } + + public List ReleaseProgramOnly() + { + var failures = new List(); + Try( + () => + { + if (Program != 0) + { + api.DeleteProgram(Program); + Program = 0; + VertexAttached = false; + FragmentAttached = false; + } + }, + failures); + return failures; + } + + public void RetryCleanup() + { + List failures = Release(includeProgram: true); + if (!IsCleanupComplete) + { + throw new AggregateException( + "Shader program cleanup remains incomplete.", + failures.Count == 0 + ? [new InvalidOperationException("Shader cleanup retained names without reporting an operation failure.")] + : failures); + } + } + + private static void Try(Action operation, List failures) + { + try + { + operation(); + } + catch (Exception failure) + { + failures.Add(failure); + } + } +} + +internal static class ShaderProgramConstruction +{ + public static uint Build( + IShaderProgramBuildApi api, + string vertexSource, + string fragmentSource) + { + ArgumentNullException.ThrowIfNull(api); + ArgumentNullException.ThrowIfNull(vertexSource); + ArgumentNullException.ThrowIfNull(fragmentSource); + + var cleanup = new ShaderProgramCleanup(api); + Exception? constructionFailure = null; + + try + { + cleanup.Vertex = CreateShader(api, ShaderType.VertexShader); + Compile(api, cleanup.Vertex, ShaderType.VertexShader, vertexSource); + cleanup.Fragment = CreateShader(api, ShaderType.FragmentShader); + Compile(api, cleanup.Fragment, ShaderType.FragmentShader, fragmentSource); + cleanup.Program = api.CreateProgram(); + if (cleanup.Program == 0) + throw new InvalidOperationException("OpenGL returned no shader program name."); + + api.AttachShader(cleanup.Program, cleanup.Vertex); + cleanup.VertexAttached = true; + api.AttachShader(cleanup.Program, cleanup.Fragment); + cleanup.FragmentAttached = true; + api.LinkProgram(cleanup.Program); + if (api.GetProgramLinkStatus(cleanup.Program) == 0) + throw new InvalidOperationException( + "program link failed: " + api.GetProgramInfoLog(cleanup.Program)); + } + catch (Exception failure) + { + constructionFailure = failure; + } + + List cleanupFailures = cleanup.Release(includeProgram: false); + + if (constructionFailure is null && cleanupFailures.Count == 0) + return cleanup.TransferProgram(); + + cleanupFailures.AddRange(cleanup.ReleaseProgramOnly()); + + if (constructionFailure is not null && cleanupFailures.Count == 0) + ExceptionDispatchInfo.Capture(constructionFailure).Throw(); + + var failures = new List(cleanupFailures.Count + 1); + if (constructionFailure is not null) + failures.Add(constructionFailure); + failures.AddRange(cleanupFailures); + const string message = + "Shader program construction failed and one or more temporary OpenGL names could not be cleanly released."; + if (!cleanup.IsCleanupComplete) + throw new GlResourceConstructionException(message, cleanup, failures); + throw new AggregateException(message, failures); + } + + private static uint CreateShader( + IShaderProgramBuildApi api, + ShaderType type) + { + uint shader = api.CreateShader(type); + if (shader == 0) + throw new InvalidOperationException($"OpenGL returned no {type} name."); + return shader; + } + + private static void Compile( + IShaderProgramBuildApi api, + uint shader, + ShaderType type, + string source) + { + api.ShaderSource(shader, source); + api.CompileShader(shader); + if (api.GetShaderCompileStatus(shader) == 0) + throw new InvalidOperationException( + $"{type} compile failed: " + api.GetShaderInfoLog(shader)); + } +} diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index 57d6b0a2..e4c7ad59 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -61,12 +61,10 @@ public sealed unsafe class TerrainAtlas : IDisposable public IReadOnlyList RoadAlphaRCodes { get; } private readonly Wb.BindlessSupport? _bindless; - - // Cached bindless handles. Generated lazily on first GetBindlessHandles() call; - // reused for the lifetime of the atlas. - private ulong _terrainHandle; - private ulong _alphaHandle; - private bool _handlesGenerated; + private readonly BindlessTexturePair? _bindlessHandles; + private readonly BindlessTextureMutationGuard? _bindlessMutation; + private readonly RestoredTextureBindingMutation _anisotropyBindingMutation = new(); + private ResourceShutdownTransaction? _shutdown; /// /// Get 64-bit bindless handles for the terrain + alpha texture arrays. @@ -80,13 +78,8 @@ public sealed unsafe class TerrainAtlas : IDisposable if (_bindless is null) throw new InvalidOperationException( "TerrainAtlas was constructed without BindlessSupport; cannot return bindless handles."); - if (!_handlesGenerated) - { - _terrainHandle = _bindless.GetResidentHandle(GlTexture); - _alphaHandle = _bindless.GetResidentHandle(GlAlphaTexture); - _handlesGenerated = true; - } - return (_terrainHandle, _alphaHandle); + (ulong terrain, ulong alpha) = _bindlessHandles!.Acquire(); + return (terrain, alpha); } private TerrainAtlas( @@ -112,6 +105,15 @@ public sealed unsafe class TerrainAtlas : IDisposable CornerAlphaTCodes = cornerTCodes; SideAlphaTCodes = sideTCodes; RoadAlphaRCodes = roadRCodes; + if (bindless is not null) + { + _bindlessHandles = new BindlessTexturePair( + glTexture, + glAlphaTexture, + bindless.GetResidentHandle, + bindless.MakeNonResident); + _bindlessMutation = new BindlessTextureMutationGuard(_bindlessHandles); + } } /// @@ -120,6 +122,37 @@ public sealed unsafe class TerrainAtlas : IDisposable /// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY. /// public static TerrainAtlas Build(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null) + { + var textures = new GlTextureConstructionTransaction(new GlTextureNameApi(gl)); + try + { + TerrainAtlas atlas = BuildCore(gl, dats, bindless, textures); + textures.Commit(); + return atlas; + } + catch (Exception constructionFailure) + { + try + { + textures.Rollback(); + } + catch (Exception cleanupFailure) + { + throw new GlResourceConstructionException( + "TerrainAtlas construction failed and its allocated OpenGL texture names did not cleanly roll back.", + textures, + [constructionFailure, cleanupFailure]); + } + + throw; + } + } + + private static TerrainAtlas BuildCore( + GL gl, + IDatReaderWriter dats, + Wb.BindlessSupport? bindless, + GlTextureConstructionTransaction textures) { var region = dats.Get(0x13000000u) ?? throw new InvalidOperationException("Region dat id 0x13000000 missing"); @@ -129,7 +162,7 @@ public sealed unsafe class TerrainAtlas : IDisposable if (terrainDesc is null || terrainDesc.Count == 0) { Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer"); - return BuildFallback(gl, bindless); + return BuildFallback(gl, bindless, textures); } // ---- Terrain atlas (unchanged Phase 2b logic) ---- @@ -179,29 +212,45 @@ public sealed unsafe class TerrainAtlas : IDisposable } int layerCount = decodedByType.Count; - uint tex = gl.GenTexture(); - gl.BindTexture(TextureTarget.Texture2DArray, tex); - gl.TexImage3D( - TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, - (uint)maxW, (uint)maxH, (uint)layerCount, - 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); - var map = new Dictionary(); - int layerIdx = 0; - foreach (var kvp in decodedByType) + uint tex = TrackedTextureConstruction.Create( + textures, + gl, + "upload terrain atlas texture array", + texture => { - byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH); - fixed (byte* p = buffer) + gl.BindTexture(TextureTarget.Texture2DArray, texture); + gl.TexImage3D( + TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, + (uint)maxW, (uint)maxH, (uint)layerCount, + 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); + + int layerIdx = 0; + foreach (var kvp in decodedByType) { - gl.TexSubImage3D( - TextureTarget.Texture2DArray, 0, - 0, 0, layerIdx, - (uint)maxW, (uint)maxH, 1, - GLPixelFormat.Rgba, PixelType.UnsignedByte, p); + byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH); + fixed (byte* p = buffer) + { + gl.TexSubImage3D( + TextureTarget.Texture2DArray, 0, + 0, 0, layerIdx, + (uint)maxW, (uint)maxH, 1, + GLPixelFormat.Rgba, PixelType.UnsignedByte, p); + } + map[kvp.Key] = (uint)layerIdx; + layerIdx++; } - map[kvp.Key] = (uint)layerIdx; - layerIdx++; - } + + // A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality. + gl.GenerateMipmap(TextureTarget.Texture2DArray); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); + // GL_TEXTURE_MAX_ANISOTROPY = 0x84FE (GL_EXT_texture_filter_anisotropic / ARB_texture_filter_anisotropic). + gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, 16.0f); + gl.BindTexture(TextureTarget.Texture2DArray, 0); + }); var tilingByLayer = TerrainTextureTilingTable.Build( map.Select(entry => @@ -209,22 +258,12 @@ public sealed unsafe class TerrainAtlas : IDisposable ? repeatCount : 1u))); - // A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality. - gl.GenerateMipmap(TextureTarget.Texture2DArray); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); - // GL_TEXTURE_MAX_ANISOTROPY = 0x84FE (GL_EXT_texture_filter_anisotropic / ARB_texture_filter_anisotropic). - gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, 16.0f); - gl.BindTexture(TextureTarget.Texture2DArray, 0); - Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} (mipmaps+aniso16x)"); // ---- Alpha atlas (new in Phase 3c.2) ---- // texMerge is guaranteed non-null here: the early return above exited // if texMerge?.TerrainDesc was null. - var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!); + var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!, textures); return new TerrainAtlas( gl, @@ -252,7 +291,10 @@ public sealed unsafe class TerrainAtlas : IDisposable IReadOnlyList cornerTCodes, IReadOnlyList sideTCodes, IReadOnlyList roadRCodes); private static AlphaAtlasBuildResult BuildAlphaAtlas( - GL gl, IDatReaderWriter dats, DatReaderWriter.Types.TexMerge texMerge) + GL gl, + IDatReaderWriter dats, + DatReaderWriter.Types.TexMerge texMerge, + GlTextureConstructionTransaction textures) { var decoded = new List(); var cornerLayers = new List(); @@ -305,15 +347,21 @@ public sealed unsafe class TerrainAtlas : IDisposable if (decoded.Count == 0) { Console.WriteLine("WARN: no alpha maps loaded; alpha atlas will be a 1x1 white fallback"); - uint fallbackAlpha = gl.GenTexture(); - gl.BindTexture(TextureTarget.Texture2DArray, fallbackAlpha); - gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, - GLPixelFormat.Rgba, PixelType.UnsignedByte, null); - var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }; - fixed (byte* p = white) - gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, - GLPixelFormat.Rgba, PixelType.UnsignedByte, p); - gl.BindTexture(TextureTarget.Texture2DArray, 0); + uint fallbackAlpha = TrackedTextureConstruction.Create( + textures, + gl, + "upload fallback terrain alpha texture array", + texture => + { + gl.BindTexture(TextureTarget.Texture2DArray, texture); + gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, + GLPixelFormat.Rgba, PixelType.UnsignedByte, null); + var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }; + fixed (byte* p = white) + gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, + GLPixelFormat.Rgba, PixelType.UnsignedByte, p); + gl.BindTexture(TextureTarget.Texture2DArray, 0); + }); return new AlphaAtlasBuildResult( fallbackAlpha, 1, cornerLayers, sideLayers, roadLayers, @@ -329,31 +377,37 @@ public sealed unsafe class TerrainAtlas : IDisposable if (d.Height > aMaxH) aMaxH = d.Height; } - uint glAlpha = gl.GenTexture(); - gl.BindTexture(TextureTarget.Texture2DArray, glAlpha); - gl.TexImage3D( - TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, - (uint)aMaxW, (uint)aMaxH, (uint)decoded.Count, - 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); - - for (int i = 0; i < decoded.Count; i++) + uint glAlpha = TrackedTextureConstruction.Create( + textures, + gl, + "upload terrain alpha texture array", + texture => { - var buffer = ResizeRgba8Nearest(decoded[i], aMaxW, aMaxH); - fixed (byte* p = buffer) - { - gl.TexSubImage3D( - TextureTarget.Texture2DArray, 0, - 0, 0, i, - (uint)aMaxW, (uint)aMaxH, 1, - GLPixelFormat.Rgba, PixelType.UnsignedByte, p); - } - } + gl.BindTexture(TextureTarget.Texture2DArray, texture); + gl.TexImage3D( + TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, + (uint)aMaxW, (uint)aMaxH, (uint)decoded.Count, + 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge); - gl.BindTexture(TextureTarget.Texture2DArray, 0); + for (int i = 0; i < decoded.Count; i++) + { + var buffer = ResizeRgba8Nearest(decoded[i], aMaxW, aMaxH); + fixed (byte* p = buffer) + { + gl.TexSubImage3D( + TextureTarget.Texture2DArray, 0, + 0, 0, i, + (uint)aMaxW, (uint)aMaxH, 1, + GLPixelFormat.Rgba, PixelType.UnsignedByte, p); + } + } + + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge); + gl.BindTexture(TextureTarget.Texture2DArray, 0); + }); Console.WriteLine( $"AlphaAtlas: {decoded.Count} layers at {aMaxW}x{aMaxH} " @@ -412,25 +466,40 @@ public sealed unsafe class TerrainAtlas : IDisposable return dst; } - private static TerrainAtlas BuildFallback(GL gl, Wb.BindlessSupport? bindless = null) + private static TerrainAtlas BuildFallback( + GL gl, + Wb.BindlessSupport? bindless, + GlTextureConstructionTransaction textures) { - uint tex = gl.GenTexture(); - gl.BindTexture(TextureTarget.Texture2DArray, tex); var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }; - gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); - fixed (byte* p = white) - gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); - gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); - gl.BindTexture(TextureTarget.Texture2DArray, 0); + uint tex = TrackedTextureConstruction.Create( + textures, + gl, + "upload fallback terrain texture array", + texture => + { + gl.BindTexture(TextureTarget.Texture2DArray, texture); + gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); + fixed (byte* p = white) + gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); + gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + gl.BindTexture(TextureTarget.Texture2DArray, 0); + }); // Fallback alpha atlas: 1x1 white, no layers tracked - uint alphaTex = gl.GenTexture(); - gl.BindTexture(TextureTarget.Texture2DArray, alphaTex); - gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); - fixed (byte* p = white) - gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p); - gl.BindTexture(TextureTarget.Texture2DArray, 0); + uint alphaTex = TrackedTextureConstruction.Create( + textures, + gl, + "upload fallback alpha texture array", + texture => + { + gl.BindTexture(TextureTarget.Texture2DArray, texture); + gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); + fixed (byte* p = white) + gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p); + gl.BindTexture(TextureTarget.Texture2DArray, 0); + }); return new TerrainAtlas( gl, @@ -453,46 +522,65 @@ public sealed unsafe class TerrainAtlas : IDisposable /// public void SetAnisotropic(int level) { - // If bindless handles are live we must make them non-resident before - // mutating texture state, then re-resident after. - bool wasResident = _handlesGenerated && _bindless is not null; - if (wasResident) + void Mutate() { - _bindless!.MakeNonResident(_terrainHandle); - // Alpha texture is not affected by anisotropic but we must keep - // residency symmetric — re-generate both handles after. - _bindless.MakeNonResident(_alphaHandle); - _handlesGenerated = false; + _anisotropyBindingMutation.Execute( + () => unchecked((uint)GlResourceCommand.Execute( + _gl, + "read terrain-array binding before anisotropy mutation", + () => _gl.GetInteger(GetPName.TextureBinding2DArray))), + binding => GlResourceCommand.Execute( + _gl, + "set terrain-array binding for anisotropy mutation", + () => _gl.BindTexture(TextureTarget.Texture2DArray, binding)), + GlTexture, + () => GlResourceCommand.Execute( + _gl, + "set terrain atlas anisotropy", + () => + { + // GL_TEXTURE_MAX_ANISOTROPY = 0x84FE + _gl.TexParameter( + TextureTarget.Texture2DArray, + (TextureParameterName)0x84FE, + (float)level); + })); } - _gl.BindTexture(TextureTarget.Texture2DArray, GlTexture); - // GL_TEXTURE_MAX_ANISOTROPY = 0x84FE - _gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, (float)level); - _gl.BindTexture(TextureTarget.Texture2DArray, 0); - - // Re-generate bindless handles if they were live before. - if (wasResident) - { - // GetBindlessHandles regenerates and makes resident. - _ = GetBindlessHandles(); - } + if (_bindlessMutation is not null) + _bindlessMutation.Execute(Mutate); + else + Mutate(); Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x"); } public void Dispose() { - // Phase 1: release bindless residency BEFORE deleting textures. - // ARB_bindless_texture requires this ordering; interleaving is UB. - if (_handlesGenerated && _bindless is not null) - { - _bindless.MakeNonResident(_terrainHandle); - _bindless.MakeNonResident(_alphaHandle); - _handlesGenerated = false; - } - - // Phase 2: delete the underlying GL textures. - _gl.DeleteTexture(GlTexture); - _gl.DeleteTexture(GlAlphaTexture); + _shutdown ??= new ResourceShutdownTransaction( + new ResourceShutdownStage( + "terrain atlas bindless residency", + [ + new ResourceShutdownOperation( + "release terrain and alpha handles", + () => _bindlessHandles?.Release()), + ]), + new ResourceShutdownStage( + "terrain atlas textures", + [ + new ResourceShutdownOperation( + "delete terrain texture", + () => GlResourceCommand.DeleteTexture( + _gl, + GlTexture, + $"delete terrain atlas texture {GlTexture}")), + new ResourceShutdownOperation( + "delete alpha texture", + () => GlResourceCommand.DeleteTexture( + _gl, + GlAlphaTexture, + $"delete terrain alpha texture {GlAlphaTexture}")), + ])); + _shutdown.CompleteOrThrow(); } } diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 096dd929..18f8862b 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -26,6 +26,7 @@ public sealed unsafe class TextRenderer : IDisposable private readonly GL _gl; private readonly ITextRenderGlStateApi _glState; private readonly Shader _shader; + private readonly ResourceCleanupGroup _resources; private uint _vao; private uint _vbo; private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket @@ -39,7 +40,7 @@ public sealed unsafe class TextRenderer : IDisposable public int UsedBytes; } - private readonly FrameBufferSet[] _frameBuffers = new FrameBufferSet[3]; + private readonly FrameBufferSet[] _frameBuffers; private FrameBufferSet? _activeFrameBuffer; internal long DynamicBufferCapacityBytes => @@ -80,26 +81,70 @@ public sealed unsafe class TextRenderer : IDisposable { _gl = gl; _glState = new SilkTextRenderGlStateApi(gl); - _shader = new Shader(gl, - Path.Combine(shaderDir, "ui_text.vert"), - Path.Combine(shaderDir, "ui_text.frag")); + var resources = new ResourceCleanupGroup(); + Shader? shader = null; + var frameBuffers = new FrameBufferSet[3]; + uint whiteTexture = 0; + try + { + shader = new Shader(gl, + Path.Combine(shaderDir, "ui_text.vert"), + Path.Combine(shaderDir, "ui_text.frag")); + resources.Add("text shader", shader.Dispose); - for (int i = 0; i < _frameBuffers.Length; i++) - _frameBuffers[i] = CreateFrameBufferSet(); + for (int i = 0; i < frameBuffers.Length; i++) + frameBuffers[i] = CreateFrameBufferSet(resources); - // 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE - // bucket (the shader multiplies texel×color → white×color = color). Lets a panel - // background draw UNDER its text in painter order, which DrawRect's separate - // bucket cannot (it always composites after all sprites). - _whiteTex = _gl.GenTexture(); - _gl.BindTexture(TextureTarget.Texture2D, _whiteTex); - Span whitePixel = stackalloc byte[] { 255, 255, 255, 255 }; - fixed (byte* wp = whitePixel) - _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0, - PixelFormat.Rgba, PixelType.UnsignedByte, wp); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest); - _gl.BindTexture(TextureTarget.Texture2D, 0); + // 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE + // bucket (the shader multiplies texel×color → white×color = color). Lets a panel + // background draw UNDER its text in painter order, which DrawRect's separate + // bucket cannot (it always composites after all sprites). + whiteTexture = GlResourceCommand.CreateTexture( + _gl, + "TextRenderer white texture"); + uint ownedWhiteTexture = whiteTexture; + resources.Add( + "white texture", + () => GlResourceCommand.DeleteTexture( + _gl, + ownedWhiteTexture, + $"delete TextRenderer white texture {ownedWhiteTexture}")); + GlResourceCommand.Execute( + _gl, + "initialize TextRenderer white texture", + () => + { + _gl.BindTexture(TextureTarget.Texture2D, whiteTexture); + Span whitePixel = stackalloc byte[] { 255, 255, 255, 255 }; + fixed (byte* wp = whitePixel) + _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0, + PixelFormat.Rgba, PixelType.UnsignedByte, wp); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest); + _gl.BindTexture(TextureTarget.Texture2D, 0); + }); + } + catch (Exception constructionFailure) + { + try + { + resources.RetryCleanup(); + } + catch (Exception cleanupFailure) + { + throw new GlResourceConstructionException( + "TextRenderer construction failed and its published GL resources did not cleanly roll back.", + resources, + [constructionFailure, cleanupFailure]); + } + + throw; + } + + _resources = resources; + _shader = shader; + _frameBuffers = frameBuffers; + _whiteTex = whiteTexture; } /// @@ -120,43 +165,54 @@ public sealed unsafe class TextRenderer : IDisposable _vboCapacityBytes = set.CapacityBytes; } - private FrameBufferSet CreateFrameBufferSet() + private FrameBufferSet CreateFrameBufferSet(ResourceCleanupGroup resources) { - uint vao = 0; - uint vbo = 0; - try - { - vao = TrackedGlResource.CreateVertexArray( + uint vao = TrackedGlResource.CreateVertexArray( + _gl, + "TextRenderer frame VAO creation"); + RetryableGpuResourceRelease vaoRelease = + TrackedGlResource.CreateRetryableVertexArrayDeletion( _gl, - "TextRenderer frame VAO creation"); - vbo = TrackedGlResource.CreateBuffer( - _gl, - "TextRenderer frame VBO creation"); - var set = new FrameBufferSet { Vao = vao, Vbo = vbo }; + vao, + "TextRenderer frame VAO disposal"); + resources.Add("frame VAO", vaoRelease.Run); + var set = new FrameBufferSet { Vao = vao }; - _gl.BindVertexArray(set.Vao); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo); - uint stride = FloatsPerVertex * sizeof(float); - _gl.EnableVertexAttribArray(0); - _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0); - _gl.EnableVertexAttribArray(1); - _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float))); - _gl.EnableVertexAttribArray(2); - _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float))); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - _gl.BindVertexArray(0); - return set; - } - catch - { - if (vbo != 0) - TrackedGlResource.DeleteBuffer( - _gl, vbo, 0, "TextRenderer frame VBO rollback"); - if (vao != 0) - TrackedGlResource.DeleteVertexArray( - _gl, vao, "TextRenderer frame VAO rollback"); - throw; - } + uint vbo = TrackedGlResource.CreateBuffer( + _gl, + "TextRenderer frame VBO creation"); + set.Vbo = vbo; + RetryableGpuResourceRelease? vboRelease = null; + resources.Add( + "frame VBO", + () => + { + vboRelease ??= TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + vbo, + set.CapacityBytes, + "TextRenderer frame VBO disposal"); + vboRelease.Run(); + }); + + GlResourceCommand.Execute( + _gl, + "initialize TextRenderer frame VAO and VBO", + () => + { + _gl.BindVertexArray(set.Vao); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo); + uint stride = FloatsPerVertex * sizeof(float); + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0); + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float))); + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float))); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + _gl.BindVertexArray(0); + }); + return set; } /// Begin a HUD pass. Call once per frame before any Draw* calls. @@ -471,19 +527,6 @@ public sealed unsafe class TextRenderer : IDisposable public void Dispose() { - _gl.DeleteTexture(_whiteTex); - foreach (FrameBufferSet set in _frameBuffers) - { - TrackedGlResource.DeleteBuffer( - _gl, - set.Vbo, - set.CapacityBytes, - "TextRenderer frame VBO disposal"); - TrackedGlResource.DeleteVertexArray( - _gl, - set.Vao, - "TextRenderer frame VAO disposal"); - } - _shader.Dispose(); + _resources.RetryCleanup(); } } diff --git a/src/AcDream.App/Rendering/TrackedTextureConstruction.cs b/src/AcDream.App/Rendering/TrackedTextureConstruction.cs new file mode 100644 index 00000000..5d19a0a9 --- /dev/null +++ b/src/AcDream.App/Rendering/TrackedTextureConstruction.cs @@ -0,0 +1,31 @@ +namespace AcDream.App.Rendering; + +internal static class TrackedTextureConstruction +{ + public static uint Create( + GlTextureConstructionTransaction transaction, + Action initialize) + { + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(initialize); + uint texture = transaction.Allocate(); + initialize(texture); + return texture; + } + + public static uint Create( + GlTextureConstructionTransaction transaction, + Silk.NET.OpenGL.GL gl, + string context, + Action initialize) + { + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(gl); + ArgumentException.ThrowIfNullOrWhiteSpace(context); + ArgumentNullException.ThrowIfNull(initialize); + + uint texture = transaction.Allocate(); + GlResourceCommand.Execute(gl, context, () => initialize(texture)); + return texture; + } +} diff --git a/src/AcDream.App/Rendering/Wb/BindlessSupport.cs b/src/AcDream.App/Rendering/Wb/BindlessSupport.cs index 64dda3cb..0047b1d0 100644 --- a/src/AcDream.App/Rendering/Wb/BindlessSupport.cs +++ b/src/AcDream.App/Rendering/Wb/BindlessSupport.cs @@ -1,5 +1,6 @@ using Silk.NET.OpenGL; using Silk.NET.OpenGL.Extensions.ARB; +using AcDream.App.Rendering; namespace AcDream.App.Rendering.Wb; @@ -10,10 +11,12 @@ namespace AcDream.App.Rendering.Wb; /// public sealed class BindlessSupport { + private readonly GL _gl; private readonly ArbBindlessTexture _ext; - private BindlessSupport(ArbBindlessTexture extension) + private BindlessSupport(GL gl, ArbBindlessTexture extension) { + _gl = gl; _ext = extension; } @@ -21,7 +24,7 @@ public sealed class BindlessSupport { if (gl.TryGetExtension(out var ext)) { - support = new BindlessSupport(ext); + support = new BindlessSupport(gl, ext); return true; } support = null; @@ -32,17 +35,42 @@ public sealed class BindlessSupport /// Idempotent: handle is the same for a given texture name. public ulong GetResidentHandle(uint textureName) { - ulong h = _ext.GetTextureHandle(textureName); - if (!_ext.IsTextureHandleResident(h)) - _ext.MakeTextureHandleResident(h); + ulong h = GlResourceCommand.Execute( + _gl, + $"get bindless handle for texture {textureName}", + () => _ext.GetTextureHandle(textureName)); + if (h == 0) + throw new InvalidOperationException( + $"OpenGL returned no bindless handle for texture {textureName}."); + + bool resident = GlResourceCommand.Execute( + _gl, + $"query bindless handle {h} residency", + () => _ext.IsTextureHandleResident(h)); + if (!resident) + { + GlResourceCommand.Execute( + _gl, + $"make bindless handle {h} resident", + () => _ext.MakeTextureHandleResident(h)); + } return h; } /// Release residency for a handle. Call before deleting the underlying texture. public void MakeNonResident(ulong handle) { - if (_ext.IsTextureHandleResident(handle)) - _ext.MakeTextureHandleNonResident(handle); + bool resident = GlResourceCommand.Execute( + _gl, + $"query bindless handle {handle} residency before release", + () => _ext.IsTextureHandleResident(handle)); + if (!resident) + return; + + GlResourceCommand.Execute( + _gl, + $"make bindless handle {handle} non-resident", + () => _ext.MakeTextureHandleNonResident(handle)); } // Phase N.5b note: a `SetSamplerHandleUniform` wrapper was added in T6 diff --git a/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs b/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs index f442268a..14b43d3f 100644 --- a/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs +++ b/src/AcDream.App/Rendering/Wb/TrackedGlResource.cs @@ -1,5 +1,6 @@ using Silk.NET.OpenGL; using AcDream.App.Rendering; +using System.Runtime.ExceptionServices; namespace AcDream.App.Rendering.Wb; @@ -96,41 +97,68 @@ internal static unsafe class TrackedGlResource public static uint CreateBuffer(GL gl, string context) { - GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); - uint name = gl.GenBuffer(); - try - { - if (name == 0) - throw new InvalidOperationException($"OpenGL returned buffer name zero. Context: {context}"); - GLHelpers.ThrowOnResourceError(gl, context); - GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer); - return name; - } - catch - { - if (name != 0) - gl.DeleteBuffer(name); - throw; - } + return CreateTrackedName( + gl, + "buffer", + context, + gl.GenBuffer, + name => GlResourceCommand.DeleteBuffer( + gl, + name, + $"rollback buffer {name} after failed {context}"), + () => GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer)); } public static uint CreateVertexArray(GL gl, string context) { - GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"); - uint name = gl.GenVertexArray(); + return CreateTrackedName( + gl, + "vertex-array", + context, + gl.GenVertexArray, + name => GlResourceCommand.DeleteVertexArray( + gl, + name, + $"rollback vertex array {name} after failed {context}"), + () => GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO)); + } + + private static uint CreateTrackedName( + GL gl, + string resourceName, + string context, + Func create, + Action rollback, + Action publishAccounting) + { + uint name = GlResourceCommand.CreateName( + gl, + $"{resourceName} for {context}", + create, + rollback); + var cleanup = new ResourceCleanupGroup(); + cleanup.Add($"{resourceName} name {name}", () => rollback(name)); try { - if (name == 0) - throw new InvalidOperationException($"OpenGL returned vertex-array name zero. Context: {context}"); - GLHelpers.ThrowOnResourceError(gl, context); - GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO); + publishAccounting(); return name; } - catch + catch (Exception publicationFailure) { - if (name != 0) - gl.DeleteVertexArray(name); - throw; + try + { + cleanup.RetryCleanup(); + } + catch (Exception cleanupFailure) + { + throw new GlResourceConstructionException( + $"Publishing {resourceName} accounting failed and GL name {name} could not be released.", + cleanup, + [publicationFailure, cleanupFailure]); + } + + ExceptionDispatchInfo.Capture(publicationFailure).Throw(); + throw new InvalidOperationException("Unreachable resource-publication path."); } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 5d59a1cd..17b46f33 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -202,6 +202,8 @@ public sealed class RetailUiRuntime : IDisposable private ResourceShutdownTransaction? _shutdown; private bool _disposed; + internal bool IsDisposalComplete => _disposed; + private RetailUiRuntime(RetailUiRuntimeBindings bindings) { _bindings = bindings; @@ -211,6 +213,15 @@ public sealed class RetailUiRuntime : IDisposable bindings.Host.HideWindow); } + internal static RetailUiRuntime CreateUninitialized( + RetailUiRuntimeBindings bindings) + { + ArgumentNullException.ThrowIfNull(bindings); + return new RetailUiRuntime(bindings); + } + + internal void InitializeForLease() => Initialize(); + private void Initialize() { RetailUiRuntimeBindings bindings = _bindings; @@ -298,7 +309,7 @@ public sealed class RetailUiRuntime : IDisposable public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { ArgumentNullException.ThrowIfNull(bindings); - var runtime = new RetailUiRuntime(bindings); + var runtime = CreateUninitialized(bindings); try { runtime.Initialize(); diff --git a/src/AcDream.App/UI/RetailUiRuntimeLease.cs b/src/AcDream.App/UI/RetailUiRuntimeLease.cs new file mode 100644 index 00000000..b7f44bff --- /dev/null +++ b/src/AcDream.App/UI/RetailUiRuntimeLease.cs @@ -0,0 +1,263 @@ +namespace AcDream.App.UI; + +/// +/// Retains one UiHost/runtime ownership chain across partial construction, +/// initialization cleanup, lifetime retry, and explicit terminal abandonment. +/// +internal sealed class RetailUiRuntimeLease : IDisposable +{ + private object? _host; + private Action? _quiesceInput; + private Action? _deactivateInput; + private Action? _disposeHost; + private Func? _hostDisposalComplete; + + private object? _runtime; + private Action? _disposeRuntime; + private Func? _runtimeDisposalComplete; + + private bool _disposalFailed; + private bool _inputQuiesced; + private bool _inputDeactivated; + private bool _operationActive; + private bool _disposed; + private bool _abandoned; + + public bool IsDisposalComplete => _disposed; + + public bool IsAbandoned => _abandoned; + + internal bool RetainsResources => _host is not null || _runtime is not null; + + public UiHost AcquireHost(Func factory) => AcquireHostCore( + factory, + static host => host.QuiesceInput(), + static host => host.DeactivateInput(), + static host => host.Dispose(), + static host => host.IsDisposalComplete); + + internal T AcquireHostCore( + Func factory, + Action quiesceInput, + Action deactivateInput, + Action dispose, + Func disposalComplete) + where T : class + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(quiesceInput); + ArgumentNullException.ThrowIfNull(deactivateInput); + ArgumentNullException.ThrowIfNull(dispose); + ArgumentNullException.ThrowIfNull(disposalComplete); + ThrowIfUnavailable(); + if (_operationActive || _host is not null) + throw new InvalidOperationException("A retained UI host is already owned."); + + _operationActive = true; + try + { + T host = factory() + ?? throw new InvalidOperationException("The retained UI host factory returned null."); + _host = host; + _quiesceInput = () => quiesceInput(host); + _deactivateInput = () => deactivateInput(host); + _disposeHost = () => dispose(host); + _hostDisposalComplete = () => disposalComplete(host); + return host; + } + finally + { + _operationActive = false; + } + } + + public RetailUiRuntime Mount(Func factory) => MountCore( + factory, + static runtime => runtime.InitializeForLease(), + static runtime => runtime.Dispose(), + static runtime => runtime.IsDisposalComplete); + + internal T MountCore( + Func factory, + Action initialize, + Action dispose, + Func disposalComplete) + where T : class + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(initialize); + ArgumentNullException.ThrowIfNull(dispose); + ArgumentNullException.ThrowIfNull(disposalComplete); + ThrowIfUnavailable(); + if (_operationActive) + throw new InvalidOperationException("The retained UI lease is changing state."); + if (_host is null) + throw new InvalidOperationException("The retained UI host must be acquired first."); + if (_runtime is not null) + throw new InvalidOperationException("A retained UI runtime is already owned."); + + // Construction may throw while the lease still owns the host directly. + _operationActive = true; + T runtime; + try + { + runtime = factory() + ?? throw new InvalidOperationException("The retained UI runtime factory returned null."); + } + finally + { + _operationActive = false; + } + + // Publish the partial runtime before any Initialize side effect. + _runtime = runtime; + _disposeRuntime = () => dispose(runtime); + _runtimeDisposalComplete = () => disposalComplete(runtime); + + _operationActive = true; + try + { + initialize(runtime); + _operationActive = false; + return runtime; + } + catch (Exception initializationFailure) + { + _operationActive = false; + try + { + Dispose(); + } + catch (Exception cleanupFailure) + { + throw new AggregateException( + "Retail UI initialization failed and its published partial runtime did not cleanly retire.", + initializationFailure, + cleanupFailure); + } + + throw; + } + } + + public void QuiesceInput() + { + if (_disposed || _abandoned || _inputQuiesced) + return; + if (_operationActive) + throw new InvalidOperationException("The retained UI lease is changing state."); + + _operationActive = true; + _inputQuiesced = true; + try + { + _quiesceInput?.Invoke(); + } + catch + { + _inputQuiesced = false; + throw; + } + finally + { + _operationActive = false; + } + } + + public void DeactivateInput() + { + if (_disposed || _abandoned || _inputDeactivated) + return; + if (_operationActive) + throw new InvalidOperationException("The retained UI lease is changing state."); + + _operationActive = true; + _inputDeactivated = true; + try + { + _deactivateInput?.Invoke(); + } + catch + { + _inputDeactivated = false; + throw; + } + finally + { + _operationActive = false; + } + } + + public void Dispose() + { + if (_disposed || _abandoned) + return; + if (_operationActive) + throw new InvalidOperationException("The retained UI lease is changing state."); + + _operationActive = true; + try + { + if (_runtime is not null) + { + _disposeRuntime!(); + if (_runtimeDisposalComplete?.Invoke() != true) + throw new InvalidOperationException( + "The retained UI runtime returned without completing disposal."); + if (_hostDisposalComplete?.Invoke() != true) + throw new InvalidOperationException( + "The retained UI runtime completed without retiring its host."); + } + else if (_host is not null) + { + _disposeHost!(); + if (_hostDisposalComplete?.Invoke() != true) + throw new InvalidOperationException( + "The retained UI host returned without completing disposal."); + } + + _runtime = null; + _disposeRuntime = null; + _runtimeDisposalComplete = null; + _host = null; + _quiesceInput = null; + _deactivateInput = null; + _disposeHost = null; + _hostDisposalComplete = null; + _disposalFailed = false; + _disposed = true; + } + catch + { + _disposalFailed = true; + throw; + } + finally + { + _operationActive = false; + } + } + + public void AbandonAfterTerminalFailure() + { + if (_disposed || _abandoned) + return; + if (_operationActive) + throw new InvalidOperationException("The retained UI lease is changing state."); + if (!_disposalFailed) + throw new InvalidOperationException( + "Retained UI resources may be abandoned only after disposal failed."); + + // Keep the exact references rooted. Checkpoint J owns the final native + // fallback/reporting policy; abandonment must not turn this into an + // unowned local resource. + _abandoned = true; + } + + private void ThrowIfUnavailable() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_abandoned) + throw new InvalidOperationException("The retained UI lease was abandoned."); + } +} diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index ae48b966..3aa7eb20 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -56,6 +56,8 @@ public sealed class UiHost : System.IDisposable private bool _disposeRequested; private bool _disposed; + internal bool IsDisposalComplete => _disposed; + public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null) : this(gl, shaderDir, defaultFont, new HostQuiescenceGate()) { @@ -67,9 +69,9 @@ public sealed class UiHost : System.IDisposable BitmapFont? defaultFont, HostQuiescenceGate quiescence) { + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); TextRenderer = new TextRenderer(gl, shaderDir); DefaultFont = defaultFont; - _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); } // ── Per-frame ────────────────────────────────────────────────────── diff --git a/src/AcDream.App/Update/UpdateFrameOrchestrator.cs b/src/AcDream.App/Update/UpdateFrameOrchestrator.cs index 57272024..a55a7508 100644 --- a/src/AcDream.App/Update/UpdateFrameOrchestrator.cs +++ b/src/AcDream.App/Update/UpdateFrameOrchestrator.cs @@ -109,7 +109,7 @@ internal interface ICameraFramePhase /// . This host order is the accepted TS-53 /// adaptation and is not claimed to be the exact retail Client::UseTime order. /// -internal sealed class UpdateFrameOrchestrator +internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdateFrameRoot { private readonly IUpdateFrameTeardownPhase _teardown; private readonly IUpdateFrameFailureSink _failureSink; diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index ca339663..cf866a99 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -43,8 +43,9 @@ public sealed class GameWindowRenderLeafCompositionTests AssertAppearsInOrder( source, + "_portalTunnelFallback.Transfer(", "new AcDream.App.Streaming.LocalPlayerTeleportController(", - "_portalTunnel = null;", + "new AcDream.App.Streaming.LocalPlayerTeleportPresentation(", "_localPlayerTeleportSink.Bind(_localPlayerTeleport);", "new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(", "new AcDream.App.Rendering.RenderFrameResourceController(", @@ -125,7 +126,7 @@ public sealed class GameWindowRenderLeafCompositionTests AssertAppearsInOrder( source, "new ResourceShutdownStage(\"frame borrowers\"", - "_renderFrameOrchestrator = null;", + "_frameGraphs.Withdraw();", "new ResourceShutdownStage(\"session dependents\""); AssertAppearsInOrder( source, @@ -133,7 +134,7 @@ public sealed class GameWindowRenderLeafCompositionTests "new(\"frame profiler\", _frameProfiler.Dispose)"); AssertAppearsInOrder( source, - "_renderFrameOrchestrator = null;", + "_frameGraphs.Withdraw();", "_devToolsBackend?.Dispose()", "new ResourceShutdownStage(\"input\"", "_input?.Dispose();", @@ -175,7 +176,7 @@ public sealed class GameWindowRenderLeafCompositionTests Assert.True(start >= 0 && end > start); string body = source[start..end]; - Assert.Equal(1, CountOccurrences(body, "_renderFrameOrchestrator!.Render(")); + Assert.Equal(1, CountOccurrences(body, "_frameGraphs.Render(")); Assert.DoesNotContain("_gpuFrameFlights", body); Assert.DoesNotContain("_worldScene", body); Assert.DoesNotContain("_devToolsFramePresenter", body); diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 1241f58f..e1372add 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -67,17 +67,18 @@ public sealed class GameWindowSlice8BoundaryTests "_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);", "_runtimeSettings.ApplyStartup(", "new RuntimeSettingsStartupTargets(", - "_uiHost = new AcDream.App.UI.UiHost(", + "_uiHost = _retailUiLease.AcquireHost(", "_uiHost.WireMouse(m)", "_uiHost.WireKeyboard(kb)", - "_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(", + "_retailUiRuntime = _retailUiLease.Mount(", "_liveEntities = new AcDream.App.World.LiveEntityRuntime(", "_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(", "_retainedUiGameplayBinding =", "AcDream.App.Input.RetainedUiGameplayBinding.Create(", "_retainedUiGameplayBinding.Attach();", - "_renderFrameOrchestrator =", - "_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(", + "var renderFrameOrchestrator =", + "var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(", + "_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);", "_liveSessionHost = CreateLiveSessionHost();", "_liveCombatModeCommands.Bind(combatCommand);", "_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);", @@ -265,7 +266,7 @@ public sealed class GameWindowSlice8BoundaryTests shutdown, "_runtimeSettings.UnbindViewModel()", "_runtimeSettings.UnbindRuntimeTargets", - "_retailUiRuntime?.Dispose()", + "_retailUiLease.Dispose()", "_streamer?.Dispose()", "_wbDrawDispatcher?.Dispose()", "_terrain?.Dispose()"); @@ -292,12 +293,12 @@ public sealed class GameWindowSlice8BoundaryTests "private void OnClosing()", "private ResourceShutdownTransaction CreateShutdownTransaction()"); - Assert.Equal(1, CountOccurrences(update, "_updateFrameOrchestrator.Tick(")); - Assert.Equal(1, CountOccurrences(render, "_renderFrameOrchestrator!.Render(")); + Assert.Equal(1, CountOccurrences(update, "_frameGraphs.Tick(")); + Assert.Equal(1, CountOccurrences(render, "_frameGraphs.Render(")); AssertAppearsInOrder( render, "Vector2D size = _window!.Size;", - "_renderFrameOrchestrator!.Render(", + "_frameGraphs.Render(", "new AcDream.App.Rendering.RenderFrameInput("); Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal); Assert.Contains( @@ -338,6 +339,8 @@ public sealed class GameWindowSlice8BoundaryTests "new ResourceShutdownStage(\"shared texture owners\"", "new ResourceShutdownStage(\"mesh adapter\"", "new ResourceShutdownStage(\"remaining render owners\"", + "new ResourceShutdownStage(\"dedicated render resources\"", + "new ResourceShutdownStage(\"failed render construction cleanup\"", "new ResourceShutdownStage(\"frame flight owner\"", "new ResourceShutdownStage(\"content mappings\"", "new ResourceShutdownStage(\"input\"", @@ -390,6 +393,66 @@ public sealed class GameWindowSlice8BoundaryTests character => Assert.True(char.IsWhiteSpace(character) || character == '}')); } + [Fact] + public void ResourceRootsAndFramePairHaveExplicitAcquireTransferAndReleaseBoundaries() + { + string source = GameWindowSource(); + string load = MethodBody( + "private void OnLoad()", + "private AcDream.App.Net.LiveSessionHost"); + string shutdown = Slice( + source, + "private ResourceShutdownTransaction CreateShutdownTransaction()", + "private void OnFocusChanged(bool focused)"); + string terrainAtlas = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "TerrainAtlas.cs")); + + AssertAppearsInOrder( + load, + "_renderResourceLifetime.AcquireTerrainAtlas(", + "_terrain = new TerrainModernRenderer(", + "_portalTunnelFallback.AcquirePrepared(", + "static portalTunnel => portalTunnel.PrepareResources());", + "_renderResourceLifetime.AcquireSkyShader(", + "_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(", + "_portalTunnelFallback.Transfer(", + "_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);"); + AssertAppearsInOrder( + shutdown, + "_frameGraphs.Withdraw();", + "_retailUiLease.Dispose();", + "_localPlayerTeleport?.Dispose();", + "_portalTunnelFallback.ReleaseFallback();", + "_skyRenderer?.Dispose()", + "_terrain?.Dispose();", + "_renderResourceLifetime.ReleaseSkyShader", + "_renderResourceLifetime.ReleaseTerrainAtlas", + "_glConstructionCleanup.Dispose", + "_gl?.Dispose();"); + + Assert.DoesNotContain( + "RetailUiRuntime.Mount(", + load, + StringComparison.Ordinal); + Assert.DoesNotContain("gl.GenTexture()", terrainAtlas, StringComparison.Ordinal); + Assert.DoesNotContain( + "portalTunnel.PrepareResources();", + load, + StringComparison.Ordinal); + Assert.DoesNotContain("_portalTunnel =", source, StringComparison.Ordinal); + Assert.DoesNotContain("_renderFrameOrchestrator", source, StringComparison.Ordinal); + Assert.DoesNotContain("_updateFrameOrchestrator", source, StringComparison.Ordinal); + AssertAppearsInOrder( + source, + "_window.Run();", + "_glConstructionCleanup.RetainFrom(failure);", + "_glConstructionCleanup.Dispose"); + } + private static string MethodBody(string start, string end) => Slice(GameWindowSource(), start, end); diff --git a/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs b/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs new file mode 100644 index 00000000..2edc666f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/GlTextureOwnershipTests.cs @@ -0,0 +1,396 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class GlTextureOwnershipTests +{ + [Fact] + public void ConstructionRollbackDeletesAllNamesInReverseOrder() + { + var api = new FakeTextureNameApi(); + var transaction = new GlTextureConstructionTransaction(api); + + Assert.Equal(1u, transaction.Allocate()); + Assert.Equal(2u, transaction.Allocate()); + Assert.Equal(3u, transaction.Allocate()); + + transaction.Rollback(); + transaction.Rollback(); + + Assert.Equal([3u, 2u, 1u], api.DeleteAttempts); + } + + [Fact] + public void ConstructionRollbackAttemptsEveryNameAndReportsFailures() + { + var api = new FakeTextureNameApi { FailingDelete = 2 }; + var transaction = new GlTextureConstructionTransaction(api); + _ = transaction.Allocate(); + _ = transaction.Allocate(); + _ = transaction.Allocate(); + + AggregateException failure = Assert.Throws(transaction.Rollback); + + Assert.Single(failure.InnerExceptions); + Assert.Equal([3u, 2u, 1u], api.DeleteAttempts); + Assert.Equal([3u, 1u], api.Deleted); + + api.FailingDelete = null; + transaction.RetryCleanup(); + + Assert.True(transaction.IsCleanupComplete); + Assert.Equal([3u, 2u, 1u, 2u], api.DeleteAttempts); + Assert.Equal([3u, 1u, 2u], api.Deleted); + } + + [Fact] + public void CommittedNamesAreNeverDeletedByConstructionTransaction() + { + var api = new FakeTextureNameApi(); + var transaction = new GlTextureConstructionTransaction(api); + _ = transaction.Allocate(); + _ = transaction.Allocate(); + + transaction.Commit(); + transaction.Rollback(); + + Assert.Empty(api.DeleteAttempts); + Assert.Throws(() => transaction.Allocate()); + } + + [Fact] + public void TrackedTextureUploadFailureLeavesEveryActualBranchNameInTransaction() + { + var api = new FakeTextureNameApi(); + var transaction = new GlTextureConstructionTransaction(api); + uint first = TrackedTextureConstruction.Create(transaction, _ => { }); + + Assert.Throws(() => + TrackedTextureConstruction.Create( + transaction, + _ => throw new InvalidOperationException("upload failed"))); + transaction.Rollback(); + + Assert.Equal(1u, first); + Assert.Equal([2u, 1u], api.DeleteAttempts); + } + + [Fact] + public void TerrainAtlasRoutesEveryTerrainAlphaAndFallbackUploadThroughTracker() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "TerrainAtlas.cs")); + + Assert.Equal(5, CountOccurrences(source, "TrackedTextureConstruction.Create(")); + Assert.Equal( + 5, + System.Text.RegularExpressions.Regex.Matches( + source, + "TrackedTextureConstruction\\.Create\\(\\s*textures,\\s*gl,\\s*\\\"").Count); + Assert.DoesNotContain("textures.Allocate()", source, StringComparison.Ordinal); + } + + [Fact] + public void ProductionGlResourcePathsUseAlwaysOnCheckedCommitBoundaries() + { + string root = FindRepoRoot(); + string textureNames = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "GlTextureConstructionTransaction.cs")); + string shaderPrograms = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "ShaderProgramConstruction.cs")); + string terrain = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "TerrainAtlas.cs")); + string text = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "TextRenderer.cs")); + string bindless = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "Wb", "BindlessSupport.cs")); + + Assert.Contains("GlResourceCommand.CreateTexture", textureNames, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.DeleteTexture", textureNames, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.CreateName", shaderPrograms, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.DeleteShader", shaderPrograms, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.DeleteProgram", shaderPrograms, StringComparison.Ordinal); + Assert.Contains("_anisotropyBindingMutation.Execute", terrain, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.DeleteTexture", terrain, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.CreateTexture", text, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.Execute", text, StringComparison.Ordinal); + Assert.Contains("make bindless handle", bindless, StringComparison.Ordinal); + Assert.Contains("GlResourceCommand.Execute", bindless, StringComparison.Ordinal); + } + + [Fact] + public void SecondBindlessAcquireFailureRollsBackFirstHandle() + { + var residency = new FakeResidency { FailingAcquireTexture = 20 }; + var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release); + + Assert.Throws(() => pair.Acquire()); + + Assert.False(pair.IsFullyResident); + Assert.Equal([10u, 20u], residency.AcquireAttempts); + Assert.Equal([1010ul], residency.ReleaseAttempts); + } + + [Fact] + public void FailedPrefixRollbackRemainsOwnedAndRetryDoesNotReacquireIt() + { + var residency = new FakeResidency + { + FailingAcquireTexture = 20, + FailingReleaseHandle = 1010, + }; + var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release); + + Assert.Throws(() => pair.Acquire()); + residency.FailingAcquireTexture = null; + residency.FailingReleaseHandle = null; + + Assert.Equal((1010ul, 1020ul), pair.Acquire()); + + Assert.True(pair.IsFullyResident); + Assert.Equal([10u, 20u, 20u], residency.AcquireAttempts); + Assert.Equal([1010ul], residency.ReleaseAttempts); + } + + [Fact] + public void BindlessReleaseAttemptsBothAndRetriesOnlyPendingHandle() + { + var residency = new FakeResidency(); + var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release); + _ = pair.Acquire(); + residency.FailingReleaseHandle = 1010; + + Assert.Throws(pair.Release); + Assert.False(pair.IsFullyResident); + Assert.True(pair.HasAnyResident); + Assert.Equal([1010ul, 1020ul], residency.ReleaseAttempts); + + residency.FailingReleaseHandle = null; + pair.Release(); + pair.Release(); + + Assert.False(pair.HasAnyResident); + Assert.Equal([1010ul, 1020ul, 1010ul], residency.ReleaseAttempts); + } + + [Fact] + public void MutationFailureStillRestoresThePreviouslyResidentPair() + { + var residency = new FakeResidency(); + var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release); + var guard = new BindlessTextureMutationGuard(pair); + _ = pair.Acquire(); + + Assert.Throws(() => + guard.Execute(() => throw new InvalidOperationException("mutation failed"))); + + Assert.True(pair.IsFullyResident); + Assert.False(guard.RestoreRequired); + Assert.Equal([10u, 20u, 10u, 20u], residency.AcquireAttempts); + } + + [Fact] + public void FailedReacquireKeepsRestoreIntentUntilALaterMutationRetry() + { + var residency = new FakeResidency(); + var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release); + var guard = new BindlessTextureMutationGuard(pair); + _ = pair.Acquire(); + residency.FailingAcquireTexture = 20; + + Assert.Throws(() => guard.Execute(() => { })); + Assert.True(guard.RestoreRequired); + Assert.False(pair.HasAnyResident); + + residency.FailingAcquireTexture = null; + guard.Execute(() => { }); + + Assert.False(guard.RestoreRequired); + Assert.True(pair.IsFullyResident); + Assert.Equal([10u, 20u, 10u, 20u, 10u, 20u], residency.AcquireAttempts); + } + + [Fact] + public void PartialReleaseFailurePreventsMutationAndRestoresPairBeforeThrowing() + { + var residency = new FakeResidency(); + var pair = new BindlessTexturePair(10, 20, residency.Acquire, residency.Release); + var guard = new BindlessTextureMutationGuard(pair); + _ = pair.Acquire(); + residency.FailingReleaseHandle = 1010; + int mutations = 0; + + Assert.Throws(() => guard.Execute(() => mutations++)); + + Assert.Equal(0, mutations); + Assert.True(pair.IsFullyResident); + Assert.False(guard.RestoreRequired); + } + + [Fact] + public void FailedTextureMutationRestoresTheExactPriorBinding() + { + var bindings = new List(); + var owner = new RestoredTextureBindingMutation(); + + InvalidOperationException failure = Assert.Throws(() => + owner.Execute( + () => 77, + bindings.Add, + 42, + () => throw new InvalidOperationException("mutation failed"))); + + Assert.Equal("mutation failed", failure.Message); + Assert.Equal([42u, 77u], bindings); + } + + [Fact] + public void TextureMutationReportsBothMutationAndBindingRestoreFailure() + { + int bindCalls = 0; + var owner = new RestoredTextureBindingMutation(); + + AggregateException failure = Assert.Throws(() => + owner.Execute( + () => 77, + _ => + { + bindCalls++; + if (bindCalls == 2) + throw new InvalidOperationException("restore failed"); + }, + 42, + () => throw new InvalidOperationException("mutation failed"))); + + Assert.Equal(2, failure.InnerExceptions.Count); + Assert.Equal(2, bindCalls); + Assert.True(owner.HasPendingRestore); + } + + [Fact] + public void FailedBindingRestoreRetriesTheOriginalBindingBeforeAnotherMutation() + { + var owner = new RestoredTextureBindingMutation(); + var bindings = new List(); + int restoreFailures = 1; + int mutations = 0; + + Assert.Throws(() => + owner.Execute( + () => 77, + binding => + { + bindings.Add(binding); + if (binding == 77 && restoreFailures-- > 0) + throw new InvalidOperationException("restore failed"); + }, + 42, + () => mutations++)); + + Assert.True(owner.HasPendingRestore); + owner.Execute( + () => 77, + bindings.Add, + 42, + () => mutations++); + + Assert.False(owner.HasPendingRestore); + Assert.Equal([42u, 77u, 77u, 42u, 77u], bindings); + Assert.Equal(2, mutations); + } + + [Fact] + public void ConstructionCleanupLedgerRetainsNestedFailureUntilRetryCompletes() + { + var api = new FakeTextureNameApi { FailingDelete = 1 }; + var transaction = new GlTextureConstructionTransaction(api); + _ = transaction.Allocate(); + AggregateException cleanupFailure = Assert.Throws( + transaction.Rollback); + var constructionFailure = new GlResourceConstructionException( + "synthetic construction failure", + transaction, + [new InvalidOperationException("build failed"), cleanupFailure]); + var ledger = new GlConstructionCleanupLedger(); + + Assert.True(ledger.RetainFrom(constructionFailure)); + Assert.Throws(ledger.Dispose); + Assert.False(ledger.IsComplete); + + api.FailingDelete = null; + ledger.Dispose(); + + Assert.True(ledger.IsComplete); + Assert.True(constructionFailure.IsCleanupComplete); + } + + private sealed class FakeTextureNameApi : IGlTextureNameApi + { + private uint _nextName = 1; + + public uint? FailingDelete { get; set; } + public List DeleteAttempts { get; } = []; + public List Deleted { get; } = []; + + public uint GenTexture() => _nextName++; + + public void DeleteTexture(uint texture) + { + DeleteAttempts.Add(texture); + if (FailingDelete == texture) + throw new InvalidOperationException("delete failed"); + Deleted.Add(texture); + } + } + + private static int CountOccurrences(string source, string value) + { + int count = 0; + int cursor = 0; + while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0) + { + count++; + cursor += value.Length; + } + return count; + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } + + private sealed class FakeResidency + { + public uint? FailingAcquireTexture { get; set; } + public ulong? FailingReleaseHandle { get; set; } + public List AcquireAttempts { get; } = []; + public List ReleaseAttempts { get; } = []; + + public ulong Acquire(uint texture) + { + AcquireAttempts.Add(texture); + if (FailingAcquireTexture == texture) + throw new InvalidOperationException("acquire failed"); + return 1000ul + texture; + } + + public void Release(ulong handle) + { + ReleaseAttempts.Add(handle); + if (FailingReleaseHandle == handle) + throw new InvalidOperationException("release failed"); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs b/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs new file mode 100644 index 00000000..d52c1352 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ResourceCleanupGroupTests.cs @@ -0,0 +1,122 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class ResourceCleanupGroupTests +{ + [Fact] + public void CleanupRunsInReverseOrderAndNeverReplaysSuccess() + { + var calls = new List(); + var resources = new ResourceCleanupGroup(); + int middleFailures = 1; + resources.Add("first", () => calls.Add("first")); + resources.Add("middle", () => + { + calls.Add("middle"); + if (middleFailures-- > 0) + throw new InvalidOperationException("middle failed"); + }); + resources.Add("last", () => calls.Add("last")); + + Assert.Throws(resources.RetryCleanup); + Assert.Equal(["last", "middle", "first"], calls); + + resources.RetryCleanup(); + resources.RetryCleanup(); + + Assert.True(resources.IsCleanupComplete); + Assert.Equal(["last", "middle", "first", "middle"], calls); + } + + [Fact] + public void TextRendererPublishesEveryConstructorResourceBeforeLaterGlWork() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "TextRenderer.cs")); + + AssertAppearsInOrder( + source, + "shader = new Shader(gl,", + "resources.Add(\"text shader\", shader.Dispose);", + "frameBuffers[i] = CreateFrameBufferSet(resources);", + "whiteTexture = GlResourceCommand.CreateTexture(", + "resources.Add(", + "\"white texture\"", + "GlResourceCommand.Execute(", + "initialize TextRenderer white texture", + "resources.RetryCleanup();", + "_resources = resources;"); + AssertAppearsInOrder( + source, + "private FrameBufferSet CreateFrameBufferSet(ResourceCleanupGroup resources)", + "TrackedGlResource.CreateVertexArray(", + "resources.Add(\"frame VAO\", vaoRelease.Run);", + "TrackedGlResource.CreateBuffer(", + "resources.Add(", + "\"frame VBO\""); + Assert.Contains("_resources.RetryCleanup();", source, StringComparison.Ordinal); + Assert.DoesNotContain("private FrameBufferSet CreateFrameBufferSet()", source, + StringComparison.Ordinal); + } + + [Fact] + public void ReturnedNameIsRetainedWhenPostconditionAndFirstRollbackFail() + { + bool deleteFails = true; + int deleteCalls = 0; + + GlResourceConstructionException failure = + Assert.Throws(() => + GlResourceCommand.CreateNameCore( + "synthetic buffer", + static () => { }, + () => 42, + () => throw new InvalidOperationException("postcondition failed"), + name => + { + Assert.Equal(42u, name); + deleteCalls++; + if (deleteFails) + throw new InvalidOperationException("delete failed"); + })); + + Assert.False(failure.IsCleanupComplete); + Assert.Equal(1, deleteCalls); + + deleteFails = false; + failure.RetryCleanup(); + failure.RetryCleanup(); + + Assert.True(failure.IsCleanupComplete); + Assert.Equal(2, deleteCalls); + } + + private static void AssertAppearsInOrder(string source, params string[] values) + { + int cursor = -1; + foreach (string value in values) + { + int next = source.IndexOf(value, cursor + 1, StringComparison.Ordinal); + Assert.True(next > cursor, $"Missing or out-of-order source fragment: {value}"); + cursor = next; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs b/tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs new file mode 100644 index 00000000..e19804df --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs @@ -0,0 +1,271 @@ +using AcDream.App.Rendering; +using AcDream.App.Update; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RuntimeResourceSlotTests +{ + [Fact] + public void OwnedSlotPublishesOnceAndRetriesOnlyFailedRelease() + { + var slot = new OwnedResourceSlot(); + var resource = new RetryableResource(remainingFailures: 1); + + Assert.Same(resource, slot.Acquire(() => resource)); + Assert.Same(resource, slot.Borrow()); + Assert.Throws(() => + slot.Acquire(() => new RetryableResource())); + + Assert.Throws(slot.Release); + Assert.True(slot.HasResource); + + slot.Release(); + slot.Release(); + + Assert.False(slot.HasResource); + Assert.Equal(2, resource.DisposeCalls); + } + + [Fact] + public void TransferSlotKeepsFallbackUntilCompleteDestinationExists() + { + var slot = new TransferableResourceSlot(); + var resource = slot.Acquire(() => new RetryableResource()); + + Assert.Throws(() => + slot.Transfer(_ => throw new InvalidOperationException("destination failed"))); + Assert.True(slot.HasFallback); + Assert.Same(resource, slot.Borrow()); + + var owner = slot.Transfer(value => new ResourceOwner(value)); + + Assert.False(slot.HasFallback); + slot.ReleaseFallback(); + Assert.Equal(0, resource.DisposeCalls); + + owner.Dispose(); + Assert.Equal(1, resource.DisposeCalls); + } + + [Fact] + public void TransferSlotReleasesOnlyFallbackOnPartialShutdown() + { + var fallback = new TransferableResourceSlot(); + RetryableResource fallbackResource = fallback.Acquire( + () => new RetryableResource()); + + fallback.ReleaseFallback(); + fallback.ReleaseFallback(); + + Assert.Equal(1, fallbackResource.DisposeCalls); + + var transferred = new TransferableResourceSlot(); + RetryableResource transferredResource = transferred.Acquire( + () => new RetryableResource()); + ResourceOwner owner = transferred.Transfer(value => new ResourceOwner(value)); + + owner.Dispose(); + transferred.ReleaseFallback(); + + Assert.Equal(1, transferredResource.DisposeCalls); + } + + [Fact] + public void TransferSlotCoversAcquirePrepareAndPostTransferBindingPrefixes() + { + var acquisition = new TransferableResourceSlot(); + Assert.Throws(() => + acquisition.Acquire(() => throw new InvalidOperationException("create failed"))); + Assert.False(acquisition.HasFallback); + + var preparation = new TransferableResourceSlot(); + var prepared = new RetryableResource(remainingPrepareFailures: 1); + Assert.Throws(() => + preparation.AcquirePrepared(() => prepared, static value => value.Prepare())); + Assert.True(preparation.HasFallback); + Assert.Throws(() => + preparation.Transfer(value => new ResourceOwner(value))); + Assert.Same( + prepared, + preparation.AcquirePrepared( + () => throw new InvalidOperationException("factory replayed"), + static value => value.Prepare())); + Assert.Equal(2, prepared.PrepareCalls); + preparation.ReleaseFallback(); + Assert.Equal(1, prepared.DisposeCalls); + + var binding = new TransferableResourceSlot(); + RetryableResource transferred = binding.Acquire(() => new RetryableResource()); + ResourceOwner? owner = null; + Assert.Throws((Action)(() => + { + owner = binding.Transfer(value => new ResourceOwner(value)); + throw new InvalidOperationException("binding failed"); + })); + Assert.False(binding.HasFallback); + Assert.NotNull(owner); + owner.Dispose(); + binding.ReleaseFallback(); + Assert.Equal(1, transferred.DisposeCalls); + } + + [Fact] + public void FrameGraphsPublishAtomicallyRouteAndBecomeSilentAfterWithdrawal() + { + var calls = new List(); + var slot = new GameFrameGraphSlot(); + + Assert.False(slot.Tick(new UpdateFrameInput(0.25))); + Assert.False(slot.Render(new RenderFrameInput(0.5, 800, 600), out _)); + + var update = new RecordingUpdateRoot(calls); + var render = new RecordingRenderRoot(calls); + slot.Publish(update, render); + + Assert.True(slot.IsPublished); + Assert.Throws(() => + slot.Publish(new RecordingUpdateRoot(calls), new RecordingRenderRoot(calls))); + Assert.True(slot.Tick(new UpdateFrameInput(0.25))); + Assert.True(slot.Render(new RenderFrameInput(0.5, 800, 600), out _)); + Assert.Equal(["update:0.25", "render:0.5:800:600"], calls); + + slot.Withdraw(); + slot.Withdraw(); + + Assert.False(slot.IsPublished); + Assert.False(slot.Tick(new UpdateFrameInput(1))); + Assert.False(slot.Render(new RenderFrameInput(1, 1, 1), out _)); + Assert.Equal(2, calls.Count); + } + + [Fact] + public void WithdrawnFrameSlotAcceptsACompleteReplacementPair() + { + var calls = new List(); + var slot = new GameFrameGraphSlot(); + slot.Publish(new RecordingUpdateRoot(calls), new RecordingRenderRoot(calls)); + slot.Withdraw(); + + slot.Publish(new RecordingUpdateRoot(calls), new RecordingRenderRoot(calls)); + + Assert.True(slot.Tick(new UpdateFrameInput(2))); + Assert.Equal(["update:2"], calls); + } + + [Fact] + public void OwnedSlotRejectsReentrantAcquireWithoutLosingOuterResource() + { + var slot = new OwnedResourceSlot(); + var outer = new RetryableResource(); + int innerFactories = 0; + + Assert.Same(outer, slot.Acquire(() => + { + Assert.Throws(() => slot.Acquire(() => + { + innerFactories++; + return new RetryableResource(); + })); + return outer; + })); + + Assert.Equal(0, innerFactories); + Assert.Same(outer, slot.Borrow()); + slot.Release(); + Assert.Equal(1, outer.DisposeCalls); + } + + [Fact] + public void OwnedSlotRejectsReentrantReleaseWithoutDoubleDisposal() + { + var slot = new OwnedResourceSlot(); + var resource = new CallbackResource(() => + Assert.Throws(slot.Release)); + slot.Acquire(() => resource); + + slot.Release(); + + Assert.Equal(1, resource.DisposeCalls); + Assert.False(slot.HasResource); + } + + [Fact] + public void TransferSlotRejectsNestedTransferAndPreservesOuterTransfer() + { + var slot = new TransferableResourceSlot(); + var resource = slot.Acquire(() => new RetryableResource()); + int nestedOwners = 0; + + ResourceOwner owner = slot.Transfer(value => + { + Assert.Throws(() => + slot.Transfer(nested => + { + nestedOwners++; + return new ResourceOwner(nested); + })); + return new ResourceOwner(value); + }); + + Assert.Equal(0, nestedOwners); + Assert.False(slot.HasFallback); + owner.Dispose(); + Assert.Equal(1, resource.DisposeCalls); + } + + private sealed class RetryableResource( + int remainingFailures = 0, + int remainingPrepareFailures = 0) : IDisposable + { + private int _remainingFailures = remainingFailures; + private int _remainingPrepareFailures = remainingPrepareFailures; + + public int DisposeCalls { get; private set; } + public int PrepareCalls { get; private set; } + + public void Prepare() + { + PrepareCalls++; + if (_remainingPrepareFailures-- > 0) + throw new InvalidOperationException("prepare failed"); + } + + public void Dispose() + { + DisposeCalls++; + if (_remainingFailures-- > 0) + throw new InvalidOperationException("synthetic release failure"); + } + } + + private sealed class ResourceOwner(RetryableResource resource) : IDisposable + { + public void Dispose() => resource.Dispose(); + } + + private sealed class CallbackResource(Action onDispose) : IDisposable + { + public int DisposeCalls { get; private set; } + + public void Dispose() + { + DisposeCalls++; + onDispose(); + } + } + + private sealed class RecordingUpdateRoot(List calls) : IGameUpdateFrameRoot + { + public void Tick(UpdateFrameInput input) => calls.Add($"update:{input.HostDeltaSeconds}"); + } + + private sealed class RecordingRenderRoot(List calls) : IGameRenderFrameRoot + { + public RenderFrameOutcome Render(RenderFrameInput input) + { + calls.Add( + $"render:{input.DeltaSeconds}:{input.ViewportWidth}:{input.ViewportHeight}"); + return default; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ShaderProgramConstructionTests.cs b/tests/AcDream.App.Tests/Rendering/ShaderProgramConstructionTests.cs new file mode 100644 index 00000000..f66113ee --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ShaderProgramConstructionTests.cs @@ -0,0 +1,197 @@ +using AcDream.App.Rendering; +using Silk.NET.OpenGL; + +namespace AcDream.App.Tests.Rendering; + +public sealed class ShaderProgramConstructionTests +{ + [Theory] + [InlineData("ShaderSource:VertexShader", 1, 0)] + [InlineData("CompileShader:VertexShader", 1, 0)] + [InlineData("CreateShader:FragmentShader", 1, 0)] + [InlineData("ShaderSource:FragmentShader", 2, 0)] + [InlineData("CompileShader:FragmentShader", 2, 0)] + [InlineData("CreateProgram", 2, 0)] + [InlineData("AttachShader:VertexShader", 2, 1)] + [InlineData("AttachShader:FragmentShader", 2, 1)] + [InlineData("LinkProgram", 2, 1)] + public void ThrowAfterEachAllocatedNameRollsBackEveryPublishedName( + string failingOperation, + int expectedShaderDeletes, + int expectedProgramDeletes) + { + var api = new FakeShaderApi { ThrowOn = failingOperation }; + + Assert.Throws(() => + ShaderProgramConstruction.Build(api, "vertex", "fragment")); + + Assert.Equal(expectedShaderDeletes, api.DeletedShaders.Count); + Assert.Equal(expectedProgramDeletes, api.DeletedPrograms.Count); + Assert.Equal(api.CreatedShaders, api.DeletedShaders.Order().ToArray()); + Assert.Equal(api.CreatedPrograms, api.DeletedPrograms.Order().ToArray()); + } + + [Fact] + public void CompileFailurePreservesOriginalFailureWhenRollbackIsClean() + { + var api = new FakeShaderApi { VertexCompiles = false }; + + InvalidOperationException failure = Assert.Throws(() => + ShaderProgramConstruction.Build(api, "vertex", "fragment")); + + Assert.Contains("VertexShader compile failed", failure.Message); + Assert.Equal([1u], api.DeletedShaders); + Assert.Empty(api.CreatedPrograms); + } + + [Fact] + public void LinkFailureDetachesAndDeletesBothShadersAndProgram() + { + var api = new FakeShaderApi { ProgramLinks = false }; + + InvalidOperationException failure = Assert.Throws(() => + ShaderProgramConstruction.Build(api, "vertex", "fragment")); + + Assert.Contains("program link failed", failure.Message); + Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls); + Assert.Equal([1u, 2u], api.DeletedShaders); + Assert.Equal([3u], api.DeletedPrograms); + } + + [Fact] + public void SuccessfulBuildCommitsOnlyProgramAndReleasesTemporaryShaders() + { + var api = new FakeShaderApi(); + + uint program = ShaderProgramConstruction.Build(api, "vertex", "fragment"); + + Assert.Equal(3u, program); + Assert.Equal([1u, 2u], api.DeletedShaders); + Assert.Empty(api.DeletedPrograms); + Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls); + } + + [Fact] + public void CleanupFailureStillAttemptsEveryOtherReleaseAndProgramRollback() + { + var api = new FakeShaderApi { ThrowOn = "DetachShader:VertexShader" }; + + AggregateException failure = Assert.Throws(() => + ShaderProgramConstruction.Build(api, "vertex", "fragment")); + + Assert.Single(failure.InnerExceptions); + Assert.Equal(["DetachShader:VertexShader", "DetachShader:FragmentShader"], api.DetachCalls); + Assert.Equal([1u, 2u], api.DeletedShaders); + Assert.Equal([3u], api.DeletedPrograms); + } + + [Fact] + public void ConstructionAndRollbackFailuresAreReportedTogether() + { + var api = new FakeShaderApi + { + ProgramLinks = false, + ThrowOn = "DeleteShader:VertexShader", + }; + + GlResourceConstructionException failure = Assert.Throws(() => + ShaderProgramConstruction.Build(api, "vertex", "fragment")); + + Assert.Equal(2, failure.InnerExceptions.Count); + Assert.Contains("program link failed", failure.InnerExceptions[0].Message); + Assert.Equal([1u, 2u], api.DeleteShaderAttempts); + Assert.Equal([2u], api.DeletedShaders); + Assert.Equal([3u], api.DeletedPrograms); + + api.ThrowOn = null; + failure.RetryCleanup(); + + Assert.True(failure.IsCleanupComplete); + Assert.Equal([2u, 1u], api.DeletedShaders); + Assert.Equal([1u, 2u, 1u], api.DeleteShaderAttempts); + } + + private sealed class FakeShaderApi : IShaderProgramBuildApi + { + private readonly Dictionary _shaderTypes = []; + private uint _nextName = 1; + + public string? ThrowOn { get; set; } + public bool VertexCompiles { get; init; } = true; + public bool FragmentCompiles { get; init; } = true; + public bool ProgramLinks { get; init; } = true; + public List CreatedShaders { get; } = []; + public List CreatedPrograms { get; } = []; + public List DeleteShaderAttempts { get; } = []; + public List DeletedShaders { get; } = []; + public List DeletedPrograms { get; } = []; + public List DetachCalls { get; } = []; + + public uint CreateShader(ShaderType type) + { + Fail($"CreateShader:{type}"); + uint name = _nextName++; + CreatedShaders.Add(name); + _shaderTypes.Add(name, type); + return name; + } + + public void ShaderSource(uint shader, string source) => + Fail($"ShaderSource:{TypeOf(shader)}"); + + public void CompileShader(uint shader) => + Fail($"CompileShader:{TypeOf(shader)}"); + + public int GetShaderCompileStatus(uint shader) => TypeOf(shader) switch + { + ShaderType.VertexShader => VertexCompiles ? 1 : 0, + ShaderType.FragmentShader => FragmentCompiles ? 1 : 0, + _ => 0, + }; + + public string GetShaderInfoLog(uint shader) => $"bad {TypeOf(shader)}"; + + public uint CreateProgram() + { + Fail("CreateProgram"); + uint name = _nextName++; + CreatedPrograms.Add(name); + return name; + } + + public void AttachShader(uint program, uint shader) => + Fail($"AttachShader:{TypeOf(shader)}"); + + public void LinkProgram(uint program) => Fail("LinkProgram"); + public int GetProgramLinkStatus(uint program) => ProgramLinks ? 1 : 0; + public string GetProgramInfoLog(uint program) => "bad link"; + + public void DetachShader(uint program, uint shader) + { + string operation = $"DetachShader:{TypeOf(shader)}"; + DetachCalls.Add(operation); + Fail(operation); + } + + public void DeleteShader(uint shader) + { + DeleteShaderAttempts.Add(shader); + Fail($"DeleteShader:{TypeOf(shader)}"); + DeletedShaders.Add(shader); + } + + public void DeleteProgram(uint program) + { + Fail("DeleteProgram"); + DeletedPrograms.Add(program); + } + + private ShaderType TypeOf(uint shader) => _shaderTypes[shader]; + + private void Fail(string operation) + { + if (ThrowOn == operation) + throw new InvalidOperationException(operation + " failed"); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs index d81a56f3..cacabedd 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -361,7 +361,7 @@ public sealed class WorldRenderFrameBuilderTests "var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(", "var worldRenderFrameBuilder =", "new AcDream.App.Rendering.RenderFrameOrchestrator(", - "_renderFrameOrchestrator = null;", + "_frameGraphs.Withdraw();", "new(\"equipped children\"", "new(\"effect network state\"", "new(\"audio\"", diff --git a/tests/AcDream.App.Tests/UI/RetailUiRuntimeLeaseTests.cs b/tests/AcDream.App.Tests/UI/RetailUiRuntimeLeaseTests.cs new file mode 100644 index 00000000..8bbb7088 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/RetailUiRuntimeLeaseTests.cs @@ -0,0 +1,352 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public sealed class RetailUiRuntimeLeaseTests +{ + [Fact] + public void ConstructorFailureLeavesHostOwnedUntilLifetimeDisposal() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + + Assert.Throws(() => + lease.MountCore( + () => throw new InvalidOperationException("constructor failed"), + static runtime => runtime.Initialize(), + static runtime => runtime.Dispose(), + static runtime => runtime.IsDisposalComplete)); + + Assert.True(lease.RetainsResources); + Assert.Equal(0, host.DisposeCalls); + + lease.Dispose(); + lease.Dispose(); + + Assert.True(lease.IsDisposalComplete); + Assert.False(lease.RetainsResources); + Assert.Equal(1, host.DisposeCalls); + } + + [Fact] + public void InitializeFailureCleansRuntimeAndHostExactlyOnce() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + var runtime = new FakeRuntime(host) { InitializeFailure = true }; + + InvalidOperationException failure = Assert.Throws(() => + Mount(lease, runtime)); + + Assert.Equal("initialize failed", failure.Message); + Assert.True(lease.IsDisposalComplete); + Assert.False(lease.RetainsResources); + Assert.Equal(1, runtime.DisposeCalls); + Assert.Equal(1, host.DisposeCalls); + } + + [Fact] + public void TransientInitializeCleanupFailureRetainsOwnershipForLifetimeRetry() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + var runtime = new FakeRuntime(host) + { + InitializeFailure = true, + RemainingDisposeFailures = 1, + }; + + AggregateException failure = Assert.Throws(() => + Mount(lease, runtime)); + + Assert.Equal(2, failure.InnerExceptions.Count); + Assert.True(lease.RetainsResources); + Assert.False(lease.IsDisposalComplete); + Assert.Equal(1, runtime.DisposeCalls); + Assert.Equal(0, host.DisposeCalls); + + lease.Dispose(); + + Assert.True(lease.IsDisposalComplete); + Assert.False(lease.RetainsResources); + Assert.Equal(2, runtime.DisposeCalls); + Assert.Equal(1, host.DisposeCalls); + } + + [Fact] + public void PersistentCleanupFailureCanBeExplicitlyAbandonedAndBecomesInert() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + var runtime = new FakeRuntime(host) + { + InitializeFailure = true, + RemainingDisposeFailures = int.MaxValue, + }; + + Assert.Throws(() => Mount(lease, runtime)); + Assert.Throws(lease.Dispose); + Assert.Throws(() => + new RetailUiRuntimeLease().AbandonAfterTerminalFailure()); + + lease.AbandonAfterTerminalFailure(); + lease.Dispose(); + lease.QuiesceInput(); + lease.DeactivateInput(); + + Assert.True(lease.IsAbandoned); + Assert.False(lease.IsDisposalComplete); + Assert.True(lease.RetainsResources); + Assert.Equal(2, runtime.DisposeCalls); + Assert.Equal(0, host.DisposeCalls); + } + + [Fact] + public void QuiesceAndDeactivateAreSeparateIdempotentOperations() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + + lease.QuiesceInput(); + lease.QuiesceInput(); + lease.DeactivateInput(); + lease.DeactivateInput(); + + Assert.Equal(1, host.QuiesceCalls); + Assert.Equal(1, host.DeactivateCalls); + Assert.Equal(0, host.DisposeCalls); + + lease.Dispose(); + + Assert.Equal(1, host.DisposeCalls); + } + + [Fact] + public void HostAndRuntimeMayOnlyBePublishedOnce() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + var runtime = new FakeRuntime(host); + + Assert.Throws(() => AcquireHost(lease)); + Assert.Same(runtime, Mount(lease, runtime)); + Assert.Throws(() => + Mount(lease, new FakeRuntime(host))); + + lease.Dispose(); + } + + [Fact] + public void ReentrantHostAndRuntimeFactoriesCannotOverwriteOuterOwnership() + { + var lease = new RetailUiRuntimeLease(); + var host = new FakeHost(); + int nestedHosts = 0; + FakeHost acquired = lease.AcquireHostCore( + () => + { + Assert.Throws(() => + lease.AcquireHostCore( + () => + { + nestedHosts++; + return new FakeHost(); + }, + static value => value.QuiesceInput(), + static value => value.DeactivateInput(), + static value => value.Dispose(), + static value => value.IsDisposalComplete)); + return host; + }, + static value => value.QuiesceInput(), + static value => value.DeactivateInput(), + static value => value.Dispose(), + static value => value.IsDisposalComplete); + var runtime = new FakeRuntime(host); + int nestedRuntimes = 0; + + FakeRuntime mounted = lease.MountCore( + () => + { + Assert.Throws(() => + lease.MountCore( + () => + { + nestedRuntimes++; + return new FakeRuntime(host); + }, + static value => value.Initialize(), + static value => value.Dispose(), + static value => value.IsDisposalComplete)); + return runtime; + }, + static value => value.Initialize(), + static value => value.Dispose(), + static value => value.IsDisposalComplete); + + Assert.Same(host, acquired); + Assert.Same(runtime, mounted); + Assert.Equal(0, nestedHosts); + Assert.Equal(0, nestedRuntimes); + lease.Dispose(); + } + + [Fact] + public void ReentrantDisposeIsRejectedWithoutDoubleDisposingRuntimeOrHost() + { + var lease = new RetailUiRuntimeLease(); + var host = AcquireHost(lease); + var runtime = new FakeRuntime(host) + { + OnDispose = () => + Assert.Throws(lease.Dispose), + }; + Mount(lease, runtime); + + lease.Dispose(); + + Assert.Equal(1, runtime.DisposeCalls); + Assert.Equal(1, host.DisposeCalls); + Assert.True(lease.IsDisposalComplete); + } + + [Fact] + public void HostFactoryCannotPrematurelyCompleteInputCutoffOperations() + { + var lease = new RetailUiRuntimeLease(); + var host = new FakeHost(); + + lease.AcquireHostCore( + () => + { + Assert.Throws(lease.QuiesceInput); + Assert.Throws(lease.DeactivateInput); + return host; + }, + static value => value.QuiesceInput(), + static value => value.DeactivateInput(), + static value => value.Dispose(), + static value => value.IsDisposalComplete); + + lease.QuiesceInput(); + lease.DeactivateInput(); + + Assert.Equal(1, host.QuiesceCalls); + Assert.Equal(1, host.DeactivateCalls); + lease.Dispose(); + } + + [Fact] + public void InputCutoffRejectsReentrantMutationAndRetriesFailedCallback() + { + var lease = new RetailUiRuntimeLease(); + var host = new FakeHost(); + int quiesceAttempts = 0; + int deactivateAttempts = 0; + lease.AcquireHostCore( + () => host, + _ => + { + quiesceAttempts++; + Assert.Throws(() => + lease.MountCore( + () => new FakeRuntime(host), + static value => value.Initialize(), + static value => value.Dispose(), + static value => value.IsDisposalComplete)); + if (quiesceAttempts == 1) + throw new InvalidOperationException("quiesce failed"); + host.QuiesceInput(); + }, + _ => + { + deactivateAttempts++; + Assert.Throws(lease.Dispose); + if (deactivateAttempts == 1) + throw new InvalidOperationException("deactivate failed"); + host.DeactivateInput(); + }, + static value => value.Dispose(), + static value => value.IsDisposalComplete); + + Assert.Throws(lease.QuiesceInput); + lease.QuiesceInput(); + Assert.Throws(lease.DeactivateInput); + lease.DeactivateInput(); + + Assert.Equal(2, quiesceAttempts); + Assert.Equal(2, deactivateAttempts); + Assert.Equal(1, host.QuiesceCalls); + Assert.Equal(1, host.DeactivateCalls); + lease.Dispose(); + } + + private static FakeHost AcquireHost(RetailUiRuntimeLease lease) + { + var host = new FakeHost(); + return lease.AcquireHostCore( + () => host, + static value => value.QuiesceInput(), + static value => value.DeactivateInput(), + static value => value.Dispose(), + static value => value.IsDisposalComplete); + } + + private static FakeRuntime Mount( + RetailUiRuntimeLease lease, + FakeRuntime runtime) => lease.MountCore( + () => runtime, + static value => value.Initialize(), + static value => value.Dispose(), + static value => value.IsDisposalComplete); + + private sealed class FakeHost : IDisposable + { + public int QuiesceCalls { get; private set; } + public int DeactivateCalls { get; private set; } + public int DisposeCalls { get; private set; } + public bool IsDisposalComplete { get; private set; } + + public void QuiesceInput() => QuiesceCalls++; + + public void DeactivateInput() => DeactivateCalls++; + + public void Dispose() + { + if (IsDisposalComplete) + return; + + DisposeCalls++; + IsDisposalComplete = true; + } + } + + private sealed class FakeRuntime(FakeHost host) : IDisposable + { + public bool InitializeFailure { get; init; } + public int RemainingDisposeFailures { get; set; } + public int DisposeCalls { get; private set; } + public bool IsDisposalComplete { get; private set; } + public Action? OnDispose { get; init; } + + public void Initialize() + { + if (InitializeFailure) + throw new InvalidOperationException("initialize failed"); + } + + public void Dispose() + { + if (IsDisposalComplete) + return; + + DisposeCalls++; + OnDispose?.Invoke(); + if (RemainingDisposeFailures-- > 0) + throw new InvalidOperationException("dispose failed"); + + host.Dispose(); + IsDisposalComplete = true; + } + } +} diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 6f11f50d..d13290af 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -362,7 +362,7 @@ public sealed class UpdateFrameOrchestratorTests Assert.DoesNotContain("_streamingFrame", source, StringComparison.Ordinal); Assert.Equal(1, CountOccurrences( source, - "_updateFrameOrchestrator.Tick(")); + "_frameGraphs.Tick(")); Assert.DoesNotContain("_streamingController.Tick(observerCx", source, StringComparison.Ordinal); Assert.DoesNotContain("DungeonStreamingGate.Compute", source, @@ -650,7 +650,7 @@ public sealed class UpdateFrameOrchestratorTests "GameWindow.cs")); Assert.Equal(1, CountOccurrences( source, - "_updateFrameOrchestrator.Tick(")); + "_frameGraphs.Tick(")); Assert.DoesNotContain("CanAdvanceLocalPlayer", source, StringComparison.Ordinal); Assert.DoesNotContain("GetCombatCameraTargetPoint()", source, StringComparison.Ordinal); @@ -686,7 +686,7 @@ public sealed class UpdateFrameOrchestratorTests Assert.Equal(1, CountOccurrences( source, - "_updateFrameOrchestrator.Tick(")); + "_frameGraphs.Tick(")); Assert.DoesNotContain("_updateFrameClock.Advance(", source, StringComparison.Ordinal); Assert.DoesNotContain("_liveFrameCoordinator", source, @@ -699,7 +699,7 @@ public sealed class UpdateFrameOrchestratorTests source, "private void OnUpdate(double dt)", "_frameProfiler.BeginStage(", - "_updateFrameOrchestrator.Tick(", + "_frameGraphs.Tick(", "private void OnRender(double deltaSeconds)"); }