From e95f55f25b260967caffef2d418c35015915b4bf Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 16 Jul 2026 21:17:13 +0200 Subject: [PATCH] fix(portal): synchronize destination presentation state --- docs/ISSUES.md | 70 +++ .../retail-divergence-register.md | 2 +- docs/architecture/worldbuilder-inventory.md | 28 ++ docs/plans/2026-04-11-roadmap.md | 3 +- docs/plans/2026-05-12-milestones.md | 31 ++ .../wf2-sky-weather-scenery.md | 2 +- ...2026-07-16-portal-completion-pseudocode.md | 228 +++++++++ .../Input/PlayerMovementController.cs | 4 +- src/AcDream.App/Rendering/GameWindow.cs | 144 +++--- .../Rendering/Wb/EntityClassificationCache.cs | 15 +- .../Rendering/Wb/IWbMeshAdapter.cs | 15 + .../Rendering/Wb/LandblockSpawnAdapter.cs | 73 ++- .../Rendering/Wb/MeshUploadCaches.cs | 117 +++++ .../Rendering/Wb/ObjectMeshManager.cs | 122 ++--- src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 25 +- src/AcDream.App/Streaming/GpuWorldState.cs | 91 +++- .../Streaming/LandblockStreamJob.cs | 42 +- .../Streaming/LandblockStreamer.cs | 27 +- .../Streaming/StreamingController.cs | 301 ++++++++++-- src/AcDream.App/Streaming/StreamingRegion.cs | 19 + .../Physics/AnimationSequencer.cs | 17 +- src/AcDream.Core/Physics/PhysicsBody.cs | 50 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 36 ++ .../Physics/ShadowObjectRegistry.cs | 76 ++- src/AcDream.Core/World/Cells/CellGraph.cs | 11 + src/AcDream.Core/World/LandblockLoader.cs | 4 +- .../World/ProceduralSceneryIdAllocator.cs | 39 ++ .../World/TeleportAnimSequencer.cs | 3 +- .../Wb/LandblockSpawnAdapterReadinessTests.cs | 104 ++++ .../Rendering/Wb/MeshUploadCachesTests.cs | 69 +++ .../StreamingControllerReadinessTests.cs | 437 +++++++++++++++++ .../World/RecallTeleportAnimationTests.cs | 58 +-- .../Physics/PhysicsBodyCellSyncTests.cs | 53 ++- .../Physics/PhysicsEngineResidencyTests.cs | 120 +++++ .../Physics/ShadowObjectRegistryTests.cs | 126 +++++ .../Streaming/LandblockStreamerTests.cs | 6 +- .../StreamingControllerDungeonGateTests.cs | 16 + .../StreamingControllerPriorityApplyTests.cs | 443 +++++++++++++++++- .../Streaming/StreamingControllerTests.cs | 7 +- .../StreamingControllerTwoTierTests.cs | 2 +- .../World/Cells/CellGraphTests.cs | 13 + .../ProceduralSceneryIdAllocatorTests.cs | 54 +++ 42 files changed, 2815 insertions(+), 288 deletions(-) create mode 100644 docs/research/2026-07-16-portal-completion-pseudocode.md create mode 100644 src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs create mode 100644 src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/LandblockSpawnAdapterReadinessTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs create mode 100644 tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs create mode 100644 tests/AcDream.Core.Tests/World/ProceduralSceneryIdAllocatorTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index ca2ed2b7..c21f9cf1 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -44,6 +44,76 @@ Copy this block when adding a new issue: --- +## #218 — Portal silhouette pose, destination reveal, and indoor observer snap + +**Status:** IN-PROGRESS — root fixes implemented 2026-07-16; pending connected visual gate +**Severity:** HIGH +**Filed:** 2026-07-16 +**Component:** portal VFX / streaming / physics / outbound movement + +**Description:** The purple player silhouette in portal space sampled the tail +of the recall action instead of the finished Ready pose. Some outdoor exits +briefly revealed grey/unloaded landblocks during the view-plane zoom. A retail +observer watching acdream enter a dungeon saw movement while commands were +active, but stopping snapped acdream back to the dungeon entrance. + +**Root cause / status:** These were three lifecycle truths hidden by the same +portal gate. Hidden entities skipped part-pose composition entirely, so +`HandleEnterWorld` changed the sequence cursor to Ready without publishing that +pose before the zero-time Hidden PES emitted the silhouette. Portal readiness +checked collision residency but not actual mesh/texture render readiness; the +captured destination additionally failed whole landblock builds when dense +procedural scenery exceeded the obsolete 256-id namespace. Finally, +`PhysicsBody.CellPosition` advanced only outdoors: indoor resolves updated the +controller cell but left the canonical outbound frame at the portal entrance. + +The implementation now samples Hidden sequence poses without advancing time, +joins render and collision readiness before world reveal, uses a collision-free +`0x8XXYYIII` 4,096-entry scenery namespace, and commits every successful indoor +transition's full cell/frame before movement serialization. Render readiness +tracks both static GfxObjs and EnvCell shell geometry through completed WB +uploads and requires the complete priority ring to be Near-tier. Stale worker +completions carry a hard-recenter generation, so even overlapping old loads and +unloads cannot overwrite the replacement window. A Near load demoted before +its first publication is converted to the equivalent terrain-only Far payload +instead of leaving a hole. Mesh upload no longer manufactures a second logical +owner; static and synthetic EnvCell geometry pins release symmetrically, while +zero-owner late uploads stay evictable. EnvCell publication replays its +schema-aware preparation request after those pins are installed, closing an +eviction race without routing synthetic ids through generic GfxObj decode. The +bounded CPU cache retains texture payloads and re-stages evicted meshes exactly +once instead of returning a never-uploaded or blank cache hit. A self-contained +promotion that supersedes a queued Far load now publishes directly as a real +Near landblock, so readiness cannot wait forever for a base the streamer +intentionally cancelled; later Far completion cannot overwrite that Near tier. +Near-to-Far now tears down the full Near-only App/Core layer while preserving +terrain, and repeated current-generation Near completions are ignored before +they can duplicate statics, scripts, pins, or callbacks. Static collision is +removed by owner before prefix cleanup, including footprints flooded across a +landblock seam, while server-live/dynamic registrations remain refloodable. +Full unload uses the same owner rule; surviving owners seeded across the seam +track withdrawn prefixes so a later reload restores their missing collision +rows. + +**Files:** `src/AcDream.Core/Physics/AnimationSequencer.cs`; +`src/AcDream.App/Rendering/GameWindow.cs`; +`src/AcDream.App/Streaming/StreamingController.cs`; +`src/AcDream.App/Streaming/GpuWorldState.cs`; +`src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; +`src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs`; +`src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs`; +`src/AcDream.Core/Physics/PhysicsBody.cs`; +`src/AcDream.App/Input/PlayerMovementController.cs`. + +**Research:** `docs/research/2026-07-16-portal-completion-pseudocode.md`. + +**Acceptance:** Recall/portal Hidden particles outline the finished standing +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. + +--- + ## #217 — Character windows did not receive live 64-bit experience updates **Status:** DONE — 2026-07-13, connected gate user-confirmed diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 60658508..11bbbba7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -66,7 +66,7 @@ accepted-divergence entries (#96, #49, #50). | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | 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 spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted). **#145 refinement (2026-06-22):** an OUTDOOR teleport additionally requires the destination's OWN landblock terrain to be resident (`PhysicsEngine.IsLandblockTerrainResident(destCell)`, which `StreamingController` priority-applies so it flips in ~hundreds of ms) before placing — and `SampleTerrainZ` is short-circuited behind it. On a teleport OUT of a dungeon the collapsed SOURCE dungeon stays resident at the same streaming-local coords and answers `SampleTerrainZ` (terrain 0), so without the load check the resolve ran against the dungeon's cells and rooted the player at a dungeon-frame cell id (ACE then rejected all movement as "failed transition"). Decision in `TeleportWorldReady`; the hold→materialize→regain-control transit is driven by `TeleportAnimSequencer` (the retail portal/view-plane lifecycle, ticked in `OnUpdate`), which replaced the retired `TeleportArrivalController` | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportWorldReady` ~5512 + the TAS transit tick in `OnUpdate`) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) | +| 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-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/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index ccbae299..a3afba1a 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -125,6 +125,27 @@ before installing retail's distant light. The lifecycle, camera, animation, and draw ordering are acdream-owned ports of `gmSmartBoxUI`; WorldBuilder never implemented this UI viewport. +**Portal destination render-readiness seam (2026-07-16).** +`GpuWorldState.IsRenderReady` does not treat dictionary publication as a draw +barrier. `LandblockSpawnAdapter` retains the complete required-id set for each +landblock: atlas-tier GfxObjs plus the synthetic geometry ids prepared by the +independent EnvCell shell pipeline. `WbMeshAdapter.IsRenderDataReady` opens the +gate only after `ObjectMeshManager` has real GPU render data. Its bounded CPU +cache retains texture payloads and stages a missing GPU object through a +deduplicated upload queue on cache hit, covering eviction and revisit churn. +GPU upload is deliberately not an ownership acquire: atlas GfxObjs use their +landblock/entity pins, while synthetic EnvCell geometry uses a no-generic-decode +pin balanced from each landblock snapshot. A late upload after all owners have +released enters the evictable LRU instead of resurrecting a reference. After +publication pins the synthetic ids, the controller replays their immutable +environment/cell-structure/surface preparation descriptors; if a formerly +unowned mesh was evicted between worker scheduling and publication, this +schema-aware replay re-stages it without a generic GfxObj lookup. +Near-to-Far demotion is a separate App transaction: it releases EnvCell +rendering and landblock mesh pins while retaining the terrain slot. Core's +matching physics demotion preserves the terrain surface but removes indoor +cells, portals, buildings, and static shadow registrations. + **Workflow:** Before re-implementing any AC-specific rendering or dat-handling algorithm, **check this inventory first**. If we already extracted it (🟢 sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has @@ -194,6 +215,13 @@ Retail `TexMerge::CopyAndTile` (`0x00503580`) and `TexMerge::Merge` | `SceneryHelpers` | Displace / RotateObj / ScaleObj / ObjAlign / CheckSlope | | `SceneryInstance` | Per-spawn instance data | +acdream's streamed projection assigns generated instances local runtime IDs +through `AcDream.Core.World.ProceduralSceneryIdAllocator`. The namespace is +`0x8XXYYIII` (full X/Y bytes plus a 12-bit counter); bit 31 remains the stable +scenery classification seam. This is projection identity, not placement +behavior. The former 8-bit counter rejected dense retail-DAT landblocks before +their render transaction could publish (#218). + ### Static objects (buildings, slabs, props — Setup + GfxObj + ObjDesc) | Component | What it does | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 9fba3b24..c1517b2c 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -27,7 +27,8 @@ active enchantment, then recall through the DAT-driven portal presentation. 3. **Pending user visual gate:** connected projectile/self-buff casts plus the complete retained magic UI interaction checklist. 4. **Pending user visual gate:** two-client portal-out/materialization observer - presentation before M3 closes. + presentation before M3 closes, 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. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index e9e84748..114d1cb7 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -332,6 +332,37 @@ the Lifestone recall boundary and a MagicPortal casting timeline. The user confirmed `/ls` and spell-recall arrivals no longer replay their animation tails; additional portal visual polish is explicitly deferred. +**2026-07-16 corrective portal gate (#218).** A side-by-side retail observer +check found three distinct state-publication defects after the arrival-tail +fix. Hidden now recomposes the post-`HandleEnterWorld` cyclic pose without +advancing animation time before its zero-time purple PES hooks run. Portal +space now waits for both completed static/EnvCell mesh uploads (including the +texture flush) and collision residency; dense +outdoor landblocks no longer abort at the obsolete 256-entry procedural +scenery id boundary (`0x8XXYYIII`, 4,096 entries). Finally, successful indoor +transitions commit the full resolved EnvCell/frame into the canonical outbound +Position, preventing stop-time snaps to the dungeon entrance on retail +observers. GPU-evicted meshes are re-staged from a bounded texture-complete CPU +cache instead of becoming false-ready or permanently absent. Portal readiness +also requires Near-tier ownership throughout the ring; stale worker completions +carry a hard-recenter generation and cannot overwrite a newer tier/window, and +a first Near completion demoted in flight is published as its equivalent +terrain-only Far payload rather than dropped. Upload completion is separate +from logical mesh ownership; landblock/static/EnvCell pins now balance across +unload and zero-owner late uploads remain evictable. A self-contained promotion +that supersedes a queued Far load publishes directly as a real Near landblock; +there is no provisional transaction waiting indefinitely for a base that the +streamer intentionally cancelled. Near-to-Far now has an explicit cross-layer +demotion transaction that preserves terrain while retiring EnvCells, indoor +physics/buildings, static collision, lights, translucency state, entities, and +render pins. Cross-landblock static shadow footprints are removed by logical +owner while dynamic registrations remain refloodable; duplicate Near +completions are publication-idempotent. Full unload shares the static-owner +teardown, and surviving adjacent owners remember withdrawn prefixes so their +cross-seam rows return when a landblock is promoted again. +Automated gates are green; the connected single-/two-client visual gate is +pending. + --- #### (historical M1.5 working notes below) diff --git a/docs/research/2026-06-11-holistic-map/wf2-sky-weather-scenery.md b/docs/research/2026-06-11-holistic-map/wf2-sky-weather-scenery.md index 0b711515..cd787889 100644 --- a/docs/research/2026-06-11-holistic-map/wf2-sky-weather-scenery.md +++ b/docs/research/2026-06-11-holistic-map/wf2-sky-weather-scenery.md @@ -24,7 +24,7 @@ OUTSIDE SLICE — DrawLandscapeThroughOutsideView (RetailPViewRenderer.cs:214-23 SKYRENDERER — src/AcDream.App/Rendering/Sky/SkyRenderer.cs (WB SkyboxRenderManager port): RenderSky draws the pre-scene partition (Properties bit 0x01 clear), RenderWeather the post-scene partition (bit set) (:106-144, 219-235 — correct retail bit semantics, citing MakeObject 0x00506ee0). GL state: depth test OFF + depth mask OFF + cull off + per-submesh additive/alpha blend (:194-210) — equivalent to retail's DEPTHTEST_ALWAYS/no-write. View translation zeroed = camera-centred sky (:175-178). Weather −120 offset applied as a CAMERA-RELATIVE model translation for bit4 && !bit8 objects (:307-308). Fog-override bit 0x02 skip implemented (:240-241). sky.vert writes gl_ClipDistance from the terrain-clip UBO (src/AcDream.App/Rendering/Shaders/sky.vert:153) so the doorway slices genuinely clip sky and rain. -SCENERY — SceneryGenerator.Generate (src/AcDream.Core/World/SceneryGenerator.cs:86 via WbSceneryAdapter) → GameWindow.BuildSceneryEntitiesForStreaming (GameWindow.cs:5290-5473): scenery becomes WorldEntity ids 0x80XXYYII with MeshRefs and NO ParentCellId (5463-5472) → lands in the Outdoor partition bucket → drawn once per outside slice with per-entity frustum cull + slice clip planes. Building suppression at generation uses a 9×9 vertex-grid set derived from building origins (5310-5316). There are no per-LandCell object lists outdoors — the bucket is flat. +SCENERY — SceneryGenerator.Generate (src/AcDream.Core/World/SceneryGenerator.cs:86 via WbSceneryAdapter) → GameWindow.BuildSceneryEntitiesForStreaming (GameWindow.cs:5290-5473): scenery becomes WorldEntity ids 0x8XXYYIII (bit-31 class + X8/Y8/counter12) with MeshRefs and NO ParentCellId (5463-5472) → lands in the Outdoor partition bucket → drawn once per outside slice with per-entity frustum cull + slice clip planes. Building suppression at generation uses a 9×9 vertex-grid set derived from building origins (5310-5316). There are no per-LandCell object lists outdoors — the bucket is flat. ## DIVERGENCES diff --git a/docs/research/2026-07-16-portal-completion-pseudocode.md b/docs/research/2026-07-16-portal-completion-pseudocode.md new file mode 100644 index 00000000..cbf0b501 --- /dev/null +++ b/docs/research/2026-07-16-portal-completion-pseudocode.md @@ -0,0 +1,228 @@ +# Portal completion: hidden pose, destination residency, and indoor position + +This note records the retail mechanisms behind three connected portal defects +observed on 2026-07-16. The named Sept-2013 retail client is the behavioral +oracle. The render-residency portion is an acdream adaptation because retail +loads cells synchronously while acdream publishes streamed landblocks +asynchronously. + +## 1. Hidden transition samples the post-link cyclic pose + +Named retail references: + +- `CPhysicsObj::set_hidden` at `0x00514C60` +- `CPartArray::HandleEnterWorld` at `0x00517D70` +- `MotionTableManager::HandleEnterWorld` at `0x0051BDD0` +- `CSequence::remove_all_link_animations` at `0x00524CA0` +- `CPhysicsObj::UpdateObjectInternal` at `0x005156B0` +- `CPhysicsObj::UpdatePositionInternal` at `0x00512C30` +- `CPhysicsObj::set_frame` at `0x00514090` +- `CPartArray::SetFrame` at `0x00519310` +- `CPartArray::UpdateParts` at `0x005190F0` + +Installed retail DAT confirmation: + +- Lifestone Recall `0x10000153` resolves to animation `0x030009BF`. +- Its ACE-duration boundary is `(150 - 0) / 9.96 = 15.06024096 s`. +- At that exact boundary the non-looping recall node remains at its final + authored frame (approximately frame 150); it has not advanced the cyclic + Ready node yet. +- Hidden PES `0x33000331` contains fourteen `CreateParticle` hooks and one + translucency hook, all at time zero. The pose published when Hidden starts + therefore determines the purple player silhouette. + +Retail pseudocode: + +```text +SetState(visible -> Hidden): + set_hidden(true) + play typed Hidden PES + part_array.HandleEnterWorld() + +MotionTableManager.HandleEnterWorld(sequence): + sequence.remove_all_link_animations() + +sequence.remove_all_link_animations(): + discard link/action nodes + if the current node was the final link before the cyclic tail: + current = first_cyclic + frame = first_cyclic.starting_frame + +next hidden CPhysicsObj.UpdateObjectInternal: + do not advance PartArray animation time + UpdatePositionInternal(...) + later set_frame(current physics frame) + PartArray.SetFrame(frame) + PartArray.UpdateParts(frame) + sample sequence.get_curr_animframe() + ScriptManager executes the zero-time Hidden PES hooks +``` + +Port rule: Hidden suppresses time advance, not pose composition. After +`HandleEnterWorld` changes the sequence cursor, acdream must sample the current +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 + +Named retail references: + +- `gmSmartBoxUI::BeginTeleportAnimation` at `0x004D6300` +- `gmSmartBoxUI::EndTeleportAnimation` at `0x004D65A0` +- `gmSmartBoxUI::UseTime` at `0x004D6E30` +- `SmartBox::teleport_in_progress` at `0x00451C20` +- `SmartBox::UseTime` at `0x00455410` + +Retail performs the destination load behind the portal-space viewport. +`SmartBox::teleport_in_progress` remains true while the player exists and +`position_update_complete == 0`; `SmartBox::UseTime` only advances that +position completion when `blocking_for_cells == 0`. When the SmartBox reports +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. + +acdream's load is split into two independent readiness domains: + +```text +worker parses/builds landblock + -> render thread uploads terrain/entities + -> GpuWorldState.AddLandblock(canonical id) + -> WbMeshAdapter.Tick uploads every required static/EnvCell mesh + and flushes its staged texture-array updates // render readiness + +worker/register path + -> PhysicsEngine registers terrain or EnvCell // collision publication +``` + +Required asynchronous equivalent: + +```text +TeleportWorldReady(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 EnvCell collision data ready + + return every landblock in the priority near ring Near-tier and render-ready + AND every landblock in that ring collision-resident +``` + +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 +overlaps the replacement region, and rejects a completed old unload that would +otherwise tear down new state. Within one generation, a stale Far completion +cannot overwrite a published or pending Near landblock. If an initial Near load +finishes only after the region has demoted that still-unpublished landblock to +Far, its already-built heightmap and mesh are published through a new +terrain-only `LandblockBuild` (empty entity list, no EnvCell transaction, empty +physics bundle). This is byte-for-behavior the payload a fresh `LoadFar` would +build, without a second DAT read or a missing terrain slot. + +GPU upload existence is not logical ownership. Landblock registrations pin +static GfxObjs; EnvCell transactions use a distinct no-decode pin for their +synthetic shell ids. Upload completion preserves that owner count rather than +incrementing it. Unload releases the matching per-landblock snapshots, and a +late upload whose owners already released enters the evictable LRU immediately. +Because an unowned EnvCell mesh can be evicted between the worker's first +schedule and landblock publication, publication pins every synthetic id first +and then replays the immutable environment/cell-structure/surface request. +The second schema-aware call is a no-op while data remains resident and a +correct CPU-cache restage or specialized decode when it does not. +`PromoteToNear` carries a complete build and terrain mesh because the streamer +allows it to supersede a queued Far load. It therefore publishes directly as a +real Near landblock when no base exists, installing EnvCells, collision, lights, +registries, entities, and render pins in one transaction. If a Far load had +already started, its later completion cannot overwrite the published Near tier. +Normal demotion and unload then own the same teardown path as any other Near +landblock. Demotion explicitly retires EnvCell visibility/rendering, indoor +cell and building physics, static shadow objects, lights, translucency owners, +static entities, and mesh pins while preserving the terrain render slot and +terrain physics surface. Static shadow owners are deregistered by their seed +landblock before cell-prefix removal, so footprints flooded across a landblock +seam cannot survive as invisible collision; dynamic owners remain refloodable. +Full unload uses the same owner teardown. For owners seeded in an adjacent +resident block, the registry remembers which streamed-out prefix withdrew +their cross-seam rows and includes them when that prefix refloods, restoring +collision on promotion without resurrecting a removed owner. +Duplicate same-generation Near completions are ignored +at the publication owner, so they cannot append statics or replay scripts. +There is no provisional side state waiting for a base that may never arrive. + +`GpuWorldState.IsRenderReady` joins state publication with +`LandblockSpawnAdapter`'s required-mesh set. That set contains atlas-tier +GfxObjs and the synthetic geometry ids used by EnvCell shells, and it remains +false until `WbMeshAdapter.TryGetRenderData` succeeds for every id after a +render-thread tick and texture flush. A timer or black fade is not a substitute. + +GPU eviction retains a bounded re-uploadable CPU mesh payload. A cache hit +re-stages that payload exactly once, preserving texture bytes, so revisit and +portal churn cannot strand the readiness gate behind a CPU-only cache entry. +Failed uploads retain queue ownership only through their bounded loud retry +sequence; they cannot create duplicate uploads or a false-ready landblock. + +The 2026-07-16 trace also found that dense outdoor landblocks failed before +publication: the procedural scenery id allocator reserved only 256 ids per +landblock although the retail DAT generator can produce more. The stable local +id namespace therefore uses the same collision-free field allocation already +used by interior statics: + +```text +procedural scenery id = 0x8XXYYIII + class prefix: 4 bits (0x8) + landblock X: 8 bits + landblock Y: 8 bits + per-landblock counter: 12 bits (0..4095) +``` + +All scenery consumers classify on bit 31 and no consumer decodes the old byte +layout. Overflow must fail before aliasing the next landblock. + +WorldBuilder cross-check: its upload queue similarly distinguishes generated +CPU scenery from render-thread GPU publication (`GameScene.ProcessUploads`), +which confirms that worker completion alone is not draw readiness. + +## 3. Successful indoor transitions commit the canonical outbound Position + +Named retail references: + +- `CPhysicsObj::SetPositionInternal(CTransition const*)` at `0x00515330` +- `CPhysicsObj::UpdateObjectInternal` at `0x005156B0` +- `CommandInterpreter::SendMovementEvent` at `0x006B4680` +- `CommandInterpreter::SendPositionEvent` at `0x006B4770` +- `CommandInterpreter::ShouldSendPositionEvent` at `0x006B45E0` + +Retail pseudocode: + +```text +after a successful transition: + if transition.curr_cell == current cell: + m_position.objcell_id = transition.curr_pos.objcell_id + else: + change_cell(transition.curr_cell) + set_frame(transition.curr_pos.frame) + +SendMovementEvent: + serialize MoveToStatePack(..., player.m_position, ...) + +SendPositionEvent: + serialize AutonomousPositionPack(player.m_position, ...) +``` + +This applies equally to outdoor position cells and indoor EnvCells. The local +frame origin advances by the resolved world displacement, and a successful +transition adopts the resolver's exact destination cell id. Outdoor positions +then use `LandDefs.AdjustToOutside` to canonicalize landblock/cell boundaries; +indoor positions retain their EnvCell-relative frame and adopt the resolved +EnvCell id. + +The previous acdream behavior changed only the controller's visible `CellId` +for indoor motion. `PhysicsBody.CellPosition`, which is what outbound movement +serializes, stayed at the dungeon portal-entry frame. ACE could predict motion +while commands continued, but the next authoritative idle position returned +the retail observer to that stale frame. The correct port commits the resolved +cell and frame into the one canonical `PhysicsBody.CellPosition`; it does not +add resends, grace periods, or observer-specific correction. diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index b09f2036..4499f5b6 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -1133,7 +1133,7 @@ public sealed class PlayerMovementController body: _body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: LocalEntityId); - _body.Position = resolved.Position; + _body.CommitTransitionPosition(resolved.CellId, resolved.Position); PhysicsObjUpdate.CommitSetPositionTransition( _body, resolved.InContact, @@ -1660,7 +1660,7 @@ public sealed class PlayerMovementController bool prevContact = _body.InContact; bool prevOnWalkable = _body.OnWalkable; - _body.Position = resolveResult.Position; + _body.CommitTransitionPosition(resolveResult.CellId, resolveResult.Position); if (physicsTickRan) { _prevPhysicsPos = oldTickEndPos; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ffb432bb..76fab1d4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2714,8 +2714,8 @@ public sealed class GameWindow : IDisposable _streamer.Start(); _streamingController = new AcDream.App.Streaming.StreamingController( - enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind), - enqueueUnload: _streamer.EnqueueUnload, + enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation), + enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation), drainCompletions: _streamer.DrainCompletions, applyTerrain: ApplyLoadedTerrain, state: _worldState, @@ -2743,10 +2743,12 @@ public sealed class GameWindow : IDisposable _buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28) _envCellRenderer?.RemoveLandblock(id); // Phase A8 }, + demoteNearLayer: DemoteLoadedLandblockToTerrain, // #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: RehydrateServerEntitiesForLandblock, + ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; @@ -7058,14 +7060,12 @@ public sealed class GameWindow : IDisposable } /// - /// worldReady for the TAS transit: is the player's teleport destination resident so we - /// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell - /// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around - /// the player, ) being registered — not just the - /// single destination landblock — so portal space exits onto a loaded, collidable world - /// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no - /// "only one landblock loaded"). The streaming controller priority-applies that same ring, - /// so it flips fast. An impossible claim (indoor cell id outside the dat's NumCells) + /// 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. /// @@ -7073,9 +7073,14 @@ public sealed class GameWindow : IDisposable { if (IsSpawnClaimUnhydratable(destCell)) return true; bool indoor = (destCell & 0xFFFFu) >= 0x0100u; + int renderRadius = indoor ? 0 : TeleportNearRingRadius; + bool renderReady = _streamingController?.IsRenderNeighborhoodResident( + destCell, + renderRadius) == true; return indoor - ? _physicsEngine.IsSpawnCellReady(destCell) - : _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius); + ? renderReady && _physicsEngine.IsSpawnCellReady(destCell) + : renderReady + && _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius); } // The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only @@ -7560,6 +7565,13 @@ public sealed class GameWindow : IDisposable completedEnvCells); } + 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 @@ -7682,15 +7694,13 @@ public sealed class GameWindow : IDisposable (lbY - _liveCenterY) * 192f, 0f); - // Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF - // where XX = landblock X coord (bits 24-31), YY = Y coord (bits 16-23). - // Both must go into our ID so landblocks don't collide. - // Format: 0x80 | XX | YY | local_index(8 bits) = 0x80XXYY_II. - // 256 slots per landblock is enough (SceneryGenerator caps ~200). + // 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 sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8); - uint localIndex = 0; + uint sceneryCounter = 0; foreach (var spawn in spawns) { @@ -7836,13 +7846,12 @@ public sealed class GameWindow : IDisposable } } - if (localIndex > 0xFFu) - throw new InvalidDataException( - $"Landblock 0x{lb.LandblockId:X8} exceeds the 256-entry procedural scenery id namespace."); - var hydrated = new AcDream.Core.World.WorldEntity { - Id = sceneryIdBase + localIndex++, + 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, @@ -8135,6 +8144,31 @@ public sealed class GameWindow : IDisposable return result; } + /// + /// Retire the Near-only presentation and collision layer while preserving + /// the terrain slot/surface used by Far streaming. StreamingController calls + /// this before GpuWorldState drops static entities so owner-based resources + /// can be released exactly once. + /// + private void DemoteLoadedLandblockToTerrain(uint landblockId) + { + if (_worldState.TryGetLandblock(landblockId, out var landblock)) + { + foreach (var entity in landblock!.Entities) + { + if (entity.ServerGuid != 0) + continue; + _lightingSink?.UnregisterOwner(entity.Id); + _translucencyFades.ClearEntity(entity.Id); + } + } + + _physicsEngine.DemoteLandblockToTerrain(landblockId); + _cellVisibility.RemoveLandblock((landblockId >> 16) & 0xFFFFu); + _buildingRegistries.Remove(landblockId & 0xFFFF0000u); + _envCellRenderer?.RemoveLandblock(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. @@ -8568,7 +8602,7 @@ public sealed class GameWindow : IDisposable // 0xC0XXYY00+n layout per LandblockLoader.cs:55. Their BSP // collision covers the whole structure; the mesh-AABB-fallback // path below is for canopy-only-BSP procedural scenery - // (0x80XXYY00+n) and produces a redundant 1.5m-clamped + // (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: @@ -10746,20 +10780,15 @@ public sealed class GameWindow : IDisposable && _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false) continue; - // Retail UpdatePositionInternal keeps a narrow Hidden path alive: - // no PartArray animation and no physics integration, but - // PositionManager::adjust_offset plus the manager tail still run. - // Scripts and particles tick in their shared passes below. - if (serverGuid != 0 && _liveEntities?.IsHidden(serverGuid) == true) - { - // Hidden suppresses the PartArray/mesh, not the effect owner. - // Remote PositionManager composition already ran in the - // unconditional live-entity pass; publish again for the local - // player and for retained objects without remote motion. - // Frozen part-local poses remain unchanged until UnHide. - _effectPoses.UpdateRoot(ae.Entity); - continue; - } + // Retail Hidden suppresses PartArray TIME ADVANCE, not part-pose + // composition. set_hidden -> HandleEnterWorld can move CSequence's + // cursor from a finished link/action to the first cyclic node; the + // next hidden CPhysicsObj::UpdateObjectInternal (0x005156B0) + // performs UpdatePositionInternal and then reaches set_frame -> + // CPartArray::UpdateParts, publishing that newly-current pose before + // the zero-time Hidden PES creates its silhouette particles. + bool hidden = serverGuid != 0 + && _liveEntities?.IsHidden(serverGuid) == true; // ── Dead-reckoning: smooth position between UpdatePosition bursts. // The server broadcasts UpdatePosition at ~5-10Hz for distant @@ -10774,7 +10803,8 @@ public sealed class GameWindow : IDisposable // runaway when the sequencer's velocity and the server's reality // disagree (e.g. server is rubber-banding the entity). Retail // uses a similar clamp at PhysicsObj::IsInterpolationComplete. - if (ae.Sequencer is not null + if (!hidden + && ae.Sequencer is not null && serverGuid != 0 && serverGuid != _playerServerGuid && _remoteDeadReckon.TryGetValue(serverGuid, out var rm) @@ -10839,7 +10869,9 @@ public sealed class GameWindow : IDisposable rmDiag.LastSeqStateLogTime = nowSec; } } - seqFrames = ae.Sequencer.Advance(dt); + seqFrames = hidden + ? ae.Sequencer.SampleCurrentPose() + : ae.Sequencer.Advance(dt); // Capture hooks now, but deliver them only after every final // part transform and equipped-child root has been published. @@ -10847,21 +10879,25 @@ public sealed class GameWindow : IDisposable // CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect // reads this frame's pose, never the previous frame's pose. // AnimationDone/UseTime remain paired with the deferred drain. - _animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer); + if (!hidden) + _animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer); } else { // Legacy path (entities without a MotionTable / sequencer). int span = ae.HighFrame - ae.LowFrame; - if (span <= 0) continue; - ae.CurrFrame += dt * ae.Framerate; - if (ae.CurrFrame > ae.HighFrame) + if (span <= 0 && !hidden) continue; + if (!hidden) { - float over = ae.CurrFrame - ae.LowFrame; - ae.CurrFrame = ae.LowFrame + (over % (span + 1)); + ae.CurrFrame += dt * 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; } - else if (ae.CurrFrame < ae.LowFrame) - ae.CurrFrame = ae.LowFrame; } int partCount = ae.PartTemplate.Count; @@ -12357,8 +12393,8 @@ public sealed class GameWindow : IDisposable _streamer.Start(); _streamingController = new AcDream.App.Streaming.StreamingController( - enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind), - enqueueUnload: _streamer.EnqueueUnload, + enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation), + enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation), drainCompletions: _streamer.DrainCompletions, applyTerrain: ApplyLoadedTerrain, state: _worldState, @@ -12381,7 +12417,9 @@ public sealed class GameWindow : IDisposable _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); _buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28) _envCellRenderer?.RemoveLandblock(id); // Phase A8 - }); + }, + demoteNearLayer: DemoteLoadedLandblockToTerrain, + ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin); _streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame; Console.WriteLine( diff --git a/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs b/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs index 4023541c..925458a9 100644 --- a/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs +++ b/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs @@ -11,12 +11,11 @@ namespace AcDream.App.Rendering.Wb; /// /// Key composition: entries are keyed by the tuple /// (EntityId, LandblockHint), NOT by EntityId alone. Issue #53 -/// uncovered that entity.Id is NOT globally unique across all -/// static-entity hydration paths: scenery (0x80XXYY00 + localIndex) -/// and interior cells (0x40XXYY00 + localCounter, X-byte fixed -/// 2026-06-11 — it used to be discarded entirely, #119) overflow at >256 -/// items per landblock, wrapping into the lbY byte and producing -/// cross-LB collisions in dense forest/urban LBs outside Holtburg. Keying +/// uncovered that older hydration IDs were not globally unique across all +/// static-entity paths: their former byte-aligned counters overflowed at +/// more than 256 items per landblock and wrapped into the lbY byte. +/// The current 0x8XXYYIII scenery and 0x4XXYYIII interior +/// allocators reserve 12-bit counters and fail before aliasing. Keying /// by the tuple is correct-by-construction ONLY when the hint identifies the /// entity's OWNING landblock — callers must derive it via /// WbDrawDispatcher.ResolveCacheLandblockHint (the entity's @@ -64,8 +63,8 @@ internal sealed class EntityClassificationCache /// /// Look up an entity's cached classification. Keyed by both /// AND to - /// disambiguate entities whose Ids collide across landblocks (e.g., - /// scenery's 0x80LLBB00 + localIndex overflow at >256 items/LB). + /// preserve defensive isolation if a future hydration path introduces an + /// entity-id collision across landblocks. /// Returns true with the entry on hit; false with /// set to null on miss. /// diff --git a/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs index 3ea4853b..67ed747a 100644 --- a/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs @@ -9,4 +9,19 @@ public interface IWbMeshAdapter { void IncrementRefCount(ulong id); void DecrementRefCount(ulong id); + + /// + /// Pins render data whose CPU preparation is owned by a specialized + /// pipeline (for example synthetic EnvCell geometry). Unlike ordinary + /// registration, production implementations must not start a generic + /// GfxObj decode for this id. + /// + void PinPreparedRenderData(ulong id) => IncrementRefCount(id); + + /// + /// True once the mesh has crossed the render-thread upload barrier and is + /// available to draw. The default keeps lifecycle-only test doubles + /// source-compatible; production adapters override it. + /// + bool IsRenderDataReady(ulong id) => true; } diff --git a/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs index 43ddb689..bb89bc21 100644 --- a/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs @@ -15,13 +15,14 @@ namespace AcDream.App.Rendering.Wb; /// /// On load: walks the landblock's atlas-tier entities, collects unique /// GfxObj ids from their MeshRefs, calls -/// IncrementRefCount per id. Snapshots the id-set per landblock so -/// unload can match the load 1:1. +/// IncrementRefCount per id, and pins each specialized EnvCell geometry +/// id without starting generic GfxObj decode. Snapshots both id-sets per +/// landblock so unload can match the load 1:1. /// /// /// -/// On unload: looks up the snapshot, calls DecrementRefCount per id, -/// drops the snapshot. Unknown / never-loaded landblocks no-op. +/// On unload: looks up both snapshots, calls DecrementRefCount per id, +/// drops the snapshots. Unknown / never-loaded landblocks no-op. /// /// /// @@ -34,11 +35,9 @@ namespace AcDream.App.Rendering.Wb; /// /// /// -/// Thread safety: the underlying implementation -/// uses ConcurrentDictionary, so the streaming worker thread may call -/// this safely. The internal snapshot dictionary is NOT thread-safe and must -/// be called from a single streaming thread (the same thread that fires -/// AddLandblock / RemoveLandblock events). +/// Thread safety: the internal snapshots are intentionally not synchronized. +/// invokes every load, unload, and readiness query +/// on the owning render/update thread. /// /// public sealed class LandblockSpawnAdapter @@ -48,6 +47,10 @@ public sealed class LandblockSpawnAdapter // Maps landblock id → unique GfxObj ids registered for that landblock. // Written on load, read+cleared on unload. Single-threaded (streaming worker). private readonly Dictionary> _idsByLandblock = new(); + // EnvCell shells are prepared through PrepareEnvCellGeomMeshDataAsync rather + // than generic GfxObj loading, but still require explicit lifetime pins. + // Keep their synthetic ids separate so registration uses the no-decode pin. + private readonly Dictionary> _additionalReadinessIdsByLandblock = new(); public LandblockSpawnAdapter(IWbMeshAdapter adapter) { @@ -61,7 +64,9 @@ public sealed class LandblockSpawnAdapter /// unique atlas-tier GfxObj id that has not already been registered for /// this landblock. /// - public void OnLandblockLoaded(LoadedLandblock landblock) + public void OnLandblockLoaded( + LoadedLandblock landblock, + IEnumerable? additionalReadinessIds = null) { System.ArgumentNullException.ThrowIfNull(landblock); @@ -80,14 +85,51 @@ public sealed class LandblockSpawnAdapter { _idsByLandblock[landblock.LandblockId] = unique; foreach (var id in unique) _adapter.IncrementRefCount(id); - return; + } + else + { + foreach (var id in unique) + { + if (registered.Add(id)) + _adapter.IncrementRefCount(id); + } } - foreach (var id in unique) + if (!_additionalReadinessIdsByLandblock.TryGetValue( + landblock.LandblockId, + out var additional)) { - if (registered.Add(id)) - _adapter.IncrementRefCount(id); + additional = new HashSet(); + _additionalReadinessIdsByLandblock[landblock.LandblockId] = additional; } + if (additionalReadinessIds is not null) + { + foreach (var id in additionalReadinessIds) + if (additional.Add(id)) + _adapter.PinPreparedRenderData(id); + } + } + + /// + /// True only after every render mesh required by this published landblock + /// is drawable. This includes both ref-counted static GfxObjs and EnvCell + /// shell geometry prepared by the independent indoor pipeline. + /// + public bool IsLandblockRenderReady(uint landblockId) + { + if (!_idsByLandblock.TryGetValue(landblockId, out var registered) + || !_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional)) + { + return false; + } + + foreach (var id in registered) + if (!_adapter.IsRenderDataReady(id)) + return false; + foreach (var id in additional) + if (!_adapter.IsRenderDataReady(id)) + return false; + return true; } /// @@ -99,6 +141,9 @@ public sealed class LandblockSpawnAdapter { if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return; foreach (var id in unique) _adapter.DecrementRefCount(id); + if (_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional)) + foreach (var id in additional) _adapter.DecrementRefCount(id); _idsByLandblock.Remove(landblockId); + _additionalReadinessIdsByLandblock.Remove(landblockId); } } diff --git a/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs b/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs new file mode 100644 index 00000000..a91b9bfc --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs @@ -0,0 +1,117 @@ +using System.Collections.Concurrent; +using AcDream.Content; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Deduplicated CPU-to-GPU staging queue. One object id may be queued or +/// in-flight at a time; a retry retains ownership until upload succeeds or +/// exhausts its retry budget. +/// +internal sealed class MeshUploadStagingQueue +{ + private readonly ConcurrentQueue _queue = new(); + private readonly ConcurrentDictionary _ownedIds = new(); + + public bool Stage(ObjectMeshData data) + { + if (!_ownedIds.TryAdd(data.ObjectId, 0)) + return false; + + data.UploadAttempts = 0; + _queue.Enqueue(data); + return true; + } + + public bool TryDequeue(out ObjectMeshData? data) => _queue.TryDequeue(out data); + + public void Requeue(ObjectMeshData data) => _queue.Enqueue(data); + + public void Complete(ulong objectId) => _ownedIds.TryRemove(objectId, out _); +} + +/// +/// Logical render-data ownership, deliberately independent from GPU upload +/// existence. Completing or repeating an upload never manufactures an owner; +/// only a live entity/landblock pin changes this count. +/// +internal sealed class MeshOwnershipCounter +{ + private readonly ConcurrentDictionary _counts = new(); + + public int Acquire(ulong id) => _counts.AddOrUpdate(id, 1, (_, count) => count + 1); + + public int Release(ulong id) => _counts.AddOrUpdate(id, 0, (_, count) => Math.Max(0, count - 1)); + + public int Count(ulong id) => _counts.TryGetValue(id, out int count) ? count : 0; + + public bool IsOwned(ulong id) => Count(id) > 0; + + /// + /// Reports whether freshly uploaded data has a live owner. This is a query, + /// not an acquire: upload existence and logical ownership are independent. + /// + public bool MarkUploadComplete(ulong id) => IsOwned(id); + + public bool Remove(ulong id) => _counts.TryRemove(id, out _); +} + +/// +/// Bounded prepared-mesh cache. A cache hit is not merely a CPU return value: +/// when GPU render data is absent it re-stages that mesh for upload. +/// +internal sealed class CpuMeshUploadCache +{ + private readonly Dictionary _data = new(); + private readonly LinkedList _lru = new(); + private readonly int _capacity; + + public CpuMeshUploadCache(int capacity) + { + if (capacity <= 0) + throw new ArgumentOutOfRangeException(nameof(capacity)); + _capacity = capacity; + } + + public bool TryGetAndStage( + ulong id, + MeshUploadStagingQueue staging, + out ObjectMeshData? data) + { + lock (_data) + { + if (!_data.TryGetValue(id, out data)) + return false; + _lru.Remove(id); + _lru.AddLast(id); + staging.Stage(data); + return true; + } + } + + public void Store(ObjectMeshData data) + { + lock (_data) + { + if (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity) + { + ulong oldest = _lru.First!.Value; + _lru.RemoveFirst(); + _data.Remove(oldest); + } + + _data[data.ObjectId] = data; + _lru.Remove(data.ObjectId); + _lru.AddLast(data.ObjectId); + } + } + + public void Clear() + { + lock (_data) + { + _data.Clear(); + _lru.Clear(); + } + } +} diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index 748d6f35..856a95ad 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -105,7 +105,7 @@ namespace AcDream.App.Rendering.Wb { public bool IsDisposed { get; private set; } private readonly ConcurrentDictionary _renderData = new(); - private readonly ConcurrentDictionary _usageCount = new(); + private readonly MeshOwnershipCounter _ownership = new(); private readonly ConcurrentDictionary _boundsCache = new(); private readonly ConcurrentDictionary> _preparationTasks = new(); @@ -119,12 +119,10 @@ namespace AcDream.App.Rendering.Wb { private readonly Dictionary<(int Width, int Height, TextureFormat Format), List> _globalAtlases = new(); // CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT) - private readonly Dictionary _cpuMeshCache = new(); - private readonly LinkedList _cpuLruList = new(); private readonly int _maxCpuCacheSize = 100; + private readonly CpuMeshUploadCache _cpuMeshCache; - private readonly ConcurrentQueue _stagedMeshData = new(); - public ConcurrentQueue StagedMeshData => _stagedMeshData; + private readonly MeshUploadStagingQueue _stagedMeshData = new(); /// #125: how many times a failed GL upload is re-staged before /// giving up loudly. Small — a transient GL error clears on the next @@ -141,17 +139,28 @@ namespace AcDream.App.Rendering.Wb { /// on re-prepare) and gives up loudly past . /// public bool UploadOrRequeue(ObjectMeshData meshData) { - if (UploadMeshData(meshData) is not null) + if (UploadMeshData(meshData) is not null) { + _stagedMeshData.Complete(meshData.ObjectId); return false; // success (incl. legitimate 0-vertex → empty render data) - if (HasRenderData(meshData.ObjectId)) + } + if (HasRenderData(meshData.ObjectId)) { + _stagedMeshData.Complete(meshData.ObjectId); return false; // raced to present by another path + } meshData.UploadAttempts++; if (meshData.UploadAttempts < MaxUploadRetries) return true; // re-stage for next frame + _stagedMeshData.Complete(meshData.ObjectId); Console.WriteLine($"[up-retry] 0x{meshData.ObjectId:X10} upload failed {meshData.UploadAttempts}x — giving up (was the #125 silent sticky drop; a GL error is being surfaced, not hidden)"); return false; } + internal bool TryDequeueStagedMeshData(out ObjectMeshData? data) => + _stagedMeshData.TryDequeue(out data); + + internal void RequeueStagedMeshData(ObjectMeshData data) => + _stagedMeshData.Requeue(data); + public GlobalMeshBuffer? GlobalBuffer { get; } private readonly bool _useModernRendering; @@ -167,7 +176,8 @@ namespace AcDream.App.Rendering.Wb { // did — immediate enqueue, surviving a later throw in the same // Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the // extractor's up-to-4 concurrent decode workers. - _extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Enqueue(data)); + _cpuMeshCache = new CpuMeshUploadCache(_maxCpuCacheSize); + _extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Stage(data)); _useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless; if (_useModernRendering) { GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL); @@ -180,7 +190,7 @@ namespace AcDream.App.Rendering.Wb { /// public ObjectRenderData? GetRenderData(ulong id) { if (_renderData.TryGetValue(id, out var data)) { - _usageCount.AddOrUpdate(id, 1, (_, count) => count + 1); + _ownership.Acquire(id); if (data.IsSetup) { foreach (var (partId, _) in data.SetupParts) { @@ -223,7 +233,7 @@ namespace AcDream.App.Rendering.Wb { /// Increment reference count for an object (e.g. when a landblock starts using it). /// public void IncrementRefCount(ulong id) { - _usageCount.AddOrUpdate(id, 1, (_, count) => count + 1); + _ownership.Acquire(id); lock (_lruList) { _lruList.Remove(id); } @@ -258,7 +268,7 @@ namespace AcDream.App.Rendering.Wb { /// Decrement reference count and unload GPU resources if no longer needed. /// public void DecrementRefCount(ulong id) { - var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1); + var newCount = _ownership.Release(id); if (newCount <= 0) { // Instead of unloading, move to LRU lock (_lruList) { @@ -272,8 +282,8 @@ namespace AcDream.App.Rendering.Wb { /// Decrement reference count and unload if no longer needed. /// public void ReleaseRenderData(ulong id) { - if (_usageCount.TryGetValue(id, out var count) && count > 0) { - var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1); + if (_ownership.IsOwned(id)) { + var newCount = _ownership.Release(id); if (newCount <= 0) { // Instead of unloading, move to LRU lock (_lruList) { @@ -291,9 +301,9 @@ namespace AcDream.App.Rendering.Wb { var idToEvict = _lruList.First!.Value; _lruList.RemoveFirst(); - if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) { + if (!_ownership.IsOwned(idToEvict)) { UnloadObject(idToEvict); - _usageCount.TryRemove(idToEvict, out _); + _ownership.Remove(idToEvict); } } } @@ -309,18 +319,15 @@ namespace AcDream.App.Rendering.Wb { var idToEvict = _lruList.First!.Value; _lruList.RemoveFirst(); - if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) { + if (!_ownership.IsOwned(idToEvict)) { UnloadObject(idToEvict); - _usageCount.TryRemove(idToEvict, out _); + _ownership.Remove(idToEvict); } } } // Also clear CPU mesh cache - lock (_cpuMeshCache) { - _cpuMeshCache.Clear(); - _cpuLruList.Clear(); - } + _cpuMeshCache.Clear(); } public struct EnvCellGeomRequest { @@ -338,13 +345,8 @@ namespace AcDream.App.Rendering.Wb { if (IsDisposed || HasRenderData(geomId)) return Task.FromResult(null); // Check CPU cache first - lock (_cpuMeshCache) { - if (_cpuMeshCache.TryGetValue(geomId, out var cachedData)) { - _cpuLruList.Remove(geomId); - _cpuLruList.AddLast(geomId); - return Task.FromResult(cachedData); - } - } + if (_cpuMeshCache.TryGetAndStage(geomId, _stagedMeshData, out var cachedData)) + return Task.FromResult(cachedData); // Return existing task if already running or queued if (_preparationTasks.TryGetValue(geomId, out var existing)) { @@ -381,13 +383,8 @@ namespace AcDream.App.Rendering.Wb { if (IsDisposed || HasRenderData(id)) return Task.FromResult(null); // Check CPU cache first - lock (_cpuMeshCache) { - if (_cpuMeshCache.TryGetValue(id, out var cachedData)) { - _cpuLruList.Remove(id); - _cpuLruList.AddLast(id); - return Task.FromResult(cachedData); - } - } + if (_cpuMeshCache.TryGetAndStage(id, _stagedMeshData, out var cachedData)) + return Task.FromResult(cachedData); // Return existing task if already running or queued if (_preparationTasks.TryGetValue(id, out var existing)) { @@ -476,16 +473,8 @@ namespace AcDream.App.Rendering.Wb { data = _extractor.PrepareMeshData(id, isSetup, CancellationToken.None); } if (data != null) { - lock (_cpuMeshCache) { - if (_cpuMeshCache.Count >= _maxCpuCacheSize) { - var oldest = _cpuLruList.First!.Value; - _cpuLruList.RemoveFirst(); - _cpuMeshCache.Remove(oldest); - } - _cpuMeshCache[id] = data; - _cpuLruList.AddLast(id); - } - _stagedMeshData.Enqueue(data); + _cpuMeshCache.Store(data); + _stagedMeshData.Stage(data); } tcs.TrySetResult(data); } @@ -538,26 +527,7 @@ namespace AcDream.App.Rendering.Wb { try { if (_renderData.TryGetValue(meshData.ObjectId, out var existing)) { _preparationTasks.TryRemove(meshData.ObjectId, out _); - if (existing.IsSetup) { - foreach (var (partId, _) in existing.SetupParts) { - IncrementRefCount(partId); - lock (_lruList) { - _lruList.Remove(partId); - } - } - } - else { - // Increment ref counts for all textures in this GfxObj - foreach (var batch in existing.Batches) { - if (batch.Atlas != null) { - batch.Atlas.AddTexture(batch.Key, Array.Empty()); - } - } - } - IncrementRefCount(meshData.ObjectId); - lock (_lruList) { - _lruList.Remove(meshData.ObjectId); - } + UpdateLruAfterUpload(meshData.ObjectId); return existing; } @@ -588,7 +558,6 @@ namespace AcDream.App.Rendering.Wb { MemorySize = 1024 // Small overhead for the setup itself }; _renderData.TryAdd(meshData.ObjectId, data); - IncrementRefCount(meshData.ObjectId); _currentGpuMemory += data.MemorySize; // Increment ref counts for all parts @@ -596,6 +565,8 @@ namespace AcDream.App.Rendering.Wb { IncrementRefCount(partId); } + UpdateLruAfterUpload(meshData.ObjectId); + return data; } @@ -619,15 +590,12 @@ namespace AcDream.App.Rendering.Wb { renderData.DIDDegrade = meshData.DIDDegrade; renderData.SelectionSphere = meshData.SelectionSphere; _renderData.TryAdd(meshData.ObjectId, renderData); - IncrementRefCount(meshData.ObjectId); _currentGpuMemory += renderData.MemorySize; + UpdateLruAfterUpload(meshData.ObjectId); - // Clear texture data after upload to save RAM - foreach (var batchList in meshData.TextureBatches.Values) { - foreach (var batch in batchList) { - batch.TextureData = Array.Empty(); - } - } + // Keep the bounded CPU cache's texture payload intact. GPU LRU + // eviction may need to upload this same prepared mesh again; + // clearing these bytes made a cache hit produce blank textures. return renderData; } catch (Exception ex) { @@ -636,6 +604,14 @@ namespace AcDream.App.Rendering.Wb { } } + private void UpdateLruAfterUpload(ulong id) { + lock (_lruList) { + _lruList.Remove(id); + if (!_ownership.MarkUploadComplete(id)) + _lruList.AddLast(id); + } + } + /// /// Gets bounding box for an object (for frustum culling). /// diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 9440ba6c..3eea0a8e 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -151,6 +151,15 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter return _meshManager.TryGetRenderData(id); } + /// + public bool IsRenderDataReady(ulong id) + { + // An uninitialized adapter owns no render pipeline, so it must not + // manufacture a readiness wait that can never complete. The modern + // production path is always initialized at startup. + return _isUninitialized || _meshManager?.TryGetRenderData(id) is not null; + } + /// public void IncrementRefCount(ulong id) { @@ -199,6 +208,16 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter _meshManager.DecrementRefCount(id); } + /// + public void PinPreparedRenderData(ulong id) + { + if (_isUninitialized || _meshManager is null) return; + // EnvCell geometry has its own schema-aware preparation request. + // Only establish lifecycle ownership here; IncrementRefCount's normal + // generic GfxObj preparation would decode the synthetic id incorrectly. + _meshManager.IncrementRefCount(id); + } + /// /// #128 self-heal (2026-06-11): re-request a mesh load at the POINT OF /// USE. Registration-time re-arming was insufficient — a preparation @@ -252,14 +271,16 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so // a genuine defect surfaces loudly instead of the old silent sticky drop. List? requeue = null; - while (_meshManager!.StagedMeshData.TryDequeue(out var meshData)) + while (_meshManager!.TryDequeueStagedMeshData(out var meshData)) { + if (meshData is null) + continue; if (_meshManager.UploadOrRequeue(meshData)) (requeue ??= new()).Add(meshData); } if (requeue is not null) foreach (var m in requeue) - _meshManager.StagedMeshData.Enqueue(m); + _meshManager.RequeueStagedMeshData(m); bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled; var pendingBefore = texProbe diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index beb443a2..b9ac7201 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -67,6 +67,7 @@ public sealed class GpuWorldState } private readonly Dictionary _loaded = new(); + private readonly Dictionary _tierByLandblock = new(); private readonly Dictionary _aabbs = new(); /// @@ -75,6 +76,12 @@ public sealed class GpuWorldState /// Drained into in . /// private readonly Dictionary> _pendingByLandblock = new(); + // Far-to-near promotion can complete before the base far landblock is + // published. Preserve its independently-prepared EnvCell geometry ids + // alongside the parked entity layer so the later AddLandblock transaction + // cannot open the render gate before those shells upload. + private readonly Dictionary> _pendingRenderIdsByLandblock = new(); + private readonly HashSet _pendingNearTierLandblocks = new(); /// /// Entities that must survive landblock unloads (e.g. the player character). @@ -96,6 +103,14 @@ public sealed class GpuWorldState public event Action? LiveProjectionVisibilityChanged; public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); + public bool IsNearTier(uint landblockId) => + _tierByLandblock.TryGetValue(landblockId, out var tier) + && tier == LandblockStreamTier.Near; + public bool IsNearTierOrPending(uint landblockId) => + IsNearTier(landblockId) || _pendingNearTierLandblocks.Contains(landblockId); + public bool IsRenderReady(uint landblockId) => + _loaded.ContainsKey(landblockId) + && (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true); public bool IsLiveEntityVisible(uint serverGuid) => serverGuid != 0 && _visibleLiveGuids.Contains(serverGuid); @@ -204,8 +219,17 @@ public sealed class GpuWorldState public int PersistentGuidCount => _persistentGuids.Count; public int PendingVisibilityTransitionCount => _visibilityTransitions.Count; - public void AddLandblock(LoadedLandblock landblock) + public void AddLandblock( + LoadedLandblock landblock, + IEnumerable? additionalRenderIds = null, + LandblockStreamTier tier = LandblockStreamTier.Near) { + // 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; + // If pending live entities have been waiting for this landblock, // merge them into the LoadedLandblock record before storing. The // record's Entities field is IReadOnlyList; we replace the whole @@ -222,9 +246,29 @@ public sealed class GpuWorldState _pendingByLandblock.Remove(landblock.LandblockId); } + HashSet? mergedRenderIds = additionalRenderIds is null + ? null + : new HashSet(additionalRenderIds); + if (_pendingRenderIdsByLandblock.Remove(landblock.LandblockId, out var pendingRenderIds)) + { + mergedRenderIds ??= new HashSet(); + mergedRenderIds.UnionWith(pendingRenderIds); + } + + bool pendingNear = _pendingNearTierLandblocks.Remove(landblock.LandblockId); + if (pendingNear + || (_tierByLandblock.TryGetValue(landblock.LandblockId, out var currentTier) + && currentTier == LandblockStreamTier.Near)) + { + tier = LandblockStreamTier.Near; + } + _loaded[landblock.LandblockId] = landblock; + _tierByLandblock[landblock.LandblockId] = tier; if (_wbSpawnAdapter is not null) - _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]); + _wbSpawnAdapter.OnLandblockLoaded( + _loaded[landblock.LandblockId], + mergedRenderIds); // C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0). // LiveEntityRuntime owns activation for live objects. This static-only @@ -397,10 +441,13 @@ public sealed class GpuWorldState } _pendingByLandblock.Remove(canonical); + _pendingRenderIdsByLandblock.Remove(canonical); + _pendingNearTierLandblocks.Remove(canonical); if (retainedLive.Count > 0) _pendingByLandblock[canonical] = retainedLive; _aabbs.Remove(canonical); + _tierByLandblock.Remove(canonical); if (_loaded.Remove(canonical)) RebuildFlatView(); } @@ -612,6 +659,18 @@ public sealed class GpuWorldState // protects against future callers that mirror live projection placement's // cell-resolved-id pattern. uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + // A promotion may have parked its Near layer before the Far base load + // exists. Demotion must retire that pending tier just as completely as + // an installed tier, while preserving live server projections. + _pendingNearTierLandblocks.Remove(canonical); + _pendingRenderIdsByLandblock.Remove(canonical); + if (_pendingByLandblock.TryGetValue(canonical, out var pending)) + { + pending.RemoveAll(entity => entity.ServerGuid == 0); + if (pending.Count == 0) + _pendingByLandblock.Remove(canonical); + } + if (!_loaded.TryGetValue(canonical, out var lb)) return; if (_wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockUnloaded(canonical); @@ -642,12 +701,7 @@ public sealed class GpuWorldState lb.LandblockId, lb.Heightmap, retainedLive); - if (_pendingByLandblock.TryGetValue(canonical, out var pending)) - { - pending.RemoveAll(entity => entity.ServerGuid == 0); - if (pending.Count == 0) - _pendingByLandblock.Remove(canonical); - } + _tierByLandblock[canonical] = LandblockStreamTier.Far; RebuildFlatView(); } @@ -664,7 +718,10 @@ public sealed class GpuWorldState /// callers may pass cell-resolved ids and they will key correctly. /// /// - public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList entities) + public bool AddEntitiesToExistingLandblock( + uint landblockId, + IReadOnlyList entities, + IEnumerable? additionalRenderIds = null) { // A.5 T14 follow-up: canonicalize for symmetry with live projection placement. uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; @@ -677,14 +734,25 @@ public sealed class GpuWorldState _pendingByLandblock[canonical] = bucket; } bucket.AddRange(entities); - return; + _pendingNearTierLandblocks.Add(canonical); + if (additionalRenderIds is not null) + { + if (!_pendingRenderIdsByLandblock.TryGetValue(canonical, out var renderIds)) + { + renderIds = new HashSet(); + _pendingRenderIdsByLandblock[canonical] = renderIds; + } + renderIds.UnionWith(additionalRenderIds); + } + return false; } var merged = new List(lb.Entities.Count + entities.Count); merged.AddRange(lb.Entities); merged.AddRange(entities); _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged); + _tierByLandblock[canonical] = LandblockStreamTier.Near; if (_wbSpawnAdapter is not null) - _wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]); + _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 @@ -697,6 +765,7 @@ public sealed class GpuWorldState } RebuildFlatView(); + return true; } private void RebuildFlatView() diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs index ac83cec5..51bcfc9d 100644 --- a/src/AcDream.App/Streaming/LandblockStreamJob.cs +++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs @@ -12,8 +12,13 @@ namespace AcDream.App.Streaming; /// public abstract record LandblockStreamJob(uint LandblockId) { - public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId); - public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId); + public sealed record Load( + uint LandblockId, + LandblockStreamJobKind Kind, + ulong Generation = 0) : LandblockStreamJob(LandblockId); + public sealed record Unload( + uint LandblockId, + ulong Generation = 0) : LandblockStreamJob(LandblockId); /// /// Control job: drop every queued (not-yet-started) Load from the worker's @@ -32,7 +37,7 @@ public abstract record LandblockStreamJob(uint LandblockId) /// an unload notification (tells the render thread to release GPU state /// for this landblock id). /// -public abstract record LandblockStreamResult(uint LandblockId) +public abstract record LandblockStreamResult(uint LandblockId, ulong Generation) { /// /// A landblock load completed. distinguishes Far @@ -43,15 +48,17 @@ public abstract record LandblockStreamResult(uint LandblockId) uint LandblockId, LandblockStreamTier Tier, LandblockBuild Build, - LandblockMeshData MeshData - ) : LandblockStreamResult(LandblockId) + LandblockMeshData MeshData, + ulong Generation = 0 + ) : LandblockStreamResult(LandblockId, Generation) { public Loaded( uint landblockId, LandblockStreamTier tier, LoadedLandblock landblock, - LandblockMeshData meshData) - : this(landblockId, tier, new LandblockBuild(landblock), meshData) + LandblockMeshData meshData, + ulong generation = 0) + : this(landblockId, tier, new LandblockBuild(landblock), meshData, generation) { } @@ -69,14 +76,16 @@ public abstract record LandblockStreamResult(uint LandblockId) public sealed record Promoted( uint LandblockId, LandblockBuild Build, - LandblockMeshData MeshData - ) : LandblockStreamResult(LandblockId) + LandblockMeshData MeshData, + ulong Generation = 0 + ) : LandblockStreamResult(LandblockId, Generation) { public Promoted( uint landblockId, LoadedLandblock landblock, - LandblockMeshData meshData) - : this(landblockId, new LandblockBuild(landblock), meshData) + LandblockMeshData meshData, + ulong generation = 0) + : this(landblockId, new LandblockBuild(landblock), meshData, generation) { } @@ -84,8 +93,13 @@ public abstract record LandblockStreamResult(uint LandblockId) public IReadOnlyList Entities => Landblock.Entities; } - public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId); - public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId); + public sealed record Failed( + uint LandblockId, + string Error, + ulong Generation = 0) : LandblockStreamResult(LandblockId, Generation); + public sealed record Unloaded( + uint LandblockId, + ulong Generation = 0) : LandblockStreamResult(LandblockId, Generation); /// /// The worker loop itself crashed with an unhandled exception. Not tied @@ -94,5 +108,5 @@ public abstract record LandblockStreamResult(uint LandblockId) /// than retrying a single landblock later. LandblockId is 0 by /// convention; readers should pattern-match on the type, not the id. /// - public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0); + public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0, 0); } diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index b66ce857..d8d7587f 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -143,23 +143,26 @@ public sealed class LandblockStreamer : IDisposable /// (or /// ) to the outbox. /// - public void EnqueueLoad(uint landblockId, LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear) + public void EnqueueLoad( + uint landblockId, + LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear, + ulong generation = 0) { if (System.Threading.Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(LandblockStreamer)); AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}"); - _inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind)); + _inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind, generation)); } /// /// Non-blocking enqueue. The worker posts a /// to the outbox. /// - public void EnqueueUnload(uint landblockId) + public void EnqueueUnload(uint landblockId, ulong generation = 0) { if (System.Threading.Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(LandblockStreamer)); - _inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId)); + _inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId, generation)); } /// @@ -335,7 +338,7 @@ public sealed class LandblockStreamer : IDisposable if (build is null) { _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( - load.LandblockId, "LandblockLoader.Load returned null")); + load.LandblockId, "LandblockLoader.Load returned null", load.Generation)); break; } var lb = build.Landblock; @@ -345,18 +348,18 @@ public sealed class LandblockStreamer : IDisposable if (promotedMesh is null) { _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( - load.LandblockId, "buildMeshOrNull returned null")); + load.LandblockId, "buildMeshOrNull returned null", load.Generation)); break; } _outbox.Writer.TryWrite(new LandblockStreamResult.Promoted( - load.LandblockId, build, promotedMesh)); + load.LandblockId, build, promotedMesh, load.Generation)); break; } var mesh = _buildMeshOrNull(load.LandblockId, lb); if (mesh is null) { _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( - load.LandblockId, "buildMeshOrNull returned null")); + load.LandblockId, "buildMeshOrNull returned null", load.Generation)); break; } var tier = load.Kind == LandblockStreamJobKind.LoadFar @@ -375,17 +378,19 @@ public sealed class LandblockStreamer : IDisposable build = new LandblockBuild(lb); } _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( - load.LandblockId, tier, build, mesh)); + load.LandblockId, tier, build, mesh, load.Generation)); } catch (Exception ex) { _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( - load.LandblockId, ex.ToString())); + load.LandblockId, ex.ToString(), load.Generation)); } break; case LandblockStreamJob.Unload unload: - _outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(unload.LandblockId)); + _outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded( + unload.LandblockId, + unload.Generation)); break; } } diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 969c9a41..1b826a09 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using AcDream.App.Rendering.Wb; using AcDream.Core.Terrain; using AcDream.Core.World; @@ -17,11 +18,12 @@ namespace AcDream.App.Streaming; /// public sealed class StreamingController { - private readonly Action _enqueueLoad; - private readonly Action _enqueueUnload; + private readonly Action _enqueueLoad; + private readonly Action _enqueueUnload; private readonly Func> _drainCompletions; private readonly Action _applyTerrain; private readonly Action? _removeTerrain; + private readonly Action? _demoteNearLayer; private readonly Action? _clearPendingLoads; /// @@ -34,8 +36,16 @@ public sealed class StreamingController /// 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; + // 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. + private ulong _generation; // True while streaming is collapsed to the single dungeon landblock the // player stands in (the dungeon gate, #133 FPS). AC dungeons have NO @@ -127,6 +137,43 @@ public sealed class StreamingController // GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface. public int DeferredApplyBacklog => _deferredApply.Count; + /// + /// True once every in-bounds landblock in the requested Chebyshev ring has + /// crossed the render-thread publication barrier. Worker completion and + /// world-state registration are not sufficient: all static GfxObj and + /// EnvCell shell meshes must have completed their render-thread upload. + /// Portal-space exit uses this alongside physics residency so the world + /// cannot be revealed while its render slots are still absent. + /// + public bool IsRenderNeighborhoodResident(uint cellOrLandblockId, int radius) + { + if (radius < 0) + throw new ArgumentOutOfRangeException(nameof(radius)); + + // LandDefs::InboundValidCellId validates both map axes and the low-word + // class (outdoor cell, EnvCell, or canonical landblock sentinel). + if (!AcDream.Core.Physics.LandDefs.InboundValidCellId(cellOrLandblockId)) + return false; + int cx = (int)((cellOrLandblockId >> 24) & 0xFFu); + int cy = (int)((cellOrLandblockId >> 16) & 0xFFu); + for (int dx = -radius; dx <= radius; dx++) + for (int dy = -radius; dy <= radius; dy++) + { + int nx = cx + dx; + int ny = cy + dy; + // Match PhysicsEngine.IsNeighborhoodTerrainResident: the outer + // 0xFF coordinate has no loadable neighbour beyond it. + if (nx < 0 || nx > 254 || ny < 0 || ny > 254) + continue; + + uint canonical = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu; + if (!_state.IsNearTier(canonical) || !_state.IsRenderReady(canonical)) + return false; + } + + 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; } @@ -135,8 +182,40 @@ public sealed class StreamingController // 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( + Action enqueueLoad, + Action enqueueUnload, + Func> drainCompletions, + Action applyTerrain, + GpuWorldState state, + int nearRadius, + int farRadius, + Action? removeTerrain = null, + Action? demoteNearLayer = null, + Action? clearPendingLoads = null, + Action? onLandblockLoaded = null, + Action? ensureEnvCellMeshes = null) + { + _enqueueLoad = enqueueLoad; + _enqueueUnload = enqueueUnload; + _drainCompletions = drainCompletions; + _applyTerrain = applyTerrain; + _removeTerrain = removeTerrain; + _demoteNearLayer = demoteNearLayer; + _clearPendingLoads = clearPendingLoads; + _onLandblockLoaded = onLandblockLoaded; + _ensureEnvCellMeshes = ensureEnvCellMeshes; + _state = state; + NearRadius = nearRadius; + FarRadius = farRadius; + } + + /// + /// Compatibility constructor for deterministic tests and callers that do + /// not own an asynchronous worker. Production must use the generation-aware + /// overload above so hard-recenter results can be rejected by incarnation. + /// + internal StreamingController( Action enqueueLoad, Action enqueueUnload, Func> drainCompletions, @@ -145,19 +224,44 @@ public sealed class StreamingController int nearRadius, int farRadius, Action? removeTerrain = null, + Action? demoteNearLayer = null, Action? clearPendingLoads = null, - Action? onLandblockLoaded = null) + Action? onLandblockLoaded = null, + Action? ensureEnvCellMeshes = null) + : this( + (id, kind, _) => enqueueLoad(id, kind), + (id, _) => enqueueUnload(id), + drainCompletions, + applyTerrain, + state, + nearRadius, + farRadius, + removeTerrain, + demoteNearLayer, + clearPendingLoads, + onLandblockLoaded, + ensureEnvCellMeshes) { - _enqueueLoad = enqueueLoad; - _enqueueUnload = enqueueUnload; - _drainCompletions = drainCompletions; - _applyTerrain = applyTerrain; - _removeTerrain = removeTerrain; - _clearPendingLoads = clearPendingLoads; - _onLandblockLoaded = onLandblockLoaded; - _state = state; - NearRadius = nearRadius; - FarRadius = farRadius; + } + + private void EnqueueLoad(uint id, LandblockStreamJobKind kind) => + _enqueueLoad(id, kind, _generation); + + private void EnqueueUnload(uint id) => _enqueueUnload(id, _generation); + + private void DemoteLandblock(uint id) + { + uint canonical = (id & 0xFFFF0000u) | 0xFFFFu; + // App-owned Near resources need the still-present static entity list + // for exact light/translucency owner cleanup. Retire them before + // GpuWorldState drops the static layer and its render pins. + _demoteNearLayer?.Invoke(canonical); + _state.RemoveEntitiesFromLandblock(canonical); + } + + private void AdvanceGeneration() + { + _generation = unchecked(_generation + 1); } /// @@ -263,18 +367,18 @@ public sealed class StreamingController { _region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); var bootstrap = _region.ComputeFirstTickDiff(); - foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear); - foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar); + foreach (var id in bootstrap.ToLoadNear) EnqueueLoad(id, LandblockStreamJobKind.LoadNear); + foreach (var id in bootstrap.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar); _region.MarkResidentFromBootstrap(); } else if (_region.CenterX != observerCx || _region.CenterY != observerCy) { var diff = _region.RecenterTo(observerCx, observerCy); - foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear); - foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear); - foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar); - foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id); - foreach (var id in diff.ToUnload) _enqueueUnload(id); + foreach (var id in diff.ToPromote) EnqueueLoad(id, LandblockStreamJobKind.PromoteToNear); + foreach (var id in diff.ToLoadNear) EnqueueLoad(id, LandblockStreamJobKind.LoadNear); + foreach (var id in diff.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar); + foreach (var id in diff.ToDemote) DemoteLandblock(id); + foreach (var id in diff.ToUnload) EnqueueUnload(id); } } @@ -293,10 +397,12 @@ public sealed class StreamingController Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}"); _collapsed = true; _collapsedCenter = centerId; + AdvanceGeneration(); _clearPendingLoads?.Invoke(); + _deferredApply.Clear(); foreach (var id in _state.LoadedLandblockIds) - if (id != centerId) _enqueueUnload(id); + if (id != centerId) EnqueueUnload(id); // Pin a radius-0 region so RecenterTo never re-expands while inside, // and so the post-exit rebuild starts from a clean, consistent state. @@ -306,7 +412,9 @@ public sealed class StreamingController // The dungeon landblock itself must be (or become) loaded. If a prior // ClearPendingLoads cancelled its queued load, re-enqueue it. if (!_state.IsLoaded(centerId)) - _enqueueLoad(centerId, LandblockStreamJobKind.LoadNear); + EnqueueLoad(centerId, LandblockStreamJobKind.LoadNear); + else if (!_state.IsNearTier(centerId)) + EnqueueLoad(centerId, LandblockStreamJobKind.PromoteToNear); } /// @@ -321,7 +429,7 @@ public sealed class StreamingController // Always preserve the true dungeon landblock (_collapsedCenter), never the // per-frame observer landblock — a CurrCell flicker must not unload the dungeon. foreach (var id in _state.LoadedLandblockIds) - if (id != _collapsedCenter) _enqueueUnload(id); + if (id != _collapsedCenter) EnqueueUnload(id); } /// Chebyshev distance in landblock cells between two landblock ids. @@ -354,16 +462,17 @@ public sealed class StreamingController $"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " + $"(was collapsed on 0x{_collapsedCenter:X8})"); _collapsed = false; + AdvanceGeneration(); var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); foreach (var id in _state.LoadedLandblockIds) - if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id); + if (!rebuilt.Resident.Contains(id)) EnqueueUnload(id); var boot = rebuilt.ComputeFirstTickDiff(); foreach (var id in boot.ToLoadNear) - if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear); + if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadNear); foreach (var id in boot.ToLoadFar) - if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar); + if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadFar); rebuilt.MarkResidentFromBootstrap(); _region = rebuilt; } @@ -383,6 +492,9 @@ public sealed class StreamingController public void ForceReloadWindow() { _collapsed = false; + AdvanceGeneration(); + _clearPendingLoads?.Invoke(); + _deferredApply.Clear(); // Snapshot — RemoveLandblock mutates the loaded set we're iterating. var ids = new List(_state.LoadedLandblockIds); ForceReloadCount++; // [FRAME-DIAG] churn counter @@ -424,6 +536,11 @@ public sealed class StreamingController if (chunk.Count == 0) break; foreach (var result in chunk) { + // 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)) + continue; if (result is LandblockStreamResult.Unloaded || IsWithinPriorityRing(ResultLandblockId(result))) ApplyResult(result); // free (unload) or behind-the-fade near ring @@ -449,19 +566,120 @@ public sealed class StreamingController /// private void ApplyResult(LandblockStreamResult result) { + if (IsStaleGeneration(result)) + { + // A worker can finish one old load/unload after a hard recenter. + // Landblock id membership cannot distinguish overlapping windows; + // generation is the logical streaming incarnation boundary. + return; + } + + if (result is LandblockStreamResult.Unloaded + && _region?.TryGetDesiredTier(result.LandblockId, out _) == true) + { + // Normal recenter does not advance the hard-window generation. A + // rapid away->back can therefore re-own an id while its same- + // generation unload is already in the worker outbox. Current + // region ownership wins; the old unload must not tear it down. + return; + } + + if (result is LandblockStreamResult.Loaded + or LandblockStreamResult.Promoted) + { + if (_region is null + || !_region.TryGetDesiredTier(result.LandblockId, out var desiredTier)) + { + // A hard recenter/collapse can leave one worker job already in + // flight. Its completion belongs to the old region and must not + // resurrect a landblock the new StreamingRegion never owns. + return; + } + + bool isNearCompletion = result is LandblockStreamResult.Promoted + or LandblockStreamResult.Loaded { Tier: LandblockStreamTier.Near }; + if (isNearCompletion && _state.IsNearTier(result.LandblockId)) + { + // Normal recenter can enqueue a second high-priority Near job + // while an earlier one is already in flight. The streamer + // supersedes Far/Unload work but intentionally does not dedupe + // high-priority jobs. Publication is idempotent at this owner: + // never append statics, replay defaults, or reapply the complete + // Near transaction to an already-Near landblock. + return; + } + if (desiredTier == LandblockStreamTier.Far && isNearCompletion) + { + // Recenter can demote a Near job before its first completion. + // StreamingRegion already considers that landblock Far, so it + // deliberately emits no second LoadFar job. If no terrain is + // resident yet, publish the completed heightmap/mesh as a Far + // result while discarding its entity and EnvCell layers. This + // is the same payload a fresh LoadFar would have produced and + // closes the in-flight Near -> desired Far lifecycle without a + // duplicate worker read or a permanent terrain hole. + if (!_state.IsLoaded(result.LandblockId)) + { + switch (result) + { + case LandblockStreamResult.Loaded loaded: + ApplyAsFar(loaded.Build, loaded.MeshData); + break; + case LandblockStreamResult.Promoted promoted: + ApplyAsFar(promoted.Build, promoted.MeshData); + break; + } + } + return; + } + } + switch (result) { case LandblockStreamResult.Loaded loaded: + if (loaded.Tier == LandblockStreamTier.Far + && _state.IsNearTier(loaded.LandblockId)) + { + // This Far completion was queued before a newer Near load + // or promotion. Applying it would erase the entity/cell + // layer that now owns the landblock. + break; + } _applyTerrain(loaded.Build, loaded.MeshData); - _state.AddLandblock(loaded.Landblock); + _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); break; case LandblockStreamResult.Promoted promoted: + // PromoteToNear carries a complete build and mesh because the + // streamer deliberately lets it supersede a queued LoadFar. If + // 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); - _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); + 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); break; case LandblockStreamResult.Unloaded unloaded: @@ -479,6 +697,31 @@ public sealed class StreamingController } } + private void ApplyAsFar(LandblockBuild completedBuild, LandblockMeshData meshData) + { + var completed = completedBuild.Landblock; + var farLandblock = new LoadedLandblock( + completed.LandblockId, + completed.Heightmap, + Array.Empty(), + PhysicsDatBundle.Empty); + var farBuild = new LandblockBuild(farLandblock); + + _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); + } + + private bool IsStaleGeneration(LandblockStreamResult result) => + result is not LandblockStreamResult.WorkerCrashed + && result.Generation != _generation; + /// /// Returns the landblock id associated with . /// For this is 0 by diff --git a/src/AcDream.App/Streaming/StreamingRegion.cs b/src/AcDream.App/Streaming/StreamingRegion.cs index 01eb85d2..1118489f 100644 --- a/src/AcDream.App/Streaming/StreamingRegion.cs +++ b/src/AcDream.App/Streaming/StreamingRegion.cs @@ -171,6 +171,25 @@ public sealed class StreamingRegion internal static uint EncodeLandblockIdForTest(int lbX, int lbY) => EncodeLandblockId(lbX, lbY); + /// + /// Returns the tier currently owned by this region after bootstrap/recenter + /// hysteresis has been applied. Late worker completions use this to avoid + /// resurrecting a Near layer after the region has demoted it to Far. + /// + internal bool TryGetDesiredTier(uint landblockId, out LandblockStreamTier tier) + { + if (_tierResidence.TryGetValue(landblockId, out var residence)) + { + tier = residence == TierResidence.Near + ? LandblockStreamTier.Near + : LandblockStreamTier.Far; + return true; + } + + tier = default; + return false; + } + /// /// Two-tier recenter: computes the 5-list diff per Phase A.5 spec §4.2. /// Hysteresis: NearRadius+2 for Near→Far demote; FarRadius+2 for Far→null diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index ab860921..cfe776ff 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -566,9 +566,7 @@ public sealed class AnimationSequencer if (_core.CurrAnim == null && rootMotionFrame is null) return BuildIdentityFrame(partCount); if (dt <= 0f) - return _core.CurrAnim == null - ? BuildIdentityFrame(partCount) - : BuildBlendedFrame(); + return SampleCurrentPose(); _core.Update(dt, rootMotionFrame); @@ -577,6 +575,19 @@ public sealed class AnimationSequencer : BuildBlendedFrame(); } + /// + /// Samples the current sequence frame without advancing animation time or + /// dispatching hooks. This is retail's hidden-object + /// CPhysicsObj::set_frame -> CPartArray::SetFrame -> UpdateParts + /// path: HandleEnterWorld may move the sequence cursor from a completed + /// link to the cyclic tail, and the hidden object must recompose that new pose + /// while PartArray time remains frozen. + /// + public IReadOnlyList SampleCurrentPose() + => _core.CurrAnim == null + ? BuildIdentityFrame(_setup.Parts.Count) + : BuildBlendedFrame(); + /// /// Retrieve and clear the list of hooks that fired since the last call. /// Empty when no frame boundary was crossed. Safe to call multiple diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs index ab4d7c7c..b7cab676 100644 --- a/src/AcDream.Core/Physics/PhysicsBody.cs +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -200,19 +200,63 @@ public sealed class PhysicsBody } } + /// + /// Commits the position and full cell returned by a successful transition. + /// Retail CPhysicsObj::SetPositionInternal(CTransition const*) + /// (0x00515330) always commits both + /// sphere_path.curr_pos.objcell_id and its frame, including EnvCells. + /// The ordinary setter first carries the accepted world + /// displacement into the landblock-relative frame; this method then adopts the + /// resolver's exact cell identity. Outdoor frames are canonicalized across 24 m + /// cells and 192 m landblock boundaries. Indoor frames retain their carried + /// landblock-relative origin and adopt the resolved EnvCell id. + /// + public void CommitTransitionPosition(uint resolvedCellId, Vector3 worldPosition) + { + Position = worldPosition; + + if (CellPosition.ObjCellId == 0 || resolvedCellId == 0) + return; + + uint cell = resolvedCellId; + Vector3 local = CellPosition.Frame.Origin; + if ((cell & 0xFFFFu) is >= 1u and <= 0x40u + && !LandDefs.AdjustToOutside(ref cell, ref local)) + { + // At the map edge retail's transition does not yield a successful + // outside cell. Preserve the carried frame instead of inventing one. + return; + } + + CellPosition = new Position( + cell, + new CellFrame(local, CellPosition.Frame.Orientation)); + } + // Mirror a world-position translation into the cell-relative frame. Velocity is // frame-invariant under translation, so the same delta applies to the local origin. // AdjustToOutside then recomputes the cell index from the local (intra-landblock 24 m // cell crossings) AND wraps + bumps the landblock on a 192 m crossing — the outdoor // membership + canonicalization in one call (get_outside_lcoord + lcoord_to_gid + - // [0,192) wrap). Idempotent within a cell. Runs only for a SEEDED OUTDOOR cell; - // unseeded bodies (ObjCellId==0, e.g. remote entities) and indoor cells are skipped. + // [0,192) wrap). Idempotent within a cell. A SEEDED indoor body carries the same + // landblock-relative delta but keeps its EnvCell identity until the transition result + // commits the exact destination via CommitTransitionPosition. Unseeded bodies are skipped. private void SyncCellPositionDelta(Vector3 delta) { uint cell = CellPosition.ObjCellId; - if ((cell & 0xFFFFu) is not (>= 1u and <= 0x40u)) + if (cell == 0) return; + Vector3 local = CellPosition.Frame.Origin + delta; + + if ((cell & 0xFFFFu) is not (>= 1u and <= 0x40u)) + { + CellPosition = new Position( + cell, + new CellFrame(local, CellPosition.Frame.Orientation)); + return; + } + uint adjusted = cell; if (LandDefs.AdjustToOutside(ref adjusted, ref local)) CellPosition = new Position(adjusted, new CellFrame(local, CellPosition.Frame.Orientation)); diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 088813c2..f7a94e61 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -121,6 +121,7 @@ public sealed class PhysicsEngine public void RemoveLandblock(uint landblockId) { _landblocks.Remove(landblockId); + ShadowObjects.DeregisterStaticOwnersForLandblock(landblockId); ShadowObjects.RemoveLandblock(landblockId); DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply @@ -141,6 +142,41 @@ public sealed class PhysicsEngine DataCache?.CellGraph.RemoveLandblock(landblockId); } + /// + /// Retire a Near landblock's indoor-cell and static-object collision layer + /// while preserving its terrain surface and world offset for Far-tier use. + /// The corresponding render-side demotion preserves the terrain slot too. + /// + public void DemoteLandblockToTerrain(uint landblockId) + { + uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + if (_landblocks.TryGetValue(canonical, out var landblock)) + { + _landblocks[canonical] = landblock with + { + Cells = Array.Empty(), + Portals = Array.Empty(), + }; + } + + // Static footprints can flood into adjacent landblocks. Retire them by + // logical owner before removing rows by cell prefix, otherwise a mesh + // demoted out of view can remain as invisible collision across a seam. + ShadowObjects.DeregisterStaticOwnersForLandblock(canonical); + ShadowObjects.RemoveLandblock(canonical); + DataCache?.RemoveCellsForLandblock(canonical); + DataCache?.RemoveBuildingsForLandblock(canonical); + DataCache?.CellGraph.RemoveEnvCellsForLandblock(canonical); + + if (DataCache?.CellGraph is { } graph + && graph.CurrCell is { } current + && (current.Id & 0xFFFF0000u) == (canonical & 0xFFFF0000u) + && (current.Id & 0xFFFFu) >= 0x0100u) + { + graph.CurrCell = null; + } + } + /// /// Find the landblock that contains the given world-space XY position and /// return its ID plus world-space origin offsets. Returns false when no diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index e4be501b..957d5525 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -29,6 +29,10 @@ public sealed class ShadowObjectRegistry private readonly Dictionary> _cells = new(); private readonly Dictionary> _entityToCells = new(); // for deregistration private readonly HashSet _suspendedEntities = new(); + // Rows withdrawn because a touched landblock streamed out. The owner may + // be seeded in an adjacent still-resident landblock, so its remaining rows + // cannot by themselves tell RefloodLandblock that this prefix needs repair. + private readonly Dictionary> _withdrawnPrefixesByOwner = new(); /// /// A6.P4 door fix (2026-05-24): per-entity original shape list, used by @@ -367,7 +371,7 @@ public sealed class ShadowObjectRegistry public void RefloodLandblock(uint landblockId) { uint lbPrefix = landblockId & 0xFFFF0000u; - var toReflood = new List(); + var toReflood = new HashSet(); foreach (var kvp in _entityReg) { @@ -390,11 +394,17 @@ public sealed class ShadowObjectRegistry } } } + if (_withdrawnPrefixesByOwner.TryGetValue(kvp.Key, out var prefixes) + && prefixes.Contains(lbPrefix)) + { + toReflood.Add(kvp.Key); + } } foreach (uint entityId in toReflood) { var reg = _entityReg[entityId]; + _withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawnBeforeReflood); if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes)) { RegisterMultiPart(entityId, reg.EntityWorldPos, reg.EntityWorldRot, shapes, @@ -408,6 +418,22 @@ public sealed class ShadowObjectRegistry reg.CollisionType, reg.CylHeight, reg.Scale, reg.State, reg.Flags, reg.SeedCellId, reg.IsStatic); } + + // Register is also the authoritative movement/replacement API and + // therefore clears obsolete markers. Only this streaming reflood + // operation preserves the still-missing prefixes across replacement. + if (withdrawnBeforeReflood is not null) + _withdrawnPrefixesByOwner[entityId] = withdrawnBeforeReflood; + + if (_entityToCells.TryGetValue(entityId, out var refreshedCells) + && refreshedCells.Exists(cell => + (cell & 0xFFFF0000u) == lbPrefix) + && _withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn)) + { + withdrawn.Remove(lbPrefix); + if (withdrawn.Count == 0) + _withdrawnPrefixesByOwner.Remove(entityId); + } } } @@ -475,6 +501,29 @@ public sealed class ShadowObjectRegistry _entityShapes.Remove(entityId); _entityReg.Remove(entityId); _suspendedEntities.Remove(entityId); + _withdrawnPrefixesByOwner.Remove(entityId); + } + + /// + /// Logically tear down every static object owned by a landblock, including + /// shadow rows flooded into adjacent landblocks. Dynamic/server-live owners + /// are deliberately retained for spatial reflood after streaming changes. + /// + public void DeregisterStaticOwnersForLandblock(uint landblockId) + { + uint prefix = landblockId & 0xFFFF0000u; + var owners = new List(); + foreach (var (entityId, registration) in _entityReg) + { + if (registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) == prefix) + { + owners.Add(entityId); + } + } + + foreach (uint entityId in owners) + Deregister(entityId); } /// @@ -491,6 +540,18 @@ public sealed class ShadowObjectRegistry uint lbPrefix = landblockId & 0xFFFF0000u; var toRemove = new List(); + foreach (var (entityId, cells) in _entityToCells) + { + if (!cells.Exists(cell => (cell & 0xFFFF0000u) == lbPrefix)) + continue; + if (!_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn)) + { + withdrawn = new HashSet(); + _withdrawnPrefixesByOwner[entityId] = withdrawn; + } + withdrawn.Add(lbPrefix); + } + foreach (var kvp in _cells) { if ((kvp.Key & 0xFFFF0000u) == lbPrefix) @@ -520,6 +581,7 @@ public sealed class ShadowObjectRegistry _entityShapes.Remove(eid); _entityReg.Remove(eid); _suspendedEntities.Remove(eid); + _withdrawnPrefixesByOwner.Remove(eid); } } } @@ -549,6 +611,18 @@ public sealed class ShadowObjectRegistry /// public int RetainedRegistrationCount => _entityReg.Count; + /// Number of owner/prefix repair markers awaiting a future reflood. + public int WithdrawnPrefixMarkerCount + { + get + { + int count = 0; + foreach (var prefixes in _withdrawnPrefixesByOwner.Values) + count += prefixes.Count; + return count; + } + } + /// Suspended logical registrations awaiting spatial re-entry. public int SuspendedRegistrationCount => _suspendedEntities.Count; diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index 00b19ce9..c4ca420c 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -63,6 +63,17 @@ public sealed class CellGraph if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _); } + /// + /// Remove only a landblock's indoor environment cells while preserving its + /// outdoor terrain registration. Used by Near-to-Far streaming demotion. + /// + public void RemoveEnvCellsForLandblock(uint landblockPrefix) + { + uint lb = landblockPrefix & 0xFFFF0000u; + foreach (var id in new List(_envCells.Keys)) + if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _); + } + /// The universal id->cell resolver (retail CObjCell::GetVisible). public ObjCell? GetVisible(uint id) { diff --git a/src/AcDream.Core/World/LandblockLoader.cs b/src/AcDream.Core/World/LandblockLoader.cs index 00400c6c..7aa4893a 100644 --- a/src/AcDream.Core/World/LandblockLoader.cs +++ b/src/AcDream.Core/World/LandblockLoader.cs @@ -40,8 +40,8 @@ public static class LandblockLoader // When landblockId is non-zero, namespace stab Ids globally: // 0xC0XXYY00 + n, where XX = lbX byte, YY = lbY byte - // matching the scenery (0x80XXYY00) and interior (0x40XXYY00) patterns - // in GameWindow.cs. The 0xC0 top byte distinguishes stabs from those. + // distinct from the 0x8XXYYIII scenery and 0x4XXYYIII interior + // namespaces in GameWindow.cs. The 0xC0 top byte distinguishes stabs. // // Pre-Tier-1 callers (existing tests) pass landblockId=0 and get the // legacy starting-from-1 monotonic Ids — compatible with their assertions diff --git a/src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs b/src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs new file mode 100644 index 00000000..885e665d --- /dev/null +++ b/src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs @@ -0,0 +1,39 @@ +namespace AcDream.Core.World; + +/// +/// Allocates stable, collision-free ids for procedurally generated scenery. +/// +/// +/// The top nibble is fixed at 0x8, so bit 31 continues to identify +/// procedural scenery to every renderer and physics consumer. The remaining +/// 28 bits are X(8), Y(8), and a 12-bit per-landblock counter: +/// 0x8XXYYIII. No consumer decodes the former byte-aligned +/// 0x80XXYYII layout; they classify only on bit 31. +/// +/// +/// +/// The old 8-bit counter aborted generation after 256 drawable spawns. That +/// exception rejected the entire streamed landblock before render publication, +/// exposing the grey clear color as portal space ended. A 12-bit counter gives +/// 4096 entries while retaining full landblock coordinates. Overflow fails +/// before it can alias the next landblock. +/// +/// +public static class ProceduralSceneryIdAllocator +{ + public const uint MaxCounter = 0xFFFu; + + public static uint Base(uint landblockX, uint landblockY) + => 0x80000000u + | ((landblockX & 0xFFu) << 20) + | ((landblockY & 0xFFu) << 12); + + public static uint Allocate(uint landblockX, uint landblockY, ref uint counter) + { + if (counter > MaxCounter) + throw new InvalidDataException( + $"Landblock ({landblockX & 0xFFu:X2},{landblockY & 0xFFu:X2}) exceeds the 4096-entry procedural scenery id namespace."); + + return Base(landblockX, landblockY) + counter++; + } +} diff --git a/src/AcDream.Core/World/TeleportAnimSequencer.cs b/src/AcDream.Core/World/TeleportAnimSequencer.cs index 510c27d5..09514a13 100644 --- a/src/AcDream.Core/World/TeleportAnimSequencer.cs +++ b/src/AcDream.Core/World/TeleportAnimSequencer.cs @@ -99,7 +99,8 @@ public sealed class TeleportAnimSequencer /// /// Advance the machine by seconds. - /// = destination collision landblock is resident. + /// = the complete destination-load barrier is + /// satisfied (Near-tier render meshes/textures plus collision residency). /// Returns the current snapshot + edge-triggered events fired THIS tick. /// public (TeleportAnimSnapshot snapshot, IReadOnlyList events) diff --git a/tests/AcDream.App.Tests/Rendering/Wb/LandblockSpawnAdapterReadinessTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/LandblockSpawnAdapterReadinessTests.cs new file mode 100644 index 00000000..9a9717c1 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/LandblockSpawnAdapterReadinessTests.cs @@ -0,0 +1,104 @@ +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class LandblockSpawnAdapterReadinessTests +{ + [Fact] + public void RenderReady_WaitsForStaticAndEnvCellMeshes() + { + var meshes = new ReadinessMeshAdapter(); + var adapter = new LandblockSpawnAdapter(meshes); + var entity = new WorldEntity + { + Id = 1, + ServerGuid = 0, + SourceGfxObjOrSetupId = 0x01000010u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [new MeshRef(0x01000010u, Matrix4x4.Identity)], + }; + var landblock = new LoadedLandblock( + 0x1234FFFFu, + new LandBlock(), + new[] { entity }); + const ulong envCellGeometryId = 0x2_0000_1234ul; + + adapter.OnLandblockLoaded(landblock, new[] { envCellGeometryId }); + + Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId)); + meshes.ReadyIds.Add(0x01000010ul); + Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId)); + meshes.ReadyIds.Add(envCellGeometryId); + Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId)); + } + + [Fact] + public void RenderReady_EmptyPublishedLandblockIsReadyUntilUnload() + { + var meshes = new ReadinessMeshAdapter(); + var adapter = new LandblockSpawnAdapter(meshes); + var landblock = new LoadedLandblock( + 0x1234FFFFu, + new LandBlock(), + Array.Empty()); + + adapter.OnLandblockLoaded(landblock); + + Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId)); + adapter.OnLandblockUnloaded(landblock.LandblockId); + Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId)); + } + + [Fact] + public void SharedEnvCellGeometry_IsPinnedPerLandblockAndReleasedSymmetrically() + { + const ulong sharedGeometryId = 0x2_0000_1234ul; + var meshes = new ReadinessMeshAdapter(); + var adapter = new LandblockSpawnAdapter(meshes); + var first = new LoadedLandblock( + 0x1234FFFFu, + new LandBlock(), + Array.Empty()); + var second = new LoadedLandblock( + 0x1235FFFFu, + new LandBlock(), + Array.Empty()); + + adapter.OnLandblockLoaded(first, new[] { sharedGeometryId, sharedGeometryId }); + adapter.OnLandblockLoaded(second, new[] { sharedGeometryId }); + + Assert.Equal(2, meshes.ReferenceCounts[sharedGeometryId]); + Assert.Equal(2, meshes.PreparedPinCalls.Count); + + adapter.OnLandblockUnloaded(first.LandblockId); + Assert.Equal(1, meshes.ReferenceCounts[sharedGeometryId]); + + adapter.OnLandblockUnloaded(second.LandblockId); + Assert.DoesNotContain(sharedGeometryId, meshes.ReferenceCounts); + } + + private sealed class ReadinessMeshAdapter : IWbMeshAdapter + { + public HashSet ReadyIds { get; } = new(); + public Dictionary ReferenceCounts { get; } = new(); + public List PreparedPinCalls { get; } = new(); + public void IncrementRefCount(ulong id) => + ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1; + public void PinPreparedRenderData(ulong id) + { + PreparedPinCalls.Add(id); + IncrementRefCount(id); + } + public void DecrementRefCount(ulong id) + { + int count = ReferenceCounts[id] - 1; + if (count == 0) ReferenceCounts.Remove(id); + else ReferenceCounts[id] = count; + } + public bool IsRenderDataReady(ulong id) => ReadyIds.Contains(id); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs new file mode 100644 index 00000000..b64d60d5 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/MeshUploadCachesTests.cs @@ -0,0 +1,69 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Content; +using Chorizite.Core.Render.Enums; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class MeshUploadCachesTests +{ + [Fact] + public void CpuCacheHit_AfterCompletedUpload_ReStagesExactlyOnceWithTexturePayload() + { + var staging = new MeshUploadStagingQueue(); + var cache = new CpuMeshUploadCache(capacity: 2); + byte[] textureBytes = [1, 2, 3, 4]; + var data = new ObjectMeshData { ObjectId = 0x01000010ul, UploadAttempts = 2 }; + data.TextureBatches[(1, 1, TextureFormat.RGBA8)] = + [ + new TextureBatchData { TextureData = textureBytes }, + ]; + cache.Store(data); + + Assert.True(staging.Stage(data)); + Assert.True(staging.TryDequeue(out var initial)); + Assert.Same(data, initial); + staging.Complete(data.ObjectId); // GPU upload completed, then was later evicted. + + data.UploadAttempts = 3; + Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out var cached)); + Assert.Same(data, cached); + Assert.Equal(0, data.UploadAttempts); + Assert.Equal(textureBytes, cached!.TextureBatches.Values.Single().Single().TextureData); + Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out _)); + Assert.True(staging.TryDequeue(out var restaged)); + Assert.Same(data, restaged); + Assert.False(staging.TryDequeue(out _)); + } + + [Fact] + public void Retry_RetainsQueueOwnershipUntilCompletion() + { + var staging = new MeshUploadStagingQueue(); + var data = new ObjectMeshData { ObjectId = 0x01000010ul }; + + Assert.True(staging.Stage(data)); + Assert.True(staging.TryDequeue(out var attempt)); + staging.Requeue(attempt!); + Assert.False(staging.Stage(data)); + Assert.True(staging.TryDequeue(out var retry)); + Assert.Same(data, retry); + + staging.Complete(data.ObjectId); + Assert.True(staging.Stage(data)); + } + + [Fact] + public void UploadCompletion_NeverManufacturesLogicalOwnership() + { + const ulong id = 0x01000010ul; + var ownership = new MeshOwnershipCounter(); + + Assert.Equal(1, ownership.Acquire(id)); + Assert.True(ownership.MarkUploadComplete(id)); + Assert.Equal(1, ownership.Count(id)); + + Assert.Equal(0, ownership.Release(id)); + Assert.False(ownership.MarkUploadComplete(id)); // late upload after unload + Assert.Equal(0, ownership.Count(id)); + } +} diff --git a/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs new file mode 100644 index 00000000..302d2291 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs @@ -0,0 +1,437 @@ +using AcDream.App.Streaming; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using System.Collections.Immutable; + +namespace AcDream.App.Tests.Streaming; + +public sealed class StreamingControllerReadinessTests +{ + [Fact] + public void RenderNeighborhoodResident_RequiresEveryPublishedLandblockInRing() + { + var state = new GpuWorldState(); + StreamingController controller = CreateController(state); + + for (int dx = -1; dx <= 1; dx++) + for (int dy = -1; dy <= 1; dy++) + { + if (dx == 1 && dy == 1) + continue; + AddPublished(state, 0x12 + dx, 0x36 + dy); + } + + Assert.False(controller.IsRenderNeighborhoodResident(0x12360022u, 1)); + + AddPublished(state, 0x13, 0x37); + + Assert.True(controller.IsRenderNeighborhoodResident(0x12360022u, 1)); + } + + [Fact] + public void RenderNeighborhoodResident_RadiusZeroAcceptsEnvCellId() + { + var state = new GpuWorldState(); + StreamingController controller = CreateController(state); + AddPublished(state, 0x8C, 0x04); + + Assert.True(controller.IsRenderNeighborhoodResident(0x8C0401ADu, 0)); + Assert.False(controller.IsRenderNeighborhoodResident(0x8D0401ADu, 0)); + } + + [Fact] + public void RenderNeighborhoodResident_SkipsOffMapNeighborsLikePhysicsGate() + { + var state = new GpuWorldState(); + StreamingController controller = CreateController(state); + AddPublished(state, 0, 0); + AddPublished(state, 0, 1); + AddPublished(state, 1, 0); + AddPublished(state, 1, 1); + + Assert.True(controller.IsRenderNeighborhoodResident(0x00000001u, 1)); + } + + [Fact] + public void RenderNeighborhoodResident_RejectsNegativeRadius() + { + StreamingController controller = CreateController(new GpuWorldState()); + + Assert.Throws( + () => controller.IsRenderNeighborhoodResident(0x1236FFFFu, -1)); + } + + [Theory] + [InlineData(0xFF0401ADu)] + [InlineData(0x04FF01ADu)] + [InlineData(0x12360000u)] + [InlineData(0x12360041u)] + [InlineData(0x1236FFFEu)] + public void RenderNeighborhoodResident_RejectsInvalidMapEdgeDestination(uint cellId) + { + StreamingController controller = CreateController(new GpuWorldState()); + + Assert.False(controller.IsRenderNeighborhoodResident(cellId, 0)); + } + + [Fact] + public void RenderNeighborhoodResident_WaitsForActualMeshUpload() + { + var meshes = new ReadinessMeshAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + StreamingController controller = CreateController(state); + uint id = 0x1236FFFFu; + const ulong envCellGeometryId = 0x2_0000_1234ul; + var entity = new WorldEntity + { + Id = 1, + ServerGuid = 0, + SourceGfxObjOrSetupId = 0x01000010u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = new[] { new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity) }, + }; + state.AddLandblock( + new LoadedLandblock(id, new LandBlock(), new[] { entity }), + new[] { envCellGeometryId }); + + Assert.False(controller.IsRenderNeighborhoodResident(id, 0)); + meshes.ReadyIds.Add(0x01000010ul); + Assert.False(controller.IsRenderNeighborhoodResident(id, 0)); + meshes.ReadyIds.Add(envCellGeometryId); + Assert.True(controller.IsRenderNeighborhoodResident(id, 0)); + } + + [Fact] + public void RenderNeighborhoodResident_PreservesShellGateWhenPromotionPrecedesBaseLoad() + { + var meshes = new ReadinessMeshAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + StreamingController controller = CreateController(state); + uint id = 0x1236FFFFu; + const ulong envCellGeometryId = 0x2_0000_1234ul; + + state.AddEntitiesToExistingLandblock( + id, + Array.Empty(), + new[] { envCellGeometryId }); + state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty())); + + Assert.True(state.IsNearTier(id)); + Assert.False(controller.IsRenderNeighborhoodResident(id, 0)); + meshes.ReadyIds.Add(envCellGeometryId); + Assert.True(controller.IsRenderNeighborhoodResident(id, 0)); + } + + [Fact] + public void RenderNeighborhoodResident_FarTerrainDoesNotSatisfyPortalGate() + { + var meshes = new ReadinessMeshAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + StreamingController controller = CreateController(state); + uint id = 0x1236FFFFu; + const ulong envCellGeometryId = 0x2_0000_1234ul; + state.AddLandblock( + new LoadedLandblock(id, new LandBlock(), Array.Empty()), + tier: LandblockStreamTier.Far); + + Assert.False(controller.IsRenderNeighborhoodResident(id, 0)); + + state.AddEntitiesToExistingLandblock( + id, + Array.Empty(), + new[] { envCellGeometryId }); + Assert.False(controller.IsRenderNeighborhoodResident(id, 0)); + meshes.ReadyIds.Add(envCellGeometryId); + Assert.True(controller.IsRenderNeighborhoodResident(id, 0)); + } + + [Fact] + public void StaleFarPublication_DoesNotReplaceNearEntitiesOrReadiness() + { + var meshes = new ReadinessMeshAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + StreamingController controller = CreateController(state); + uint id = 0x1236FFFFu; + const ulong envCellGeometryId = 0x2_0000_1234ul; + var entity = new WorldEntity + { + Id = 1, + ServerGuid = 0, + SourceGfxObjOrSetupId = 0x01000010u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = [new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity)], + }; + meshes.ReadyIds.UnionWith([0x01000010ul, envCellGeometryId]); + state.AddLandblock( + new LoadedLandblock(id, new LandBlock(), new[] { entity }), + new[] { envCellGeometryId }, + LandblockStreamTier.Near); + Assert.True(controller.IsRenderNeighborhoodResident(id, 0)); + + state.AddLandblock( + new LoadedLandblock(id, new LandBlock(), Array.Empty()), + tier: LandblockStreamTier.Far); + + Assert.True(state.IsNearTier(id)); + Assert.Contains(entity, state.Entities); + Assert.True(controller.IsRenderNeighborhoodResident(id, 0)); + } + + [Fact] + public void EnvCellPublication_RearmsSpecializedPreparationAfterPin() + { + uint id = 0x1236FFFFu; + const ulong geometryId = 0x2_0000_1234ul; + var meshes = new ReadinessMeshAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var outbox = new Queue(); + int ensureCalls = 0; + var shell = new EnvCellShellPlacement( + 0x12360001u, + geometryId, + 0x0D000001u, + 2, + ImmutableArray.Create(3, 4), + System.Numerics.Vector3.Zero, + System.Numerics.Quaternion.Identity, + System.Numerics.Matrix4x4.Identity, + new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One), + new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One)); + var envCells = new EnvCellLandblockBuild( + id, + Array.Empty(), + new[] { shell }); + var build = new LandblockBuild( + new LoadedLandblock(id, new LandBlock(), Array.Empty()), + envCells); + outbox.Enqueue(new LandblockStreamResult.Loaded( + id, + LandblockStreamTier.Near, + build, + new AcDream.Core.Terrain.LandblockMeshData( + Array.Empty(), + Array.Empty()))); + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 0, + farRadius: 0, + ensureEnvCellMeshes: completed => + { + // The specialized request is replayed only after the synthetic + // id is pinned, closing existing-at-schedule -> evicted-before- + // publication without falling through generic GfxObj decode. + Assert.Equal(1, meshes.ReferenceCounts[geometryId]); + Assert.Equal(shell, Assert.Single(completed.Shells)); + ensureCalls++; + }); + + controller.Tick(0x12, 0x36); + + Assert.Equal(1, ensureCalls); + } + + [Fact] + public void PromotionWithoutBase_PublishesSelfContainedNearAndPinsBeforeReplay() + { + uint id = 0x1236FFFFu; + const ulong geometryId = 0x2_0000_5678ul; + var meshes = new ReadinessMeshAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var outbox = new Queue(); + int ensureCalls = 0; + var shell = new EnvCellShellPlacement( + 0x12360001u, + geometryId, + 0x0D000001u, + 2, + ImmutableArray.Create(3, 4), + System.Numerics.Vector3.Zero, + System.Numerics.Quaternion.Identity, + System.Numerics.Matrix4x4.Identity, + new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One), + new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One)); + var envCells = new EnvCellLandblockBuild( + id, + Array.Empty(), + new[] { shell }); + var nearBuild = new LandblockBuild( + new LoadedLandblock(id, new LandBlock(), Array.Empty()), + envCells); + var mesh = new AcDream.Core.Terrain.LandblockMeshData( + Array.Empty(), + Array.Empty()); + outbox.Enqueue(new LandblockStreamResult.Promoted(id, nearBuild, mesh)); + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 0, + farRadius: 0, + ensureEnvCellMeshes: completed => + { + Assert.Equal(1, meshes.ReferenceCounts[geometryId]); + Assert.Equal(shell, Assert.Single(completed.Shells)); + ensureCalls++; + }); + + controller.Tick(0x12, 0x36); + + Assert.Equal(1, ensureCalls); + Assert.Equal(1, meshes.ReferenceCounts[geometryId]); + Assert.True(state.IsNearTier(id)); + + // A Far job that had already started may still finish after the + // self-contained promotion. It must not replace or replay the Near tier. + var farBase = new LandblockBuild( + new LoadedLandblock(id, new LandBlock(), Array.Empty())); + outbox.Enqueue(new LandblockStreamResult.Loaded( + id, + LandblockStreamTier.Far, + farBase, + mesh)); + + controller.Tick(0x12, 0x36); + + Assert.Equal(1, ensureCalls); + Assert.Equal(1, meshes.ReferenceCounts[geometryId]); + Assert.True(state.IsNearTier(id)); + } + + [Fact] + public void PromotionBeforeBase_DemotedBeforeBase_DropsPendingNearPresentation() + { + uint id = 0x1236FFFFu; + const ulong geometryId = 0x2_0000_9ABCul; + var meshes = new ReadinessMeshAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var outbox = new Queue(); + int ensureCalls = 0; + var appliedBuilds = new List(); + var demotedWhileStaticPresent = new List(); + var shell = new EnvCellShellPlacement( + 0x12360001u, + geometryId, + 0x0D000001u, + 2, + ImmutableArray.Create(3, 4), + System.Numerics.Vector3.Zero, + System.Numerics.Quaternion.Identity, + System.Numerics.Matrix4x4.Identity, + new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One), + new WbBoundingBox(System.Numerics.Vector3.Zero, System.Numerics.Vector3.One)); + var envCells = new EnvCellLandblockBuild( + id, + Array.Empty(), + new[] { shell }); + var staticEntity = new WorldEntity + { + Id = 1, + ServerGuid = 0, + SourceGfxObjOrSetupId = 0x01000010u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = [new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity)], + }; + var nearBuild = new LandblockBuild( + new LoadedLandblock(id, new LandBlock(), new[] { staticEntity }), + envCells); + var mesh = new AcDream.Core.Terrain.LandblockMeshData( + Array.Empty(), + Array.Empty()); + outbox.Enqueue(new LandblockStreamResult.Promoted(id, nearBuild, mesh)); + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (build, _) => appliedBuilds.Add(build), + state: state, + nearRadius: 0, + farRadius: 3, + demoteNearLayer: demotedId => + { + Assert.Contains(staticEntity, state.Entities); + demotedWhileStaticPresent.Add(demotedId); + }, + ensureEnvCellMeshes: _ => ensureCalls++); + + controller.Tick(0x12, 0x36); // self-contained promotion publishes Near. + Assert.Single(appliedBuilds); + Assert.NotNull(appliedBuilds[0].EnvCells); + controller.Tick(0x12, 0x39); // normal Near->Far demotion retires presentation. + + var farBase = new LandblockBuild( + new LoadedLandblock(id, new LandBlock(), Array.Empty())); + outbox.Enqueue(new LandblockStreamResult.Loaded( + id, + LandblockStreamTier.Far, + farBase, + mesh)); + controller.Tick(0x12, 0x39); + + Assert.Equal(1, ensureCalls); + Assert.DoesNotContain(geometryId, meshes.ReferenceCounts); + Assert.DoesNotContain(staticEntity, state.Entities); + Assert.Equal(new[] { id }, demotedWhileStaticPresent); + Assert.True(state.IsLoaded(id)); + Assert.False(state.IsNearTier(id)); + Assert.Equal(2, appliedBuilds.Count); + Assert.Null(appliedBuilds[1].EnvCells); + Assert.Empty(appliedBuilds[1].Landblock.Entities); + } + + private static StreamingController CreateController(GpuWorldState state) + => new( + (_, _) => { }, + _ => { }, + _ => Array.Empty(), + (_, _) => { }, + state, + nearRadius: 1, + farRadius: 2); + + private static void AddPublished(GpuWorldState state, int x, int y) + { + uint id = ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu; + state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty())); + } + + private sealed class ReadinessMeshAdapter : IWbMeshAdapter + { + public HashSet ReadyIds { get; } = new(); + public Dictionary ReferenceCounts { get; } = new(); + public void IncrementRefCount(ulong id) => + ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1; + public void DecrementRefCount(ulong id) + { + int next = ReferenceCounts.GetValueOrDefault(id) - 1; + if (next <= 0) ReferenceCounts.Remove(id); + else ReferenceCounts[id] = next; + } + public bool IsRenderDataReady(ulong id) => ReadyIds.Contains(id); + } +} diff --git a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs index 2340d529..7942d1ac 100644 --- a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs +++ b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs @@ -1,3 +1,4 @@ +using System.Numerics; using AcDream.App.Rendering; using AcDream.App.World; using AcDream.Content.Vfx; @@ -57,7 +58,7 @@ public sealed class RecallTeleportAnimationTests } [Fact] - public void InstalledRecall_RetiresBeforeTeleportHiddenStateIsDispatched() + public void InstalledRecall_HiddenEnterWorldPublishesFinishedCyclicPoseWithoutAdvancingTime() { string datDir = @"C:\Turbine\Asheron's Call"; if (!File.Exists(Path.Combine(datDir, "client_portal.dat"))) @@ -81,38 +82,29 @@ public sealed class RecallTeleportAnimationTests aceDuration += (high - anim.LowFrame) / Math.Abs(anim.Framerate); } - const float updateStep = 1f / 60f; - double elapsed = 0.0; - bool hidden = false; - bool hiddenPacketReady = false; - var frame = new RetailLiveFrameCoordinator( - dt => - { - if (!hidden) - sequencer.Advance(dt); - }, - () => - { - if (hiddenPacketReady) - hidden = true; - }, - () => { }, - () => { }); - - // The recall motion is dispatched after an object's UseTime phase. - // On the first later frame whose timestamp reaches ACE's scheduled - // teleport boundary, retail advances the PartArray and only then - // consumes the Hidden SetState from the inbound queue. - while (elapsed < aceDuration) - { - hiddenPacketReady = elapsed + updateStep >= aceDuration; - frame.Tick(updateStep); - elapsed += updateStep; - } - - Assert.True(hidden); - Assert.True( + // Advance returns a reusable scratch buffer; snapshot the authored tail + // before HandleEnterWorld/SampleCurrentPose overwrites that buffer. + PartTransform[] recallTail = sequencer.Advance((float)aceDuration).ToArray(); + Assert.False( sequencer.CurrentNodeDiag.IsLooping, - "Recall must reach the Ready cyclic tail before Hidden freezes PartArray playback."); + "At ACE's exact teleport boundary the authored recall link is still the current node."); + + // set_hidden -> CPartArray::HandleEnterWorld -> + // MotionTableManager::HandleEnterWorld removes link animations and + // selects the first cyclic (Ready) node. Hidden then suppresses time + // advance, but set_frame/UpdateParts still samples this new pose. + sequencer.Manager.HandleEnterWorld(); + IReadOnlyList finishedPose = sequencer.SampleCurrentPose(); + + Assert.True(sequencer.CurrentNodeDiag.IsLooping); + Assert.Equal(recallTail.Length, finishedPose.Count); + Assert.Contains( + Enumerable.Range(0, finishedPose.Count), + index => Vector3.DistanceSquared( + recallTail[index].Origin, + finishedPose[index].Origin) > 1e-6f + || MathF.Abs(Quaternion.Dot( + Quaternion.Normalize(recallTail[index].Orientation), + Quaternion.Normalize(finishedPose[index].Orientation))) < 0.999999f); } } diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs index fed0ca92..de410194 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs @@ -66,15 +66,56 @@ public class PhysicsBodyCellSyncTests Assert.Equal(0u, body.CellPosition.ObjCellId); } + [Fact] + public void PositionDelta_InsideEnvCell_AdvancesCanonicalLocalFrame() + { + var body = new PhysicsBody(); + body.SnapToCell( + 0x8C0401ADu, + worldPos: new Vector3(12f, -30f, 4f), + cellLocal: new Vector3(80f, 40f, 4f)); + + body.Position += new Vector3(3f, -2f, 0.5f); + + Assert.Equal(0x8C0401ADu, body.CellPosition.ObjCellId); + Assert.Equal(new Vector3(83f, 38f, 4.5f), body.CellPosition.Frame.Origin); + } + + [Fact] + public void CommitTransitionPosition_InsideDungeon_AdoptsResolvedEnvCellAndFrame() + { + var body = new PhysicsBody(); + body.SnapToCell( + 0x8C0401ADu, + worldPos: new Vector3(12f, -30f, 4f), + cellLocal: new Vector3(80f, 40f, 4f)); + + body.CommitTransitionPosition( + 0x8C0401AEu, + new Vector3(17f, -27f, 4f)); + + Assert.Equal(0x8C0401AEu, body.CellPosition.ObjCellId); + Assert.Equal(new Vector3(85f, 43f, 4f), body.CellPosition.Frame.Origin); + Assert.Equal(new Vector3(17f, -27f, 4f), body.Position); + } + + [Fact] + public void CommitTransitionPosition_WithoutSeed_DoesNotInventCanonicalCell() + { + var body = new PhysicsBody(); + + body.CommitTransitionPosition(0x8C0401ADu, new Vector3(1f, 2f, 3f)); + + Assert.Equal(0u, body.CellPosition.ObjCellId); + Assert.Equal(new Vector3(1f, 2f, 3f), body.Position); + } + [Fact] public void ContinuousTracking_LongWalkAcrossCellsAndBlock_NeverGoesStale() { - // Refutes the "CellPosition goes stale" concern (incl. the indoor-transit case): - // because the Position setter re-derives the cell via AdjustToOutside on EVERY - // write, CellPosition continuously tracks the outdoor cell under the body's WORLD - // position. The body is always world-space, so the world delta is frame-invariant — - // there is no separate "interior" frame to desync. On any exit it is the correct - // cell, never stale. + // Refutes outdoor CellPosition drift: the Position setter re-derives the + // cell via AdjustToOutside on every write, so the carried (cell, local) + // pair continuously tracks the body across cells and landblocks. var body = new PhysicsBody(); body.SnapToCell(0xC95B0001u, new Vector3(1000f, 2000f, 12f), new Vector3(10f, 10f, 12f)); diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs index 3cb17aad..6032062f 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs @@ -1,6 +1,7 @@ using System; using System.Numerics; using AcDream.Core.Physics; +using AcDream.Core.World.Cells; using Xunit; namespace AcDream.Core.Tests.Physics; @@ -31,4 +32,123 @@ public class PhysicsEngineResidencyTests Assert.True(eng.IsLandblockTerrainResident(0xA9B40019u)); // cell-resolved id, same LB Assert.False(eng.IsLandblockTerrainResident(0xC6A90019u)); // different LB } + + [Fact] + public void DemoteLandblockToTerrain_RemovesEnvCellsButPreservesTerrainResidency() + { + const uint landblockId = 0xA9B4FFFFu; + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + engine.AddLandblock( + landblockId, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + cache.CellGraph.Add(new EnvCell( + 0xA9B40174u, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector3.Zero, + Vector3.One, + Array.Empty(), + Array.Empty(), + false, + null)); + + engine.DemoteLandblockToTerrain(landblockId); + + Assert.True(engine.IsLandblockTerrainResident(landblockId)); + Assert.Null(cache.CellGraph.GetVisible(0xA9B40174u)); + Assert.IsType(cache.CellGraph.GetVisible(0xA9B40001u)); + } + + [Fact] + public void DemoteLandblockToTerrain_DeregistersCrossBoundaryStaticButRetainsDynamic() + { + const uint landblockId = 0xA9B4FFFFu; + const uint adjacentCell = 0xAAB40001u; + const uint staticId = 100u; + const uint dynamicId = 200u; + var engine = new PhysicsEngine(); + engine.AddLandblock( + landblockId, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + var seamPosition = new Vector3(191.5f, 12f, 1f); + engine.ShadowObjects.Register( + staticId, + 0x01000001u, + seamPosition, + Quaternion.Identity, + 2f, + 0f, + 0f, + landblockId, + isStatic: true); + engine.ShadowObjects.Register( + dynamicId, + 0x01000002u, + seamPosition, + Quaternion.Identity, + 2f, + 0f, + 0f, + landblockId, + isStatic: false); + Assert.Contains( + engine.ShadowObjects.GetObjectsInCell(adjacentCell), + entry => entry.EntityId == staticId); + Assert.Contains( + engine.ShadowObjects.GetObjectsInCell(adjacentCell), + entry => entry.EntityId == dynamicId); + + engine.DemoteLandblockToTerrain(landblockId); + + Assert.DoesNotContain( + engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == staticId); + Assert.Contains( + engine.ShadowObjects.GetObjectsInCell(adjacentCell), + entry => entry.EntityId == dynamicId); + Assert.Equal(1, engine.ShadowObjects.RetainedRegistrationCount); + } + + [Fact] + public void RemoveLandblock_DeregistersCrossBoundaryStaticButRetainsDynamic() + { + const uint landblockId = 0xA9B4FFFFu; + const uint adjacentCell = 0xAAB40001u; + const uint staticId = 300u; + const uint dynamicId = 400u; + var engine = new PhysicsEngine(); + engine.AddLandblock( + landblockId, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + var seamPosition = new Vector3(191.5f, 12f, 1f); + engine.ShadowObjects.Register( + staticId, 0x01000003u, seamPosition, Quaternion.Identity, 2f, + 0f, 0f, landblockId, isStatic: true); + engine.ShadowObjects.Register( + dynamicId, 0x01000004u, seamPosition, Quaternion.Identity, 2f, + 0f, 0f, landblockId, isStatic: false); + + engine.RemoveLandblock(landblockId); + + Assert.DoesNotContain( + engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == staticId); + Assert.Contains( + engine.ShadowObjects.GetObjectsInCell(adjacentCell), + entry => entry.EntityId == dynamicId); + Assert.Equal(1, engine.ShadowObjects.RetainedRegistrationCount); + } } diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index 1f4556a2..4673fb83 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -207,6 +207,132 @@ public class ShadowObjectRegistryTests entry => entry.EntityId == entityId); } + [Fact] + public void RefloodLandblock_RestoresAdjacentOwnedFootprintWithdrawnByUnload() + { + const uint adjacentOwnerLb = 0xAAB40000u; + const uint ownerCell = adjacentOwnerLb | 1u; + const uint touchedCell = LbId | 57u; // west block cell (7,0) + const uint entityId = 32u; + var reg = new ShadowObjectRegistry(); + var cache = new PhysicsDataCache(); + cache.CellGraph.RegisterTerrain( + adjacentOwnerLb, + new TerrainSurface(new byte[81], new float[256]), + new Vector3(192f, 0f, 0f)); + reg.DataCache = cache; + reg.Register( + entityId, + 0x01000006u, + new Vector3(192.5f, 12f, 50f), + Quaternion.Identity, + 2f, + 192f, + 0f, + adjacentOwnerLb, + seedCellId: ownerCell, + isStatic: false); + Assert.Contains(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId); + Assert.Contains(reg.GetObjectsInCell(ownerCell), e => e.EntityId == entityId); + + reg.RemoveLandblock(LbId); + Assert.DoesNotContain(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId); + Assert.Contains(reg.GetObjectsInCell(ownerCell), e => e.EntityId == entityId); + Assert.Equal(1, reg.WithdrawnPrefixMarkerCount); + + reg.RefloodLandblock(LbId); + + Assert.Contains(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId); + Assert.Contains(reg.GetObjectsInCell(ownerCell), e => e.EntityId == entityId); + Assert.Equal(0, reg.WithdrawnPrefixMarkerCount); + } + + [Fact] + public void AuthoritativeMove_ClearsObsoleteWithdrawnPrefix() + { + const uint adjacentOwnerLb = 0xAAB40000u; + const uint ownerCell = adjacentOwnerLb | 1u; + const uint touchedCell = LbId | 57u; + const uint entityId = 33u; + var cache = new PhysicsDataCache(); + cache.CellGraph.RegisterTerrain( + adjacentOwnerLb, + new TerrainSurface(new byte[81], new float[256]), + new Vector3(192f, 0f, 0f)); + var reg = new ShadowObjectRegistry { DataCache = cache }; + reg.Register( + entityId, 0x01000007u, new Vector3(192.5f, 12f, 50f), + Quaternion.Identity, 2f, 192f, 0f, adjacentOwnerLb, + seedCellId: ownerCell, isStatic: false); + reg.RemoveLandblock(LbId); + Assert.Equal(1, reg.WithdrawnPrefixMarkerCount); + + reg.UpdatePosition( + entityId, + new Vector3(204f, 12f, 50f), + Quaternion.Identity, + 192f, + 0f, + adjacentOwnerLb, + seedCellId: ownerCell); + + Assert.Equal(0, reg.WithdrawnPrefixMarkerCount); + reg.RefloodLandblock(LbId); + Assert.DoesNotContain(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId); + } + + [Fact] + public void RefloodLandblock_RetiresOnlyCurrentMarker_WhenTwoPrefixesWereWithdrawn() + { + const uint ownerLb = 0xAAB50000u; + const uint ownerCell = ownerLb | 1u; + const uint westLb = 0xA9B50000u; + const uint southLb = 0xAAB40000u; + const uint westCell = westLb | 57u; + const uint southCell = southLb | 8u; + const uint entityId = 35u; + var cache = new PhysicsDataCache(); + cache.CellGraph.RegisterTerrain( + ownerLb, + new TerrainSurface(new byte[81], new float[256]), + new Vector3(192f, 192f, 0f)); + var reg = new ShadowObjectRegistry { DataCache = cache }; + reg.Register( + entityId, 0x01000009u, new Vector3(192.5f, 192.5f, 50f), + Quaternion.Identity, 2f, 192f, 192f, ownerLb, + seedCellId: ownerCell, isStatic: false); + Assert.Contains(reg.GetObjectsInCell(westCell), e => e.EntityId == entityId); + Assert.Contains(reg.GetObjectsInCell(southCell), e => e.EntityId == entityId); + + reg.RemoveLandblock(westLb); + reg.RemoveLandblock(southLb); + Assert.Equal(2, reg.WithdrawnPrefixMarkerCount); + + reg.RefloodLandblock(westLb); + Assert.Equal(1, reg.WithdrawnPrefixMarkerCount); + + reg.RefloodLandblock(southLb); + Assert.Equal(0, reg.WithdrawnPrefixMarkerCount); + Assert.Contains(reg.GetObjectsInCell(westCell), e => e.EntityId == entityId); + Assert.Contains(reg.GetObjectsInCell(southCell), e => e.EntityId == entityId); + } + + [Fact] + public void RemoveLandblock_DirectStaticRetirement_ClearsWithdrawnMarker() + { + const uint entityId = 34u; + var reg = new ShadowObjectRegistry(); + reg.Register( + entityId, 0x01000008u, new Vector3(12f, 12f, 50f), + Quaternion.Identity, 1f, OffX, OffY, LbId, + isStatic: true); + + reg.RemoveLandblock(LbId); + + Assert.Equal(0, reg.RetainedRegistrationCount); + Assert.Equal(0, reg.WithdrawnPrefixMarkerCount); + } + // ----------------------------------------------------------------------- // Per-cell query surface (BR-7 / A6.P4 2026-06-11): GetObjectsInCell IS // the query — retail CObjCell::find_obj_collisions iterates only the diff --git a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs index da29611b..666b7507 100644 --- a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs @@ -29,7 +29,7 @@ public class LandblockStreamerTests buildMeshOrNull: (_, _) => stubMesh); streamer.Start(); - streamer.EnqueueLoad(0xA9B4FFFEu); + streamer.EnqueueLoad(0xA9B4FFFEu, generation: 42); // Spin until the worker produces a completion, with a 2s timeout. LandblockStreamResult? result = null; @@ -43,6 +43,7 @@ public class LandblockStreamerTests Assert.NotNull(result); var loaded = Assert.IsType(result); Assert.Equal(0xA9B4FFFEu, loaded.LandblockId); + Assert.Equal(42ul, loaded.Generation); Assert.Same(stubLandblock, loaded.Landblock); } @@ -247,7 +248,7 @@ public class LandblockStreamerTests using var streamer = new LandblockStreamer(loadLandblock: _ => null); streamer.Start(); - streamer.EnqueueUnload(0xABCD0000u); + streamer.EnqueueUnload(0xABCD0000u, generation: 43); LandblockStreamResult? result = null; for (int i = 0; i < SpinMaxIterations && result is null; i++) @@ -259,6 +260,7 @@ public class LandblockStreamerTests var unloaded = Assert.IsType(result); Assert.Equal(0xABCD0000u, unloaded.LandblockId); + Assert.Equal(43ul, unloaded.Generation); } [Fact] diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs index bfc76bcf..c65e331c 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs @@ -163,6 +163,22 @@ public class StreamingControllerDungeonGateTests Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); } + [Fact] + public void PreCollapse_CenterResidentOnlyAsFarTier_EnqueuesPromotion() + { + var h = Make(); + uint center = Encode(0, 7); + h.State.AddLandblock(MakeLb(0, 7), tier: LandblockStreamTier.Far); + + h.Ctrl.PreCollapseToDungeon(0, 7); + + Assert.False(h.State.IsNearTier(center)); + Assert.Contains(h.Loads, load => + load.Id == center && load.Kind == LandblockStreamJobKind.PromoteToNear); + Assert.DoesNotContain(h.Loads, load => + load.Id == center && load.Kind == LandblockStreamJobKind.LoadNear); + } + [Fact] public void InitializeKnownLoginCenter_DungeonPerformsOneCollapseWithoutReloadChurn() { diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs index 4668b867..da4ed6d3 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using AcDream.App.Streaming; using AcDream.Core.Terrain; using AcDream.Core.World; @@ -10,10 +11,11 @@ namespace AcDream.Core.Tests.Streaming; public class StreamingControllerPriorityApplyTests { - private static LandblockStreamResult.Loaded LoadedOf(uint canonicalId) + private static LandblockStreamResult.Loaded LoadedOf(uint canonicalId, ulong generation = 0) => new(canonicalId, LandblockStreamTier.Near, new LoadedLandblock(canonicalId, new LandBlock(), Array.Empty()), - new LandblockMeshData(Array.Empty(), Array.Empty())); + new LandblockMeshData(Array.Empty(), Array.Empty()), + generation); [Fact] public void PriorityLandblock_isApplied_evenWhenBeyondPerFrameCap() @@ -21,10 +23,10 @@ public class StreamingControllerPriorityApplyTests uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); var outbox = new Queue(new LandblockStreamResult[] { - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 181)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 182)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 183)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 184)), LoadedOf(priority), }); @@ -58,11 +60,11 @@ public class StreamingControllerPriorityApplyTests uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); var outbox = new Queue(new LandblockStreamResult[] { - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 4)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 181)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 182)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 183)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 184)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 185)), LoadedOf(priority), }); var applied = new List(); @@ -101,9 +103,9 @@ public class StreamingControllerPriorityApplyTests // as the normal throttle, just relocated — until the caller clears // PriorityLandblockId (the TAS MaxContinue safety net does this on timeout). uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); - uint otherId0 = StreamingRegion.EncodeLandblockIdForTest(1, 0); - uint otherId1 = StreamingRegion.EncodeLandblockIdForTest(1, 1); - uint otherId2 = StreamingRegion.EncodeLandblockIdForTest(1, 2); + uint otherId0 = StreamingRegion.EncodeLandblockIdForTest(169, 181); + uint otherId1 = StreamingRegion.EncodeLandblockIdForTest(169, 182); + uint otherId2 = StreamingRegion.EncodeLandblockIdForTest(169, 183); var outbox = new Queue(new LandblockStreamResult[] { @@ -146,4 +148,417 @@ public class StreamingControllerPriorityApplyTests // (c) no double-apply: applied count matches completions enqueued Assert.Equal(3, applied.Count); } + + [Fact] + public void ForceReloadWindow_DiscardsBufferedCompletionsFromOldWindow() + { + uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); + var outbox = new Queue( + Enumerable.Range(0, 6) + .Select(i => (LandblockStreamResult)LoadedOf( + StreamingRegion.EncodeLandblockIdForTest(169, 181 + i)))); + var applied = new List(); + var state = new GpuWorldState(); + var ctrl = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (build, _) => applied.Add(build.LandblockId), + state: state, + nearRadius: 4, + farRadius: 12) + { MaxCompletionsPerFrame = 4, PriorityLandblockId = priority }; + + ctrl.Tick(169, 180); + Assert.Equal(4, applied.Count); + applied.Clear(); + + ctrl.ForceReloadWindow(); + ctrl.Tick(169, 180); + + Assert.Empty(applied); + } + + [Fact] + public void ForceReloadWindow_RejectsLateInFlightCompletionFromOldRegion() + { + uint oldId = StreamingRegion.EncodeLandblockIdForTest(10, 10); + var outbox = new Queue(); + var applied = new List(); + var state = new GpuWorldState(); + var ctrl = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (build, _) => applied.Add(build.LandblockId), + state: state, + nearRadius: 1, + farRadius: 2); + + ctrl.Tick(10, 10); + ctrl.ForceReloadWindow(); + outbox.Enqueue(LoadedOf(oldId)); + + ctrl.Tick(100, 100); + + Assert.Empty(applied); + Assert.False(state.IsLoaded(oldId)); + } + + [Fact] + public void DungeonCollapseBeforePromotionBase_RetiresProvisionalTerrainAndPendingStatics() + { + uint outdoorId = StreamingRegion.EncodeLandblockIdForTest(10, 10); + uint dungeonId = StreamingRegion.EncodeLandblockIdForTest(20, 20); + var staticEntity = new WorldEntity + { + Id = 77, + ServerGuid = 0, + SourceGfxObjOrSetupId = 0x01000077u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = [new MeshRef(0x01000077u, System.Numerics.Matrix4x4.Identity)], + }; + var mesh = new LandblockMeshData(Array.Empty(), Array.Empty()); + var promotedBuild = new LandblockBuild( + new LoadedLandblock(outdoorId, new LandBlock(), new[] { staticEntity })); + var outbox = new Queue(); + outbox.Enqueue(new LandblockStreamResult.Promoted( + outdoorId, + promotedBuild, + mesh, + Generation: 0)); + var terrainApplied = new List(); + var terrainRemoved = new List(); + var state = new GpuWorldState(); + var unloads = new List<(uint Id, ulong Generation)>(); + var ctrl = new StreamingController( + enqueueLoad: (_, _, _) => { }, + enqueueUnload: (id, generation) => unloads.Add((id, generation)), + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (build, _) => terrainApplied.Add(build.LandblockId), + state: state, + nearRadius: 0, + farRadius: 2, + removeTerrain: id => terrainRemoved.Add(id)); + + ctrl.Tick(10, 10); // generation 0 promotion supersedes its queued Far base. + Assert.Equal(new[] { outdoorId }, terrainApplied); + Assert.True(state.IsLoaded(outdoorId)); + Assert.True(state.IsNearTier(outdoorId)); + + ctrl.Tick(20, 20, insideDungeon: true); // hard generation 1 boundary. + var outdoorUnload = Assert.Single(unloads, unload => unload.Id == outdoorId); + outbox.Enqueue(new LandblockStreamResult.Unloaded( + outdoorId, + outdoorUnload.Generation)); + ctrl.Tick(20, 20, insideDungeon: true); + + Assert.Equal(new[] { outdoorId }, terrainRemoved); + + // Return to the old location in generation 2 and publish a clean base. + // No static presentation from the abandoned generation may merge into it. + ctrl.Tick(10, 10, insideDungeon: false); + outbox.Enqueue(new LandblockStreamResult.Loaded( + outdoorId, + LandblockStreamTier.Near, + new LoadedLandblock(outdoorId, new LandBlock(), Array.Empty()), + mesh, + generation: 2)); + ctrl.Tick(10, 10); + + Assert.True(state.IsLoaded(outdoorId)); + Assert.DoesNotContain(staticEntity, state.Entities); + Assert.Equal(2, terrainApplied.Count(id => id == outdoorId)); + } + + [Fact] + public void LatePromotion_IsRejectedAfterRegionDemotesTargetToFar() + { + uint target = StreamingRegion.EncodeLandblockIdForTest(10, 13); + var outbox = new Queue(); + var loads = new List<(uint Id, LandblockStreamJobKind Kind)>(); + var applied = new List(); + var state = new GpuWorldState(); + state.AddLandblock( + new LoadedLandblock(target, new LandBlock(), Array.Empty()), + tier: LandblockStreamTier.Far); + var ctrl = new StreamingController( + enqueueLoad: (id, kind) => loads.Add((id, kind)), + enqueueUnload: _ => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (build, _) => applied.Add(build.LandblockId), + state: state, + nearRadius: 0, + farRadius: 3); + + ctrl.Tick(10, 10); // target starts Far. + ctrl.Tick(10, 13); // target becomes Near; promotion is now in flight. + Assert.Contains(loads, load => + load.Id == target && load.Kind == LandblockStreamJobKind.PromoteToNear); + ctrl.Tick(10, 10); // target is demoted back to Far before completion. + + var promotedEntity = new WorldEntity + { + Id = 1, + ServerGuid = 0, + SourceGfxObjOrSetupId = 0x01000010u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = [new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity)], + }; + var promotedLandblock = new LoadedLandblock( + target, + new LandBlock(), + new[] { promotedEntity }); + outbox.Enqueue(new LandblockStreamResult.Promoted( + target, + promotedLandblock, + new LandblockMeshData(Array.Empty(), Array.Empty()))); + + ctrl.Tick(10, 10); + + Assert.True(state.IsLoaded(target)); + Assert.False(state.IsNearTier(target)); + Assert.DoesNotContain(promotedEntity, state.Entities); + Assert.Empty(applied); + } + + [Fact] + public void DuplicateNearCompletions_PublishExactlyOnce() + { + uint id = StreamingRegion.EncodeLandblockIdForTest(10, 10); + var entity = new WorldEntity + { + Id = 91, + ServerGuid = 0, + SourceGfxObjOrSetupId = 0x01000091u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = [new MeshRef(0x01000091u, System.Numerics.Matrix4x4.Identity)], + }; + var build = new LandblockBuild( + new LoadedLandblock(id, new LandBlock(), new[] { entity })); + var mesh = new LandblockMeshData(Array.Empty(), Array.Empty()); + var outbox = new Queue(new LandblockStreamResult[] + { + new LandblockStreamResult.Promoted(id, build, mesh), + new LandblockStreamResult.Promoted(id, build, mesh), + }); + var applied = new List(); + var loadedCallbacks = new List(); + var state = new GpuWorldState(); + var ctrl = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (completed, _) => applied.Add(completed.LandblockId), + state: state, + nearRadius: 0, + farRadius: 0, + onLandblockLoaded: loadedCallbacks.Add); + + ctrl.Tick(10, 10); + + Assert.Equal(new[] { id }, applied); + Assert.Equal(new[] { id }, loadedCallbacks); + Assert.Single(state.Entities, existing => ReferenceEquals(existing, entity)); + } + + [Fact] + public void InFlightNearLoad_DemotedBeforeFirstCompletion_PublishesTerrainAsFar() + { + uint target = StreamingRegion.EncodeLandblockIdForTest(10, 10); + var outbox = new Queue(); + var applied = new List(); + var state = new GpuWorldState(); + var ctrl = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (build, _) => applied.Add(build), + state: state, + nearRadius: 0, + farRadius: 3); + + ctrl.Tick(10, 10); // target's initial LoadNear is now in flight. + ctrl.Tick(10, 13); // target becomes desired Far before it completes. + + var nearEntity = new WorldEntity + { + Id = 1, + ServerGuid = 0, + SourceGfxObjOrSetupId = 0x01000010u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = [new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity)], + }; + var nearLandblock = new LoadedLandblock( + target, + new LandBlock(), + new[] { nearEntity }); + outbox.Enqueue(new LandblockStreamResult.Loaded( + target, + LandblockStreamTier.Near, + nearLandblock, + new LandblockMeshData(Array.Empty(), Array.Empty()))); + + ctrl.Tick(10, 13); + + Assert.Single(applied); + Assert.Equal(target, applied[0].LandblockId); + Assert.Empty(applied[0].Landblock.Entities); + Assert.Null(applied[0].EnvCells); + Assert.True(state.IsLoaded(target)); + Assert.False(state.IsNearTier(target)); + Assert.True(state.TryGetLandblock(target, out var resident)); + Assert.Empty(resident!.Entities); + } + + [Fact] + public void HardRecenter_RejectsOldOverlappingLoadAndUnloadGenerations() + { + uint overlap = StreamingRegion.EncodeLandblockIdForTest(10, 10); + var outbox = new Queue(); + var loads = new List<(uint Id, LandblockStreamJobKind Kind, ulong Generation)>(); + var applied = new List(); + var state = new GpuWorldState(); + var ctrl = new StreamingController( + enqueueLoad: (id, kind, generation) => loads.Add((id, kind, generation)), + enqueueUnload: (_, _) => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (build, _) => applied.Add(build.LandblockId), + state: state, + nearRadius: 1, + farRadius: 2); + + ctrl.Tick(10, 10); + ulong oldGeneration = loads.Single(load => load.Id == overlap).Generation; + + ctrl.ForceReloadWindow(); + loads.Clear(); + ctrl.Tick(11, 10); // overlap remains desired in the replacement window. + ulong newGeneration = loads.Single(load => load.Id == overlap).Generation; + Assert.NotEqual(oldGeneration, newGeneration); + + outbox.Enqueue(LoadedOf(overlap, oldGeneration)); + outbox.Enqueue(LoadedOf(overlap, newGeneration)); + outbox.Enqueue(new LandblockStreamResult.Unloaded(overlap, oldGeneration)); + + ctrl.Tick(11, 10); + + Assert.Equal(new[] { overlap }, applied); + Assert.True(state.IsLoaded(overlap)); + Assert.True(state.IsNearTier(overlap)); + } + + [Fact] + public void HardRecenter_DropsStaleOutboxBeforeDeferredApplyBudget() + { + uint current = StreamingRegion.EncodeLandblockIdForTest(11, 10); + var outbox = new Queue(); + var loads = new List<(uint Id, LandblockStreamJobKind Kind, ulong Generation)>(); + var applied = new List(); + var ctrl = new StreamingController( + enqueueLoad: (id, kind, generation) => loads.Add((id, kind, generation)), + enqueueUnload: (_, _) => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (build, _) => applied.Add(build.LandblockId), + state: new GpuWorldState(), + nearRadius: 1, + farRadius: 2) + { MaxCompletionsPerFrame = 1 }; + + ctrl.Tick(10, 10); + ulong oldGeneration = loads[0].Generation; + ctrl.ForceReloadWindow(); + loads.Clear(); + ctrl.Tick(11, 10); + ulong newGeneration = loads.Single(load => load.Id == current).Generation; + + for (int i = 0; i < 12; i++) + outbox.Enqueue(LoadedOf( + StreamingRegion.EncodeLandblockIdForTest(10, 10 + i), + oldGeneration)); + outbox.Enqueue(LoadedOf(current, newGeneration)); + + ctrl.Tick(11, 10); + + Assert.Equal(new[] { current }, applied); + Assert.Equal(0, ctrl.DeferredApplyBacklog); + } + + [Fact] + public void NormalRecenter_AwayThenBack_DiscardsCompletedUnloadForReownedId() + { + uint target = StreamingRegion.EncodeLandblockIdForTest(10, 10); + var outbox = new Queue(); + var state = new GpuWorldState(); + var ctrl = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 0, + farRadius: 0); + + ctrl.Tick(10, 10); + state.AddLandblock( + new LoadedLandblock(target, new LandBlock(), Array.Empty()), + tier: LandblockStreamTier.Near); + + ctrl.Tick(10, 13); // outside hysteresis: unload is now in flight. + ctrl.Tick(10, 10); // target is re-owned before unload completion drains. + outbox.Enqueue(new LandblockStreamResult.Unloaded(target)); + + ctrl.Tick(10, 10); + + Assert.True(state.IsLoaded(target)); + } } diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs index b7914ccc..f871ee0e 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs @@ -95,7 +95,7 @@ public class StreamingControllerTests } [Fact] - public void DrainingUnloadedResult_RemovesFromState() + public void DrainingUnloadedResult_RemovesUnownedLandblockFromState() { var state = new GpuWorldState(); var fake = new FakeStreamer(); @@ -103,7 +103,10 @@ public class StreamingControllerTests fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, (_, _) => { }, state, nearRadius: 2, farRadius: 2); - const uint landblockId = 0x3232FFFFu; + // The current region is centered on 0x3232. A completed unload for a + // landblock it no longer owns must remove state; an unload for 0x3232 + // itself is now correctly rejected by the away->back lifecycle gate. + const uint landblockId = 0x2020FFFFu; var lb = new LoadedLandblock(landblockId, new LandBlock(), System.Array.Empty()); state.AddLandblock(lb); fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(landblockId)); diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs index 0acab3bd..4afb623c 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs @@ -96,7 +96,7 @@ public class StreamingControllerTwoTierTests // AddEntitiesToExistingLandblock canonicalizes incoming ids. uint lbId = 0x3232FFFFu; var lb = new LoadedLandblock(lbId, Heightmap: null!, Entities: System.Array.Empty()); - state.AddLandblock(lb); + state.AddLandblock(lb, tier: LandblockStreamTier.Far); Assert.Empty(state.Entities); // Streamer pushes a Promoted result carrying the full near landblock. diff --git a/tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs b/tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs index ce987e12..e7944c8f 100644 --- a/tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs +++ b/tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs @@ -56,6 +56,19 @@ public class CellGraphTests Assert.Null(g.GetVisible(0xA9B40014u)); } + [Fact] + public void RemoveEnvCellsForLandblock_PreservesTerrain() + { + var g = new CellGraph(); + g.Add(Env(0xA9B40174u)); + g.RegisterTerrain(0xA9B40000u, FlatTerrain(), Vector3.Zero); + + g.RemoveEnvCellsForLandblock(0xA9B4FFFFu); + + Assert.Null(g.GetVisible(0xA9B40174u)); + Assert.IsType(g.GetVisible(0xA9B40014u)); + } + [Fact] public void Neighbor_ResolvesPortalOtherCellId() { diff --git a/tests/AcDream.Core.Tests/World/ProceduralSceneryIdAllocatorTests.cs b/tests/AcDream.Core.Tests/World/ProceduralSceneryIdAllocatorTests.cs new file mode 100644 index 00000000..2ea01a47 --- /dev/null +++ b/tests/AcDream.Core.Tests/World/ProceduralSceneryIdAllocatorTests.cs @@ -0,0 +1,54 @@ +using AcDream.Core.World; + +namespace AcDream.Core.Tests.World; + +public sealed class ProceduralSceneryIdAllocatorTests +{ + [Fact] + public void Base_AlwaysSetsSceneryBitAndAvoidsStabPrefix() + { + for (uint y = 0; y <= 255; y += 51) + for (uint x = 0; x <= 255; x += 51) + { + uint value = ProceduralSceneryIdAllocator.Base(x, y); + Assert.NotEqual(0u, value & 0x80000000u); + Assert.NotEqual(0xC0000000u, value & 0xC0000000u); + } + } + + [Fact] + public void MoreThan256SceneryEntriesRemainInOwningLandblockRange() + { + uint id = ProceduralSceneryIdAllocator.Base(0x12u, 0x36u) + 300u; + + Assert.True(id < ProceduralSceneryIdAllocator.Base(0x12u, 0x37u)); + Assert.NotEqual(0u, id & 0x80000000u); + } + + [Fact] + public void MaximumCounterDoesNotAliasAdjacentLandblock() + { + for (uint y = 0; y < 255; y++) + { + uint current = ProceduralSceneryIdAllocator.Base(0, y); + uint next = ProceduralSceneryIdAllocator.Base(0, y + 1); + Assert.True(current + ProceduralSceneryIdAllocator.MaxCounter < next); + } + } + + [Fact] + public void Allocate_AcceptsLastCounterThenFailsBeforeNamespaceWrap() + { + uint counter = ProceduralSceneryIdAllocator.MaxCounter; + + uint last = ProceduralSceneryIdAllocator.Allocate(0x12u, 0x36u, ref counter); + + Assert.Equal( + ProceduralSceneryIdAllocator.Base(0x12u, 0x36u) + + ProceduralSceneryIdAllocator.MaxCounter, + last); + InvalidDataException error = Assert.Throws( + () => ProceduralSceneryIdAllocator.Allocate(0x12u, 0x36u, ref counter)); + Assert.Contains("4096-entry", error.Message, StringComparison.Ordinal); + } +}