fix(portal): synchronize destination presentation state
This commit is contained in:
parent
4b1bceefbb
commit
e95f55f25b
42 changed files with 2815 additions and 288 deletions
|
|
@ -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
|
## #217 — Character windows did not receive live 64-bit experience updates
|
||||||
|
|
||||||
**Status:** DONE — 2026-07-13, connected gate user-confirmed
|
**Status:** DONE — 2026-07-13, connected gate user-confirmed
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| # | 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-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-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-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
|
||||||
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
|
| AD-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) |
|
||||||
|
|
|
||||||
|
|
@ -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
|
and draw ordering are acdream-owned ports of `gmSmartBoxUI`; WorldBuilder never
|
||||||
implemented this UI viewport.
|
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
|
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling
|
||||||
algorithm, **check this inventory first**. If we already extracted it (🟢
|
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
|
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 |
|
| `SceneryHelpers` | Displace / RotateObj / ScaleObj / ObjAlign / CheckSlope |
|
||||||
| `SceneryInstance` | Per-spawn instance data |
|
| `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)
|
### Static objects (buildings, slabs, props — Setup + GfxObj + ObjDesc)
|
||||||
|
|
||||||
| Component | What it does |
|
| Component | What it does |
|
||||||
|
|
|
||||||
|
|
@ -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
|
3. **Pending user visual gate:** connected projectile/self-buff casts plus the
|
||||||
complete retained magic UI interaction checklist.
|
complete retained magic UI interaction checklist.
|
||||||
4. **Pending user visual gate:** two-client portal-out/materialization observer
|
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
|
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.
|
the active slice only when they block this demo or represent a severe regression.
|
||||||
|
|
|
||||||
|
|
@ -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
|
confirmed `/ls` and spell-recall arrivals no longer replay their animation
|
||||||
tails; additional portal visual polish is explicitly deferred.
|
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)
|
#### (historical M1.5 working notes below)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## DIVERGENCES
|
||||||
|
|
||||||
|
|
|
||||||
228
docs/research/2026-07-16-portal-completion-pseudocode.md
Normal file
228
docs/research/2026-07-16-portal-completion-pseudocode.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -1133,7 +1133,7 @@ public sealed class PlayerMovementController
|
||||||
body: _body,
|
body: _body,
|
||||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||||
movingEntityId: LocalEntityId);
|
movingEntityId: LocalEntityId);
|
||||||
_body.Position = resolved.Position;
|
_body.CommitTransitionPosition(resolved.CellId, resolved.Position);
|
||||||
PhysicsObjUpdate.CommitSetPositionTransition(
|
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||||
_body,
|
_body,
|
||||||
resolved.InContact,
|
resolved.InContact,
|
||||||
|
|
@ -1660,7 +1660,7 @@ public sealed class PlayerMovementController
|
||||||
bool prevContact = _body.InContact;
|
bool prevContact = _body.InContact;
|
||||||
bool prevOnWalkable = _body.OnWalkable;
|
bool prevOnWalkable = _body.OnWalkable;
|
||||||
|
|
||||||
_body.Position = resolveResult.Position;
|
_body.CommitTransitionPosition(resolveResult.CellId, resolveResult.Position);
|
||||||
if (physicsTickRan)
|
if (physicsTickRan)
|
||||||
{
|
{
|
||||||
_prevPhysicsPos = oldTickEndPos;
|
_prevPhysicsPos = oldTickEndPos;
|
||||||
|
|
|
||||||
|
|
@ -2714,8 +2714,8 @@ public sealed class GameWindow : IDisposable
|
||||||
_streamer.Start();
|
_streamer.Start();
|
||||||
|
|
||||||
_streamingController = new AcDream.App.Streaming.StreamingController(
|
_streamingController = new AcDream.App.Streaming.StreamingController(
|
||||||
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
|
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation),
|
||||||
enqueueUnload: _streamer.EnqueueUnload,
|
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
|
||||||
drainCompletions: _streamer.DrainCompletions,
|
drainCompletions: _streamer.DrainCompletions,
|
||||||
applyTerrain: ApplyLoadedTerrain,
|
applyTerrain: ApplyLoadedTerrain,
|
||||||
state: _worldState,
|
state: _worldState,
|
||||||
|
|
@ -2743,10 +2743,12 @@ public sealed class GameWindow : IDisposable
|
||||||
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
|
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
|
||||||
_envCellRenderer?.RemoveLandblock(id); // Phase A8
|
_envCellRenderer?.RemoveLandblock(id); // Phase A8
|
||||||
},
|
},
|
||||||
|
demoteNearLayer: DemoteLoadedLandblockToTerrain,
|
||||||
// #138: restore retained server objects when a landblock reloads
|
// #138: restore retained server objects when a landblock reloads
|
||||||
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
|
// (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.
|
// 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.
|
// A.5 T22.5: apply max-completions from resolved quality.
|
||||||
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
|
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
|
||||||
|
|
||||||
|
|
@ -7058,14 +7060,12 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// worldReady for the TAS transit: is the player's teleport destination resident so we
|
/// worldReady for the TAS transit: is the player's teleport destination BOTH rendered
|
||||||
/// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell
|
/// and collidable so we can materialize? Retail crosses this edge after its blocking cell
|
||||||
/// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around
|
/// load. acdream's async equivalent must join the render publication barrier
|
||||||
/// the player, <see cref="TeleportNearRingRadius"/>) being registered — not just the
|
/// (<see cref="StreamingController.IsRenderNeighborhoodResident"/>) with physics
|
||||||
/// single destination landblock — so portal space exits onto a loaded, collidable world
|
/// residency. Indoor destinations require the center render landblock + EnvCell; outdoor
|
||||||
/// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no
|
/// destinations require both domains for the priority near ring. An impossible claim
|
||||||
/// "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)
|
|
||||||
/// returns true so the TAS stops holding and the forced placement surfaces the failure
|
/// returns true so the TAS stops holding and the forced placement surfaces the failure
|
||||||
/// loudly rather than holding forever.
|
/// loudly rather than holding forever.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -7073,9 +7073,14 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
if (IsSpawnClaimUnhydratable(destCell)) return true;
|
if (IsSpawnClaimUnhydratable(destCell)) return true;
|
||||||
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||||||
|
int renderRadius = indoor ? 0 : TeleportNearRingRadius;
|
||||||
|
bool renderReady = _streamingController?.IsRenderNeighborhoodResident(
|
||||||
|
destCell,
|
||||||
|
renderRadius) == true;
|
||||||
return indoor
|
return indoor
|
||||||
? _physicsEngine.IsSpawnCellReady(destCell)
|
? renderReady && _physicsEngine.IsSpawnCellReady(destCell)
|
||||||
: _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
|
: renderReady
|
||||||
|
&& _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
||||||
|
|
@ -7560,6 +7565,13 @@ public sealed class GameWindow : IDisposable
|
||||||
completedEnvCells);
|
completedEnvCells);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnsureEnvCellMeshesAfterPin(
|
||||||
|
AcDream.App.Rendering.Wb.EnvCellLandblockBuild build)
|
||||||
|
{
|
||||||
|
if (_wbMeshAdapter?.MeshManager is { } meshManager)
|
||||||
|
AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(build, meshManager);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pre-reads (under the worker's <c>_datLock</c>) every dat object
|
/// Pre-reads (under the worker's <c>_datLock</c>) every dat object
|
||||||
/// <see cref="ApplyLoadedTerrainLocked"/> consumes, so the apply makes zero
|
/// <see cref="ApplyLoadedTerrainLocked"/> consumes, so the apply makes zero
|
||||||
|
|
@ -7682,15 +7694,13 @@ public sealed class GameWindow : IDisposable
|
||||||
(lbY - _liveCenterY) * 192f,
|
(lbY - _liveCenterY) * 192f,
|
||||||
0f);
|
0f);
|
||||||
|
|
||||||
// Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF
|
// Per-landblock id namespace. The top nibble identifies procedural
|
||||||
// where XX = landblock X coord (bits 24-31), YY = Y coord (bits 16-23).
|
// scenery and the full X/Y bytes plus a 12-bit counter prevent dense
|
||||||
// Both must go into our ID so landblocks don't collide.
|
// DAT-generated landblocks from overflowing into a neighbour's range.
|
||||||
// Format: 0x80 | XX | YY | local_index(8 bits) = 0x80XXYY_II.
|
// See ProceduralSceneryIdAllocator for the audited 0x8XXYYIII layout.
|
||||||
// 256 slots per landblock is enough (SceneryGenerator caps ~200).
|
|
||||||
uint lbXByte = (lb.LandblockId >> 24) & 0xFFu;
|
uint lbXByte = (lb.LandblockId >> 24) & 0xFFu;
|
||||||
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
|
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
|
||||||
uint sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8);
|
uint sceneryCounter = 0;
|
||||||
uint localIndex = 0;
|
|
||||||
|
|
||||||
foreach (var spawn in spawns)
|
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
|
var hydrated = new AcDream.Core.World.WorldEntity
|
||||||
{
|
{
|
||||||
Id = sceneryIdBase + localIndex++,
|
Id = AcDream.Core.World.ProceduralSceneryIdAllocator.Allocate(
|
||||||
|
lbXByte,
|
||||||
|
lbYByte,
|
||||||
|
ref sceneryCounter),
|
||||||
SourceGfxObjOrSetupId = spawn.ObjectId,
|
SourceGfxObjOrSetupId = spawn.ObjectId,
|
||||||
Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset,
|
Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset,
|
||||||
Rotation = spawn.Rotation,
|
Rotation = spawn.Rotation,
|
||||||
|
|
@ -8135,6 +8144,31 @@ public sealed class GameWindow : IDisposable
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick
|
/// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick
|
||||||
/// whenever a new landblock's terrain + entities are ready for GPU upload.
|
/// 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
|
// 0xC0XXYY00+n layout per LandblockLoader.cs:55. Their BSP
|
||||||
// collision covers the whole structure; the mesh-AABB-fallback
|
// collision covers the whole structure; the mesh-AABB-fallback
|
||||||
// path below is for canopy-only-BSP procedural scenery
|
// 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
|
// invisible disc at the stab's mesh origin — the user-reported
|
||||||
// "thin air" collision inside cottages. Gate the fallback to
|
// "thin air" collision inside cottages. Gate the fallback to
|
||||||
// exclude stabs. Spec:
|
// exclude stabs. Spec:
|
||||||
|
|
@ -10746,20 +10780,15 @@ public sealed class GameWindow : IDisposable
|
||||||
&& _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false)
|
&& _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Retail UpdatePositionInternal keeps a narrow Hidden path alive:
|
// Retail Hidden suppresses PartArray TIME ADVANCE, not part-pose
|
||||||
// no PartArray animation and no physics integration, but
|
// composition. set_hidden -> HandleEnterWorld can move CSequence's
|
||||||
// PositionManager::adjust_offset plus the manager tail still run.
|
// cursor from a finished link/action to the first cyclic node; the
|
||||||
// Scripts and particles tick in their shared passes below.
|
// next hidden CPhysicsObj::UpdateObjectInternal (0x005156B0)
|
||||||
if (serverGuid != 0 && _liveEntities?.IsHidden(serverGuid) == true)
|
// performs UpdatePositionInternal and then reaches set_frame ->
|
||||||
{
|
// CPartArray::UpdateParts, publishing that newly-current pose before
|
||||||
// Hidden suppresses the PartArray/mesh, not the effect owner.
|
// the zero-time Hidden PES creates its silhouette particles.
|
||||||
// Remote PositionManager composition already ran in the
|
bool hidden = serverGuid != 0
|
||||||
// unconditional live-entity pass; publish again for the local
|
&& _liveEntities?.IsHidden(serverGuid) == true;
|
||||||
// player and for retained objects without remote motion.
|
|
||||||
// Frozen part-local poses remain unchanged until UnHide.
|
|
||||||
_effectPoses.UpdateRoot(ae.Entity);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
||||||
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
// 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
|
// runaway when the sequencer's velocity and the server's reality
|
||||||
// disagree (e.g. server is rubber-banding the entity). Retail
|
// disagree (e.g. server is rubber-banding the entity). Retail
|
||||||
// uses a similar clamp at PhysicsObj::IsInterpolationComplete.
|
// uses a similar clamp at PhysicsObj::IsInterpolationComplete.
|
||||||
if (ae.Sequencer is not null
|
if (!hidden
|
||||||
|
&& ae.Sequencer is not null
|
||||||
&& serverGuid != 0
|
&& serverGuid != 0
|
||||||
&& serverGuid != _playerServerGuid
|
&& serverGuid != _playerServerGuid
|
||||||
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm)
|
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm)
|
||||||
|
|
@ -10839,7 +10869,9 @@ public sealed class GameWindow : IDisposable
|
||||||
rmDiag.LastSeqStateLogTime = nowSec;
|
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
|
// Capture hooks now, but deliver them only after every final
|
||||||
// part transform and equipped-child root has been published.
|
// 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
|
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
|
||||||
// reads this frame's pose, never the previous frame's pose.
|
// reads this frame's pose, never the previous frame's pose.
|
||||||
// AnimationDone/UseTime remain paired with the deferred drain.
|
// 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
|
else
|
||||||
{
|
{
|
||||||
// Legacy path (entities without a MotionTable / sequencer).
|
// Legacy path (entities without a MotionTable / sequencer).
|
||||||
int span = ae.HighFrame - ae.LowFrame;
|
int span = ae.HighFrame - ae.LowFrame;
|
||||||
if (span <= 0) continue;
|
if (span <= 0 && !hidden) continue;
|
||||||
ae.CurrFrame += dt * ae.Framerate;
|
if (!hidden)
|
||||||
if (ae.CurrFrame > ae.HighFrame)
|
|
||||||
{
|
{
|
||||||
float over = ae.CurrFrame - ae.LowFrame;
|
ae.CurrFrame += dt * ae.Framerate;
|
||||||
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
|
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;
|
int partCount = ae.PartTemplate.Count;
|
||||||
|
|
@ -12357,8 +12393,8 @@ public sealed class GameWindow : IDisposable
|
||||||
_streamer.Start();
|
_streamer.Start();
|
||||||
|
|
||||||
_streamingController = new AcDream.App.Streaming.StreamingController(
|
_streamingController = new AcDream.App.Streaming.StreamingController(
|
||||||
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
|
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation),
|
||||||
enqueueUnload: _streamer.EnqueueUnload,
|
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
|
||||||
drainCompletions: _streamer.DrainCompletions,
|
drainCompletions: _streamer.DrainCompletions,
|
||||||
applyTerrain: ApplyLoadedTerrain,
|
applyTerrain: ApplyLoadedTerrain,
|
||||||
state: _worldState,
|
state: _worldState,
|
||||||
|
|
@ -12381,7 +12417,9 @@ public sealed class GameWindow : IDisposable
|
||||||
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
|
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
|
||||||
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
|
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
|
||||||
_envCellRenderer?.RemoveLandblock(id); // Phase A8
|
_envCellRenderer?.RemoveLandblock(id); // Phase A8
|
||||||
});
|
},
|
||||||
|
demoteNearLayer: DemoteLoadedLandblockToTerrain,
|
||||||
|
ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin);
|
||||||
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;
|
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;
|
||||||
|
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,11 @@ namespace AcDream.App.Rendering.Wb;
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Key composition:</b> entries are keyed by the tuple
|
/// <b>Key composition:</b> entries are keyed by the tuple
|
||||||
/// <c>(EntityId, LandblockHint)</c>, NOT by <c>EntityId</c> alone. Issue #53
|
/// <c>(EntityId, LandblockHint)</c>, NOT by <c>EntityId</c> alone. Issue #53
|
||||||
/// uncovered that <c>entity.Id</c> is NOT globally unique across all
|
/// uncovered that older hydration IDs were not globally unique across all
|
||||||
/// static-entity hydration paths: scenery (<c>0x80XXYY00 + localIndex</c>)
|
/// static-entity paths: their former byte-aligned counters overflowed at
|
||||||
/// and interior cells (<c>0x40XXYY00 + localCounter</c>, X-byte fixed
|
/// more than 256 items per landblock and wrapped into the <c>lbY</c> byte.
|
||||||
/// 2026-06-11 — it used to be discarded entirely, #119) overflow at >256
|
/// The current <c>0x8XXYYIII</c> scenery and <c>0x4XXYYIII</c> interior
|
||||||
/// items per landblock, wrapping into the <c>lbY</c> byte and producing
|
/// allocators reserve 12-bit counters and fail before aliasing. Keying
|
||||||
/// cross-LB collisions in dense forest/urban LBs outside Holtburg. Keying
|
|
||||||
/// by the tuple is correct-by-construction ONLY when the hint identifies the
|
/// by the tuple is correct-by-construction ONLY when the hint identifies the
|
||||||
/// entity's OWNING landblock — callers must derive it via
|
/// entity's OWNING landblock — callers must derive it via
|
||||||
/// <c>WbDrawDispatcher.ResolveCacheLandblockHint</c> (the entity's
|
/// <c>WbDrawDispatcher.ResolveCacheLandblockHint</c> (the entity's
|
||||||
|
|
@ -64,8 +63,8 @@ internal sealed class EntityClassificationCache
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Look up an entity's cached classification. Keyed by both
|
/// Look up an entity's cached classification. Keyed by both
|
||||||
/// <paramref name="entityId"/> AND <paramref name="landblockHint"/> to
|
/// <paramref name="entityId"/> AND <paramref name="landblockHint"/> to
|
||||||
/// disambiguate entities whose Ids collide across landblocks (e.g.,
|
/// preserve defensive isolation if a future hydration path introduces an
|
||||||
/// scenery's <c>0x80LLBB00 + localIndex</c> overflow at >256 items/LB).
|
/// entity-id collision across landblocks.
|
||||||
/// Returns <c>true</c> with the entry on hit; <c>false</c> with
|
/// Returns <c>true</c> with the entry on hit; <c>false</c> with
|
||||||
/// <paramref name="entry"/> set to <c>null</c> on miss.
|
/// <paramref name="entry"/> set to <c>null</c> on miss.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,19 @@ public interface IWbMeshAdapter
|
||||||
{
|
{
|
||||||
void IncrementRefCount(ulong id);
|
void IncrementRefCount(ulong id);
|
||||||
void DecrementRefCount(ulong id);
|
void DecrementRefCount(ulong id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
void PinPreparedRenderData(ulong id) => IncrementRefCount(id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
bool IsRenderDataReady(ulong id) => true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,14 @@ namespace AcDream.App.Rendering.Wb;
|
||||||
/// <para>
|
/// <para>
|
||||||
/// On load: walks the landblock's atlas-tier entities, collects unique
|
/// On load: walks the landblock's atlas-tier entities, collects unique
|
||||||
/// GfxObj ids from their <c>MeshRefs</c>, calls
|
/// GfxObj ids from their <c>MeshRefs</c>, calls
|
||||||
/// <c>IncrementRefCount</c> per id. Snapshots the id-set per landblock so
|
/// <c>IncrementRefCount</c> per id, and pins each specialized EnvCell geometry
|
||||||
/// unload can match the load 1:1.
|
/// id without starting generic GfxObj decode. Snapshots both id-sets per
|
||||||
|
/// landblock so unload can match the load 1:1.
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// On unload: looks up the snapshot, calls <c>DecrementRefCount</c> per id,
|
/// On unload: looks up both snapshots, calls <c>DecrementRefCount</c> per id,
|
||||||
/// drops the snapshot. Unknown / never-loaded landblocks no-op.
|
/// drops the snapshots. Unknown / never-loaded landblocks no-op.
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -34,11 +35,9 @@ namespace AcDream.App.Rendering.Wb;
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Thread safety: the underlying <see cref="IWbMeshAdapter"/> implementation
|
/// Thread safety: the internal snapshots are intentionally not synchronized.
|
||||||
/// uses <c>ConcurrentDictionary</c>, so the streaming worker thread may call
|
/// <see cref="AcDream.App.Streaming.GpuWorldState"/> invokes every load, unload, and readiness query
|
||||||
/// this safely. The internal snapshot dictionary is NOT thread-safe and must
|
/// on the owning render/update thread.
|
||||||
/// be called from a single streaming thread (the same thread that fires
|
|
||||||
/// AddLandblock / RemoveLandblock events).
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LandblockSpawnAdapter
|
public sealed class LandblockSpawnAdapter
|
||||||
|
|
@ -48,6 +47,10 @@ public sealed class LandblockSpawnAdapter
|
||||||
// Maps landblock id → unique GfxObj ids registered for that landblock.
|
// Maps landblock id → unique GfxObj ids registered for that landblock.
|
||||||
// Written on load, read+cleared on unload. Single-threaded (streaming worker).
|
// Written on load, read+cleared on unload. Single-threaded (streaming worker).
|
||||||
private readonly Dictionary<uint, HashSet<ulong>> _idsByLandblock = new();
|
private readonly Dictionary<uint, HashSet<ulong>> _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<uint, HashSet<ulong>> _additionalReadinessIdsByLandblock = new();
|
||||||
|
|
||||||
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
|
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
|
||||||
{
|
{
|
||||||
|
|
@ -61,7 +64,9 @@ public sealed class LandblockSpawnAdapter
|
||||||
/// unique atlas-tier GfxObj id that has not already been registered for
|
/// unique atlas-tier GfxObj id that has not already been registered for
|
||||||
/// this landblock.
|
/// this landblock.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void OnLandblockLoaded(LoadedLandblock landblock)
|
public void OnLandblockLoaded(
|
||||||
|
LoadedLandblock landblock,
|
||||||
|
IEnumerable<ulong>? additionalReadinessIds = null)
|
||||||
{
|
{
|
||||||
System.ArgumentNullException.ThrowIfNull(landblock);
|
System.ArgumentNullException.ThrowIfNull(landblock);
|
||||||
|
|
||||||
|
|
@ -80,14 +85,51 @@ public sealed class LandblockSpawnAdapter
|
||||||
{
|
{
|
||||||
_idsByLandblock[landblock.LandblockId] = unique;
|
_idsByLandblock[landblock.LandblockId] = unique;
|
||||||
foreach (var id in unique) _adapter.IncrementRefCount(id);
|
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))
|
additional = new HashSet<ulong>();
|
||||||
_adapter.IncrementRefCount(id);
|
_additionalReadinessIdsByLandblock[landblock.LandblockId] = additional;
|
||||||
}
|
}
|
||||||
|
if (additionalReadinessIds is not null)
|
||||||
|
{
|
||||||
|
foreach (var id in additionalReadinessIds)
|
||||||
|
if (additional.Add(id))
|
||||||
|
_adapter.PinPreparedRenderData(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -99,6 +141,9 @@ public sealed class LandblockSpawnAdapter
|
||||||
{
|
{
|
||||||
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
|
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
|
||||||
foreach (var id in unique) _adapter.DecrementRefCount(id);
|
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);
|
_idsByLandblock.Remove(landblockId);
|
||||||
|
_additionalReadinessIdsByLandblock.Remove(landblockId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
117
src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs
Normal file
117
src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using AcDream.Content;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering.Wb;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MeshUploadStagingQueue
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<ObjectMeshData> _queue = new();
|
||||||
|
private readonly ConcurrentDictionary<ulong, byte> _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 _);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MeshOwnershipCounter
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<ulong, int> _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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports whether freshly uploaded data has a live owner. This is a query,
|
||||||
|
/// not an acquire: upload existence and logical ownership are independent.
|
||||||
|
/// </summary>
|
||||||
|
public bool MarkUploadComplete(ulong id) => IsOwned(id);
|
||||||
|
|
||||||
|
public bool Remove(ulong id) => _counts.TryRemove(id, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CpuMeshUploadCache
|
||||||
|
{
|
||||||
|
private readonly Dictionary<ulong, ObjectMeshData> _data = new();
|
||||||
|
private readonly LinkedList<ulong> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -105,7 +105,7 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
|
|
||||||
public bool IsDisposed { get; private set; }
|
public bool IsDisposed { get; private set; }
|
||||||
private readonly ConcurrentDictionary<ulong, ObjectRenderData> _renderData = new();
|
private readonly ConcurrentDictionary<ulong, ObjectRenderData> _renderData = new();
|
||||||
private readonly ConcurrentDictionary<ulong, int> _usageCount = new();
|
private readonly MeshOwnershipCounter _ownership = new();
|
||||||
private readonly ConcurrentDictionary<ulong, (Vector3 Min, Vector3 Max)?> _boundsCache = new();
|
private readonly ConcurrentDictionary<ulong, (Vector3 Min, Vector3 Max)?> _boundsCache = new();
|
||||||
private readonly ConcurrentDictionary<ulong, Task<ObjectMeshData?>> _preparationTasks = new();
|
private readonly ConcurrentDictionary<ulong, Task<ObjectMeshData?>> _preparationTasks = new();
|
||||||
|
|
||||||
|
|
@ -119,12 +119,10 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
private readonly Dictionary<(int Width, int Height, TextureFormat Format), List<TextureAtlasManager>> _globalAtlases = new();
|
private readonly Dictionary<(int Width, int Height, TextureFormat Format), List<TextureAtlasManager>> _globalAtlases = new();
|
||||||
|
|
||||||
// CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT)
|
// CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT)
|
||||||
private readonly Dictionary<ulong, ObjectMeshData> _cpuMeshCache = new();
|
|
||||||
private readonly LinkedList<ulong> _cpuLruList = new();
|
|
||||||
private readonly int _maxCpuCacheSize = 100;
|
private readonly int _maxCpuCacheSize = 100;
|
||||||
|
private readonly CpuMeshUploadCache _cpuMeshCache;
|
||||||
|
|
||||||
private readonly ConcurrentQueue<ObjectMeshData> _stagedMeshData = new();
|
private readonly MeshUploadStagingQueue _stagedMeshData = new();
|
||||||
public ConcurrentQueue<ObjectMeshData> StagedMeshData => _stagedMeshData;
|
|
||||||
|
|
||||||
/// <summary>#125: how many times a failed GL upload is re-staged before
|
/// <summary>#125: how many times a failed GL upload is re-staged before
|
||||||
/// giving up loudly. Small — a transient GL error clears on the next
|
/// 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 <see cref="MaxUploadRetries"/>.
|
/// on re-prepare) and gives up loudly past <see cref="MaxUploadRetries"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool UploadOrRequeue(ObjectMeshData meshData) {
|
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)
|
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
|
return false; // raced to present by another path
|
||||||
|
}
|
||||||
meshData.UploadAttempts++;
|
meshData.UploadAttempts++;
|
||||||
if (meshData.UploadAttempts < MaxUploadRetries)
|
if (meshData.UploadAttempts < MaxUploadRetries)
|
||||||
return true; // re-stage for next frame
|
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)");
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal bool TryDequeueStagedMeshData(out ObjectMeshData? data) =>
|
||||||
|
_stagedMeshData.TryDequeue(out data);
|
||||||
|
|
||||||
|
internal void RequeueStagedMeshData(ObjectMeshData data) =>
|
||||||
|
_stagedMeshData.Requeue(data);
|
||||||
|
|
||||||
public GlobalMeshBuffer? GlobalBuffer { get; }
|
public GlobalMeshBuffer? GlobalBuffer { get; }
|
||||||
private readonly bool _useModernRendering;
|
private readonly bool _useModernRendering;
|
||||||
|
|
||||||
|
|
@ -167,7 +176,8 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
// did — immediate enqueue, surviving a later throw in the same
|
// did — immediate enqueue, surviving a later throw in the same
|
||||||
// Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the
|
// Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the
|
||||||
// extractor's up-to-4 concurrent decode workers.
|
// 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;
|
_useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless;
|
||||||
if (_useModernRendering) {
|
if (_useModernRendering) {
|
||||||
GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL);
|
GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL);
|
||||||
|
|
@ -180,7 +190,7 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ObjectRenderData? GetRenderData(ulong id) {
|
public ObjectRenderData? GetRenderData(ulong id) {
|
||||||
if (_renderData.TryGetValue(id, out var data)) {
|
if (_renderData.TryGetValue(id, out var data)) {
|
||||||
_usageCount.AddOrUpdate(id, 1, (_, count) => count + 1);
|
_ownership.Acquire(id);
|
||||||
|
|
||||||
if (data.IsSetup) {
|
if (data.IsSetup) {
|
||||||
foreach (var (partId, _) in data.SetupParts) {
|
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).
|
/// Increment reference count for an object (e.g. when a landblock starts using it).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void IncrementRefCount(ulong id) {
|
public void IncrementRefCount(ulong id) {
|
||||||
_usageCount.AddOrUpdate(id, 1, (_, count) => count + 1);
|
_ownership.Acquire(id);
|
||||||
lock (_lruList) {
|
lock (_lruList) {
|
||||||
_lruList.Remove(id);
|
_lruList.Remove(id);
|
||||||
}
|
}
|
||||||
|
|
@ -258,7 +268,7 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
/// Decrement reference count and unload GPU resources if no longer needed.
|
/// Decrement reference count and unload GPU resources if no longer needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void DecrementRefCount(ulong id) {
|
public void DecrementRefCount(ulong id) {
|
||||||
var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1);
|
var newCount = _ownership.Release(id);
|
||||||
if (newCount <= 0) {
|
if (newCount <= 0) {
|
||||||
// Instead of unloading, move to LRU
|
// Instead of unloading, move to LRU
|
||||||
lock (_lruList) {
|
lock (_lruList) {
|
||||||
|
|
@ -272,8 +282,8 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
/// Decrement reference count and unload if no longer needed.
|
/// Decrement reference count and unload if no longer needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ReleaseRenderData(ulong id) {
|
public void ReleaseRenderData(ulong id) {
|
||||||
if (_usageCount.TryGetValue(id, out var count) && count > 0) {
|
if (_ownership.IsOwned(id)) {
|
||||||
var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1);
|
var newCount = _ownership.Release(id);
|
||||||
if (newCount <= 0) {
|
if (newCount <= 0) {
|
||||||
// Instead of unloading, move to LRU
|
// Instead of unloading, move to LRU
|
||||||
lock (_lruList) {
|
lock (_lruList) {
|
||||||
|
|
@ -291,9 +301,9 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
var idToEvict = _lruList.First!.Value;
|
var idToEvict = _lruList.First!.Value;
|
||||||
_lruList.RemoveFirst();
|
_lruList.RemoveFirst();
|
||||||
|
|
||||||
if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) {
|
if (!_ownership.IsOwned(idToEvict)) {
|
||||||
UnloadObject(idToEvict);
|
UnloadObject(idToEvict);
|
||||||
_usageCount.TryRemove(idToEvict, out _);
|
_ownership.Remove(idToEvict);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -309,18 +319,15 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
var idToEvict = _lruList.First!.Value;
|
var idToEvict = _lruList.First!.Value;
|
||||||
_lruList.RemoveFirst();
|
_lruList.RemoveFirst();
|
||||||
|
|
||||||
if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) {
|
if (!_ownership.IsOwned(idToEvict)) {
|
||||||
UnloadObject(idToEvict);
|
UnloadObject(idToEvict);
|
||||||
_usageCount.TryRemove(idToEvict, out _);
|
_ownership.Remove(idToEvict);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also clear CPU mesh cache
|
// Also clear CPU mesh cache
|
||||||
lock (_cpuMeshCache) {
|
_cpuMeshCache.Clear();
|
||||||
_cpuMeshCache.Clear();
|
|
||||||
_cpuLruList.Clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct EnvCellGeomRequest {
|
public struct EnvCellGeomRequest {
|
||||||
|
|
@ -338,13 +345,8 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
if (IsDisposed || HasRenderData(geomId)) return Task.FromResult<ObjectMeshData?>(null);
|
if (IsDisposed || HasRenderData(geomId)) return Task.FromResult<ObjectMeshData?>(null);
|
||||||
|
|
||||||
// Check CPU cache first
|
// Check CPU cache first
|
||||||
lock (_cpuMeshCache) {
|
if (_cpuMeshCache.TryGetAndStage(geomId, _stagedMeshData, out var cachedData))
|
||||||
if (_cpuMeshCache.TryGetValue(geomId, out var cachedData)) {
|
return Task.FromResult(cachedData);
|
||||||
_cpuLruList.Remove(geomId);
|
|
||||||
_cpuLruList.AddLast(geomId);
|
|
||||||
return Task.FromResult<ObjectMeshData?>(cachedData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return existing task if already running or queued
|
// Return existing task if already running or queued
|
||||||
if (_preparationTasks.TryGetValue(geomId, out var existing)) {
|
if (_preparationTasks.TryGetValue(geomId, out var existing)) {
|
||||||
|
|
@ -381,13 +383,8 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
if (IsDisposed || HasRenderData(id)) return Task.FromResult<ObjectMeshData?>(null);
|
if (IsDisposed || HasRenderData(id)) return Task.FromResult<ObjectMeshData?>(null);
|
||||||
|
|
||||||
// Check CPU cache first
|
// Check CPU cache first
|
||||||
lock (_cpuMeshCache) {
|
if (_cpuMeshCache.TryGetAndStage(id, _stagedMeshData, out var cachedData))
|
||||||
if (_cpuMeshCache.TryGetValue(id, out var cachedData)) {
|
return Task.FromResult(cachedData);
|
||||||
_cpuLruList.Remove(id);
|
|
||||||
_cpuLruList.AddLast(id);
|
|
||||||
return Task.FromResult<ObjectMeshData?>(cachedData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return existing task if already running or queued
|
// Return existing task if already running or queued
|
||||||
if (_preparationTasks.TryGetValue(id, out var existing)) {
|
if (_preparationTasks.TryGetValue(id, out var existing)) {
|
||||||
|
|
@ -476,16 +473,8 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
data = _extractor.PrepareMeshData(id, isSetup, CancellationToken.None);
|
data = _extractor.PrepareMeshData(id, isSetup, CancellationToken.None);
|
||||||
}
|
}
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
lock (_cpuMeshCache) {
|
_cpuMeshCache.Store(data);
|
||||||
if (_cpuMeshCache.Count >= _maxCpuCacheSize) {
|
_stagedMeshData.Stage(data);
|
||||||
var oldest = _cpuLruList.First!.Value;
|
|
||||||
_cpuLruList.RemoveFirst();
|
|
||||||
_cpuMeshCache.Remove(oldest);
|
|
||||||
}
|
|
||||||
_cpuMeshCache[id] = data;
|
|
||||||
_cpuLruList.AddLast(id);
|
|
||||||
}
|
|
||||||
_stagedMeshData.Enqueue(data);
|
|
||||||
}
|
}
|
||||||
tcs.TrySetResult(data);
|
tcs.TrySetResult(data);
|
||||||
}
|
}
|
||||||
|
|
@ -538,26 +527,7 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
try {
|
try {
|
||||||
if (_renderData.TryGetValue(meshData.ObjectId, out var existing)) {
|
if (_renderData.TryGetValue(meshData.ObjectId, out var existing)) {
|
||||||
_preparationTasks.TryRemove(meshData.ObjectId, out _);
|
_preparationTasks.TryRemove(meshData.ObjectId, out _);
|
||||||
if (existing.IsSetup) {
|
UpdateLruAfterUpload(meshData.ObjectId);
|
||||||
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<byte>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IncrementRefCount(meshData.ObjectId);
|
|
||||||
lock (_lruList) {
|
|
||||||
_lruList.Remove(meshData.ObjectId);
|
|
||||||
}
|
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -588,7 +558,6 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
MemorySize = 1024 // Small overhead for the setup itself
|
MemorySize = 1024 // Small overhead for the setup itself
|
||||||
};
|
};
|
||||||
_renderData.TryAdd(meshData.ObjectId, data);
|
_renderData.TryAdd(meshData.ObjectId, data);
|
||||||
IncrementRefCount(meshData.ObjectId);
|
|
||||||
_currentGpuMemory += data.MemorySize;
|
_currentGpuMemory += data.MemorySize;
|
||||||
|
|
||||||
// Increment ref counts for all parts
|
// Increment ref counts for all parts
|
||||||
|
|
@ -596,6 +565,8 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
IncrementRefCount(partId);
|
IncrementRefCount(partId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateLruAfterUpload(meshData.ObjectId);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -619,15 +590,12 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
renderData.DIDDegrade = meshData.DIDDegrade;
|
renderData.DIDDegrade = meshData.DIDDegrade;
|
||||||
renderData.SelectionSphere = meshData.SelectionSphere;
|
renderData.SelectionSphere = meshData.SelectionSphere;
|
||||||
_renderData.TryAdd(meshData.ObjectId, renderData);
|
_renderData.TryAdd(meshData.ObjectId, renderData);
|
||||||
IncrementRefCount(meshData.ObjectId);
|
|
||||||
_currentGpuMemory += renderData.MemorySize;
|
_currentGpuMemory += renderData.MemorySize;
|
||||||
|
UpdateLruAfterUpload(meshData.ObjectId);
|
||||||
|
|
||||||
// Clear texture data after upload to save RAM
|
// Keep the bounded CPU cache's texture payload intact. GPU LRU
|
||||||
foreach (var batchList in meshData.TextureBatches.Values) {
|
// eviction may need to upload this same prepared mesh again;
|
||||||
foreach (var batch in batchList) {
|
// clearing these bytes made a cache hit produce blank textures.
|
||||||
batch.TextureData = Array.Empty<byte>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return renderData;
|
return renderData;
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets bounding box for an object (for frustum culling).
|
/// Gets bounding box for an object (for frustum culling).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,15 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
||||||
return _meshManager.TryGetRenderData(id);
|
return _meshManager.TryGetRenderData(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public void IncrementRefCount(ulong id)
|
public void IncrementRefCount(ulong id)
|
||||||
{
|
{
|
||||||
|
|
@ -199,6 +208,16 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
||||||
_meshManager.DecrementRefCount(id);
|
_meshManager.DecrementRefCount(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #128 self-heal (2026-06-11): re-request a mesh load at the POINT OF
|
/// #128 self-heal (2026-06-11): re-request a mesh load at the POINT OF
|
||||||
/// USE. Registration-time re-arming was insufficient — a preparation
|
/// 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
|
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
|
||||||
// a genuine defect surfaces loudly instead of the old silent sticky drop.
|
// a genuine defect surfaces loudly instead of the old silent sticky drop.
|
||||||
List<ObjectMeshData>? requeue = null;
|
List<ObjectMeshData>? requeue = null;
|
||||||
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
|
while (_meshManager!.TryDequeueStagedMeshData(out var meshData))
|
||||||
{
|
{
|
||||||
|
if (meshData is null)
|
||||||
|
continue;
|
||||||
if (_meshManager.UploadOrRequeue(meshData))
|
if (_meshManager.UploadOrRequeue(meshData))
|
||||||
(requeue ??= new()).Add(meshData);
|
(requeue ??= new()).Add(meshData);
|
||||||
}
|
}
|
||||||
if (requeue is not null)
|
if (requeue is not null)
|
||||||
foreach (var m in requeue)
|
foreach (var m in requeue)
|
||||||
_meshManager.StagedMeshData.Enqueue(m);
|
_meshManager.RequeueStagedMeshData(m);
|
||||||
|
|
||||||
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
|
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
|
||||||
var pendingBefore = texProbe
|
var pendingBefore = texProbe
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ public sealed class GpuWorldState
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
|
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
|
||||||
|
private readonly Dictionary<uint, LandblockStreamTier> _tierByLandblock = new();
|
||||||
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
|
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -75,6 +76,12 @@ public sealed class GpuWorldState
|
||||||
/// Drained into <see cref="_loaded"/> in <see cref="AddLandblock"/>.
|
/// Drained into <see cref="_loaded"/> in <see cref="AddLandblock"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<uint, List<WorldEntity>> _pendingByLandblock = new();
|
private readonly Dictionary<uint, List<WorldEntity>> _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<uint, HashSet<ulong>> _pendingRenderIdsByLandblock = new();
|
||||||
|
private readonly HashSet<uint> _pendingNearTierLandblocks = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entities that must survive landblock unloads (e.g. the player character).
|
/// Entities that must survive landblock unloads (e.g. the player character).
|
||||||
|
|
@ -96,6 +103,14 @@ public sealed class GpuWorldState
|
||||||
public event Action<uint, bool>? LiveProjectionVisibilityChanged;
|
public event Action<uint, bool>? LiveProjectionVisibilityChanged;
|
||||||
|
|
||||||
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
|
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) =>
|
public bool IsLiveEntityVisible(uint serverGuid) =>
|
||||||
serverGuid != 0 && _visibleLiveGuids.Contains(serverGuid);
|
serverGuid != 0 && _visibleLiveGuids.Contains(serverGuid);
|
||||||
|
|
||||||
|
|
@ -204,8 +219,17 @@ public sealed class GpuWorldState
|
||||||
public int PersistentGuidCount => _persistentGuids.Count;
|
public int PersistentGuidCount => _persistentGuids.Count;
|
||||||
public int PendingVisibilityTransitionCount => _visibilityTransitions.Count;
|
public int PendingVisibilityTransitionCount => _visibilityTransitions.Count;
|
||||||
|
|
||||||
public void AddLandblock(LoadedLandblock landblock)
|
public void AddLandblock(
|
||||||
|
LoadedLandblock landblock,
|
||||||
|
IEnumerable<ulong>? 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,
|
// If pending live entities have been waiting for this landblock,
|
||||||
// merge them into the LoadedLandblock record before storing. The
|
// merge them into the LoadedLandblock record before storing. The
|
||||||
// record's Entities field is IReadOnlyList; we replace the whole
|
// record's Entities field is IReadOnlyList; we replace the whole
|
||||||
|
|
@ -222,9 +246,29 @@ public sealed class GpuWorldState
|
||||||
_pendingByLandblock.Remove(landblock.LandblockId);
|
_pendingByLandblock.Remove(landblock.LandblockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HashSet<ulong>? mergedRenderIds = additionalRenderIds is null
|
||||||
|
? null
|
||||||
|
: new HashSet<ulong>(additionalRenderIds);
|
||||||
|
if (_pendingRenderIdsByLandblock.Remove(landblock.LandblockId, out var pendingRenderIds))
|
||||||
|
{
|
||||||
|
mergedRenderIds ??= new HashSet<ulong>();
|
||||||
|
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;
|
_loaded[landblock.LandblockId] = landblock;
|
||||||
|
_tierByLandblock[landblock.LandblockId] = tier;
|
||||||
if (_wbSpawnAdapter is not null)
|
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).
|
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
|
||||||
// LiveEntityRuntime owns activation for live objects. This static-only
|
// LiveEntityRuntime owns activation for live objects. This static-only
|
||||||
|
|
@ -397,10 +441,13 @@ public sealed class GpuWorldState
|
||||||
}
|
}
|
||||||
|
|
||||||
_pendingByLandblock.Remove(canonical);
|
_pendingByLandblock.Remove(canonical);
|
||||||
|
_pendingRenderIdsByLandblock.Remove(canonical);
|
||||||
|
_pendingNearTierLandblocks.Remove(canonical);
|
||||||
if (retainedLive.Count > 0)
|
if (retainedLive.Count > 0)
|
||||||
_pendingByLandblock[canonical] = retainedLive;
|
_pendingByLandblock[canonical] = retainedLive;
|
||||||
_aabbs.Remove(canonical);
|
_aabbs.Remove(canonical);
|
||||||
|
|
||||||
|
_tierByLandblock.Remove(canonical);
|
||||||
if (_loaded.Remove(canonical))
|
if (_loaded.Remove(canonical))
|
||||||
RebuildFlatView();
|
RebuildFlatView();
|
||||||
}
|
}
|
||||||
|
|
@ -612,6 +659,18 @@ public sealed class GpuWorldState
|
||||||
// protects against future callers that mirror live projection placement's
|
// protects against future callers that mirror live projection placement's
|
||||||
// cell-resolved-id pattern.
|
// cell-resolved-id pattern.
|
||||||
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
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 (!_loaded.TryGetValue(canonical, out var lb)) return;
|
||||||
if (_wbSpawnAdapter is not null)
|
if (_wbSpawnAdapter is not null)
|
||||||
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
|
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
|
||||||
|
|
@ -642,12 +701,7 @@ public sealed class GpuWorldState
|
||||||
lb.LandblockId,
|
lb.LandblockId,
|
||||||
lb.Heightmap,
|
lb.Heightmap,
|
||||||
retainedLive);
|
retainedLive);
|
||||||
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
|
_tierByLandblock[canonical] = LandblockStreamTier.Far;
|
||||||
{
|
|
||||||
pending.RemoveAll(entity => entity.ServerGuid == 0);
|
|
||||||
if (pending.Count == 0)
|
|
||||||
_pendingByLandblock.Remove(canonical);
|
|
||||||
}
|
|
||||||
RebuildFlatView();
|
RebuildFlatView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -664,7 +718,10 @@ public sealed class GpuWorldState
|
||||||
/// callers may pass cell-resolved ids and they will key correctly.
|
/// callers may pass cell-resolved ids and they will key correctly.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> entities)
|
public bool AddEntitiesToExistingLandblock(
|
||||||
|
uint landblockId,
|
||||||
|
IReadOnlyList<WorldEntity> entities,
|
||||||
|
IEnumerable<ulong>? additionalRenderIds = null)
|
||||||
{
|
{
|
||||||
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
|
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
|
||||||
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||||
|
|
@ -677,14 +734,25 @@ public sealed class GpuWorldState
|
||||||
_pendingByLandblock[canonical] = bucket;
|
_pendingByLandblock[canonical] = bucket;
|
||||||
}
|
}
|
||||||
bucket.AddRange(entities);
|
bucket.AddRange(entities);
|
||||||
return;
|
_pendingNearTierLandblocks.Add(canonical);
|
||||||
|
if (additionalRenderIds is not null)
|
||||||
|
{
|
||||||
|
if (!_pendingRenderIdsByLandblock.TryGetValue(canonical, out var renderIds))
|
||||||
|
{
|
||||||
|
renderIds = new HashSet<ulong>();
|
||||||
|
_pendingRenderIdsByLandblock[canonical] = renderIds;
|
||||||
|
}
|
||||||
|
renderIds.UnionWith(additionalRenderIds);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
|
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
|
||||||
merged.AddRange(lb.Entities);
|
merged.AddRange(lb.Entities);
|
||||||
merged.AddRange(entities);
|
merged.AddRange(entities);
|
||||||
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
|
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
|
||||||
|
_tierByLandblock[canonical] = LandblockStreamTier.Near;
|
||||||
if (_wbSpawnAdapter is not null)
|
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.
|
// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity.
|
||||||
// All entities arriving via this path are atlas-tier by construction
|
// All entities arriving via this path are atlas-tier by construction
|
||||||
|
|
@ -697,6 +765,7 @@ public sealed class GpuWorldState
|
||||||
}
|
}
|
||||||
|
|
||||||
RebuildFlatView();
|
RebuildFlatView();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RebuildFlatView()
|
private void RebuildFlatView()
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,13 @@ namespace AcDream.App.Streaming;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract record LandblockStreamJob(uint LandblockId)
|
public abstract record LandblockStreamJob(uint LandblockId)
|
||||||
{
|
{
|
||||||
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
|
public sealed record Load(
|
||||||
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
|
uint LandblockId,
|
||||||
|
LandblockStreamJobKind Kind,
|
||||||
|
ulong Generation = 0) : LandblockStreamJob(LandblockId);
|
||||||
|
public sealed record Unload(
|
||||||
|
uint LandblockId,
|
||||||
|
ulong Generation = 0) : LandblockStreamJob(LandblockId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Control job: drop every queued (not-yet-started) Load from the worker's
|
/// 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
|
/// an unload notification (tells the render thread to release GPU state
|
||||||
/// for this landblock id).
|
/// for this landblock id).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract record LandblockStreamResult(uint LandblockId)
|
public abstract record LandblockStreamResult(uint LandblockId, ulong Generation)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A landblock load completed. <see cref="Tier"/> distinguishes Far
|
/// A landblock load completed. <see cref="Tier"/> distinguishes Far
|
||||||
|
|
@ -43,15 +48,17 @@ public abstract record LandblockStreamResult(uint LandblockId)
|
||||||
uint LandblockId,
|
uint LandblockId,
|
||||||
LandblockStreamTier Tier,
|
LandblockStreamTier Tier,
|
||||||
LandblockBuild Build,
|
LandblockBuild Build,
|
||||||
LandblockMeshData MeshData
|
LandblockMeshData MeshData,
|
||||||
) : LandblockStreamResult(LandblockId)
|
ulong Generation = 0
|
||||||
|
) : LandblockStreamResult(LandblockId, Generation)
|
||||||
{
|
{
|
||||||
public Loaded(
|
public Loaded(
|
||||||
uint landblockId,
|
uint landblockId,
|
||||||
LandblockStreamTier tier,
|
LandblockStreamTier tier,
|
||||||
LoadedLandblock landblock,
|
LoadedLandblock landblock,
|
||||||
LandblockMeshData meshData)
|
LandblockMeshData meshData,
|
||||||
: this(landblockId, tier, new LandblockBuild(landblock), 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(
|
public sealed record Promoted(
|
||||||
uint LandblockId,
|
uint LandblockId,
|
||||||
LandblockBuild Build,
|
LandblockBuild Build,
|
||||||
LandblockMeshData MeshData
|
LandblockMeshData MeshData,
|
||||||
) : LandblockStreamResult(LandblockId)
|
ulong Generation = 0
|
||||||
|
) : LandblockStreamResult(LandblockId, Generation)
|
||||||
{
|
{
|
||||||
public Promoted(
|
public Promoted(
|
||||||
uint landblockId,
|
uint landblockId,
|
||||||
LoadedLandblock landblock,
|
LoadedLandblock landblock,
|
||||||
LandblockMeshData meshData)
|
LandblockMeshData meshData,
|
||||||
: this(landblockId, new LandblockBuild(landblock), meshData)
|
ulong generation = 0)
|
||||||
|
: this(landblockId, new LandblockBuild(landblock), meshData, generation)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,8 +93,13 @@ public abstract record LandblockStreamResult(uint LandblockId)
|
||||||
public IReadOnlyList<WorldEntity> Entities => Landblock.Entities;
|
public IReadOnlyList<WorldEntity> Entities => Landblock.Entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
|
public sealed record Failed(
|
||||||
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);
|
uint LandblockId,
|
||||||
|
string Error,
|
||||||
|
ulong Generation = 0) : LandblockStreamResult(LandblockId, Generation);
|
||||||
|
public sealed record Unloaded(
|
||||||
|
uint LandblockId,
|
||||||
|
ulong Generation = 0) : LandblockStreamResult(LandblockId, Generation);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The worker loop itself crashed with an unhandled exception. Not tied
|
/// 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
|
/// than retrying a single landblock later. LandblockId is 0 by
|
||||||
/// convention; readers should pattern-match on the type, not the id.
|
/// convention; readers should pattern-match on the type, not the id.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0);
|
public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0, 0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,23 +143,26 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
/// <see cref="LandblockStreamResult.Loaded"/> (or
|
/// <see cref="LandblockStreamResult.Loaded"/> (or
|
||||||
/// <see cref="LandblockStreamResult.Failed"/>) to the outbox.
|
/// <see cref="LandblockStreamResult.Failed"/>) to the outbox.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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)
|
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}");
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Non-blocking enqueue. The worker posts a
|
/// Non-blocking enqueue. The worker posts a
|
||||||
/// <see cref="LandblockStreamResult.Unloaded"/> to the outbox.
|
/// <see cref="LandblockStreamResult.Unloaded"/> to the outbox.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void EnqueueUnload(uint landblockId)
|
public void EnqueueUnload(uint landblockId, ulong generation = 0)
|
||||||
{
|
{
|
||||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||||
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
|
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId, generation));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -335,7 +338,7 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
if (build is null)
|
if (build is null)
|
||||||
{
|
{
|
||||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||||
load.LandblockId, "LandblockLoader.Load returned null"));
|
load.LandblockId, "LandblockLoader.Load returned null", load.Generation));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
var lb = build.Landblock;
|
var lb = build.Landblock;
|
||||||
|
|
@ -345,18 +348,18 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
if (promotedMesh is null)
|
if (promotedMesh is null)
|
||||||
{
|
{
|
||||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||||
load.LandblockId, "buildMeshOrNull returned null"));
|
load.LandblockId, "buildMeshOrNull returned null", load.Generation));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
|
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
|
||||||
load.LandblockId, build, promotedMesh));
|
load.LandblockId, build, promotedMesh, load.Generation));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
var mesh = _buildMeshOrNull(load.LandblockId, lb);
|
var mesh = _buildMeshOrNull(load.LandblockId, lb);
|
||||||
if (mesh is null)
|
if (mesh is null)
|
||||||
{
|
{
|
||||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||||
load.LandblockId, "buildMeshOrNull returned null"));
|
load.LandblockId, "buildMeshOrNull returned null", load.Generation));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
var tier = load.Kind == LandblockStreamJobKind.LoadFar
|
var tier = load.Kind == LandblockStreamJobKind.LoadFar
|
||||||
|
|
@ -375,17 +378,19 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
build = new LandblockBuild(lb);
|
build = new LandblockBuild(lb);
|
||||||
}
|
}
|
||||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
|
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
|
||||||
load.LandblockId, tier, build, mesh));
|
load.LandblockId, tier, build, mesh, load.Generation));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||||
load.LandblockId, ex.ToString()));
|
load.LandblockId, ex.ToString(), load.Generation));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case LandblockStreamJob.Unload unload:
|
case LandblockStreamJob.Unload unload:
|
||||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(unload.LandblockId));
|
_outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(
|
||||||
|
unload.LandblockId,
|
||||||
|
unload.Generation));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
using AcDream.Core.Terrain;
|
using AcDream.Core.Terrain;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
|
@ -17,11 +18,12 @@ namespace AcDream.App.Streaming;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class StreamingController
|
public sealed class StreamingController
|
||||||
{
|
{
|
||||||
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
|
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
|
||||||
private readonly Action<uint> _enqueueUnload;
|
private readonly Action<uint, ulong> _enqueueUnload;
|
||||||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||||||
private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
|
private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
|
||||||
private readonly Action<uint>? _removeTerrain;
|
private readonly Action<uint>? _removeTerrain;
|
||||||
|
private readonly Action<uint>? _demoteNearLayer;
|
||||||
private readonly Action? _clearPendingLoads;
|
private readonly Action? _clearPendingLoads;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -34,8 +36,16 @@ public sealed class StreamingController
|
||||||
/// tests that don't exercise re-hydration.
|
/// tests that don't exercise re-hydration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Action<uint>? _onLandblockLoaded;
|
private readonly Action<uint>? _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<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
|
||||||
private readonly GpuWorldState _state;
|
private readonly GpuWorldState _state;
|
||||||
private StreamingRegion? _region;
|
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
|
// True while streaming is collapsed to the single dungeon landblock the
|
||||||
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
|
// 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.
|
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
|
||||||
public int DeferredApplyBacklog => _deferredApply.Count;
|
public int DeferredApplyBacklog => _deferredApply.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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 +
|
// [FRAME-DIAG]: how many times ForceReloadWindow has fired (each one drops +
|
||||||
// re-uploads the WHOLE window) and the landblock count it dropped last time.
|
// re-uploads the WHOLE window) and the landblock count it dropped last time.
|
||||||
public int ForceReloadCount { get; private set; }
|
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
|
// Completions that were drained past a priority item get buffered here
|
||||||
// so they still apply over subsequent frames without loss.
|
// so they still apply over subsequent frames without loss.
|
||||||
private readonly List<LandblockStreamResult> _deferredApply = new();
|
private readonly List<LandblockStreamResult> _deferredApply = new();
|
||||||
|
|
||||||
public StreamingController(
|
public StreamingController(
|
||||||
|
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
|
||||||
|
Action<uint, ulong> enqueueUnload,
|
||||||
|
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
||||||
|
Action<LandblockBuild, LandblockMeshData> applyTerrain,
|
||||||
|
GpuWorldState state,
|
||||||
|
int nearRadius,
|
||||||
|
int farRadius,
|
||||||
|
Action<uint>? removeTerrain = null,
|
||||||
|
Action<uint>? demoteNearLayer = null,
|
||||||
|
Action? clearPendingLoads = null,
|
||||||
|
Action<uint>? onLandblockLoaded = null,
|
||||||
|
Action<EnvCellLandblockBuild>? 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal StreamingController(
|
||||||
Action<uint, LandblockStreamJobKind> enqueueLoad,
|
Action<uint, LandblockStreamJobKind> enqueueLoad,
|
||||||
Action<uint> enqueueUnload,
|
Action<uint> enqueueUnload,
|
||||||
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
||||||
|
|
@ -145,19 +224,44 @@ public sealed class StreamingController
|
||||||
int nearRadius,
|
int nearRadius,
|
||||||
int farRadius,
|
int farRadius,
|
||||||
Action<uint>? removeTerrain = null,
|
Action<uint>? removeTerrain = null,
|
||||||
|
Action<uint>? demoteNearLayer = null,
|
||||||
Action? clearPendingLoads = null,
|
Action? clearPendingLoads = null,
|
||||||
Action<uint>? onLandblockLoaded = null)
|
Action<uint>? onLandblockLoaded = null,
|
||||||
|
Action<EnvCellLandblockBuild>? 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;
|
private void EnqueueLoad(uint id, LandblockStreamJobKind kind) =>
|
||||||
_applyTerrain = applyTerrain;
|
_enqueueLoad(id, kind, _generation);
|
||||||
_removeTerrain = removeTerrain;
|
|
||||||
_clearPendingLoads = clearPendingLoads;
|
private void EnqueueUnload(uint id) => _enqueueUnload(id, _generation);
|
||||||
_onLandblockLoaded = onLandblockLoaded;
|
|
||||||
_state = state;
|
private void DemoteLandblock(uint id)
|
||||||
NearRadius = nearRadius;
|
{
|
||||||
FarRadius = farRadius;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -263,18 +367,18 @@ public sealed class StreamingController
|
||||||
{
|
{
|
||||||
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||||
var bootstrap = _region.ComputeFirstTickDiff();
|
var bootstrap = _region.ComputeFirstTickDiff();
|
||||||
foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
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.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||||
_region.MarkResidentFromBootstrap();
|
_region.MarkResidentFromBootstrap();
|
||||||
}
|
}
|
||||||
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
|
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
|
||||||
{
|
{
|
||||||
var diff = _region.RecenterTo(observerCx, observerCy);
|
var diff = _region.RecenterTo(observerCx, observerCy);
|
||||||
foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
|
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.ToLoadNear) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||||||
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
foreach (var id in diff.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||||
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
|
foreach (var id in diff.ToDemote) DemoteLandblock(id);
|
||||||
foreach (var id in diff.ToUnload) _enqueueUnload(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}");
|
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
|
||||||
_collapsed = true;
|
_collapsed = true;
|
||||||
_collapsedCenter = centerId;
|
_collapsedCenter = centerId;
|
||||||
|
AdvanceGeneration();
|
||||||
_clearPendingLoads?.Invoke();
|
_clearPendingLoads?.Invoke();
|
||||||
|
_deferredApply.Clear();
|
||||||
|
|
||||||
foreach (var id in _state.LoadedLandblockIds)
|
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,
|
// Pin a radius-0 region so RecenterTo never re-expands while inside,
|
||||||
// and so the post-exit rebuild starts from a clean, consistent state.
|
// 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
|
// The dungeon landblock itself must be (or become) loaded. If a prior
|
||||||
// ClearPendingLoads cancelled its queued load, re-enqueue it.
|
// ClearPendingLoads cancelled its queued load, re-enqueue it.
|
||||||
if (!_state.IsLoaded(centerId))
|
if (!_state.IsLoaded(centerId))
|
||||||
_enqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
|
EnqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
|
||||||
|
else if (!_state.IsNearTier(centerId))
|
||||||
|
EnqueueLoad(centerId, LandblockStreamJobKind.PromoteToNear);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -321,7 +429,7 @@ public sealed class StreamingController
|
||||||
// Always preserve the true dungeon landblock (_collapsedCenter), never the
|
// Always preserve the true dungeon landblock (_collapsedCenter), never the
|
||||||
// per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
|
// per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
|
||||||
foreach (var id in _state.LoadedLandblockIds)
|
foreach (var id in _state.LoadedLandblockIds)
|
||||||
if (id != _collapsedCenter) _enqueueUnload(id);
|
if (id != _collapsedCenter) EnqueueUnload(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
|
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
|
||||||
|
|
@ -354,16 +462,17 @@ public sealed class StreamingController
|
||||||
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
|
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
|
||||||
$"(was collapsed on 0x{_collapsedCenter:X8})");
|
$"(was collapsed on 0x{_collapsedCenter:X8})");
|
||||||
_collapsed = false;
|
_collapsed = false;
|
||||||
|
AdvanceGeneration();
|
||||||
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||||
|
|
||||||
foreach (var id in _state.LoadedLandblockIds)
|
foreach (var id in _state.LoadedLandblockIds)
|
||||||
if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id);
|
if (!rebuilt.Resident.Contains(id)) EnqueueUnload(id);
|
||||||
|
|
||||||
var boot = rebuilt.ComputeFirstTickDiff();
|
var boot = rebuilt.ComputeFirstTickDiff();
|
||||||
foreach (var id in boot.ToLoadNear)
|
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)
|
foreach (var id in boot.ToLoadFar)
|
||||||
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||||
rebuilt.MarkResidentFromBootstrap();
|
rebuilt.MarkResidentFromBootstrap();
|
||||||
_region = rebuilt;
|
_region = rebuilt;
|
||||||
}
|
}
|
||||||
|
|
@ -383,6 +492,9 @@ public sealed class StreamingController
|
||||||
public void ForceReloadWindow()
|
public void ForceReloadWindow()
|
||||||
{
|
{
|
||||||
_collapsed = false;
|
_collapsed = false;
|
||||||
|
AdvanceGeneration();
|
||||||
|
_clearPendingLoads?.Invoke();
|
||||||
|
_deferredApply.Clear();
|
||||||
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
|
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
|
||||||
var ids = new List<uint>(_state.LoadedLandblockIds);
|
var ids = new List<uint>(_state.LoadedLandblockIds);
|
||||||
ForceReloadCount++; // [FRAME-DIAG] churn counter
|
ForceReloadCount++; // [FRAME-DIAG] churn counter
|
||||||
|
|
@ -424,6 +536,11 @@ public sealed class StreamingController
|
||||||
if (chunk.Count == 0) break;
|
if (chunk.Count == 0) break;
|
||||||
foreach (var result in chunk)
|
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
|
if (result is LandblockStreamResult.Unloaded
|
||||||
|| IsWithinPriorityRing(ResultLandblockId(result)))
|
|| IsWithinPriorityRing(ResultLandblockId(result)))
|
||||||
ApplyResult(result); // free (unload) or behind-the-fade near ring
|
ApplyResult(result); // free (unload) or behind-the-fade near ring
|
||||||
|
|
@ -449,19 +566,120 @@ public sealed class StreamingController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ApplyResult(LandblockStreamResult result)
|
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)
|
switch (result)
|
||||||
{
|
{
|
||||||
case LandblockStreamResult.Loaded loaded:
|
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);
|
_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
|
// #138: after the landblock is in _loaded (so live projection
|
||||||
// hot-paths), restore any retained server objects ACE won't
|
// hot-paths), restore any retained server objects ACE won't
|
||||||
// re-send. Fired AFTER AddLandblock, never before.
|
// re-send. Fired AFTER AddLandblock, never before.
|
||||||
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
|
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
|
||||||
break;
|
break;
|
||||||
case LandblockStreamResult.Promoted promoted:
|
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);
|
_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);
|
_onLandblockLoaded?.Invoke(promoted.LandblockId);
|
||||||
break;
|
break;
|
||||||
case LandblockStreamResult.Unloaded unloaded:
|
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<WorldEntity>(),
|
||||||
|
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;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the landblock id associated with <paramref name="result"/>.
|
/// Returns the landblock id associated with <paramref name="result"/>.
|
||||||
/// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by
|
/// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,25 @@ public sealed class StreamingRegion
|
||||||
internal static uint EncodeLandblockIdForTest(int lbX, int lbY)
|
internal static uint EncodeLandblockIdForTest(int lbX, int lbY)
|
||||||
=> EncodeLandblockId(lbX, lbY);
|
=> EncodeLandblockId(lbX, lbY);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Two-tier recenter: computes the 5-list diff per Phase A.5 spec §4.2.
|
/// 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
|
/// Hysteresis: NearRadius+2 for Near→Far demote; FarRadius+2 for Far→null
|
||||||
|
|
|
||||||
|
|
@ -566,9 +566,7 @@ public sealed class AnimationSequencer
|
||||||
if (_core.CurrAnim == null && rootMotionFrame is null)
|
if (_core.CurrAnim == null && rootMotionFrame is null)
|
||||||
return BuildIdentityFrame(partCount);
|
return BuildIdentityFrame(partCount);
|
||||||
if (dt <= 0f)
|
if (dt <= 0f)
|
||||||
return _core.CurrAnim == null
|
return SampleCurrentPose();
|
||||||
? BuildIdentityFrame(partCount)
|
|
||||||
: BuildBlendedFrame();
|
|
||||||
|
|
||||||
_core.Update(dt, rootMotionFrame);
|
_core.Update(dt, rootMotionFrame);
|
||||||
|
|
||||||
|
|
@ -577,6 +575,19 @@ public sealed class AnimationSequencer
|
||||||
: BuildBlendedFrame();
|
: BuildBlendedFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Samples the current sequence frame without advancing animation time or
|
||||||
|
/// dispatching hooks. This is retail's hidden-object
|
||||||
|
/// <c>CPhysicsObj::set_frame -> CPartArray::SetFrame -> UpdateParts</c>
|
||||||
|
/// path: <c>HandleEnterWorld</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<PartTransform> SampleCurrentPose()
|
||||||
|
=> _core.CurrAnim == null
|
||||||
|
? BuildIdentityFrame(_setup.Parts.Count)
|
||||||
|
: BuildBlendedFrame();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieve and clear the list of hooks that fired since the last call.
|
/// Retrieve and clear the list of hooks that fired since the last call.
|
||||||
/// Empty when no frame boundary was crossed. Safe to call multiple
|
/// Empty when no frame boundary was crossed. Safe to call multiple
|
||||||
|
|
|
||||||
|
|
@ -200,19 +200,63 @@ public sealed class PhysicsBody
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Commits the position and full cell returned by a successful transition.
|
||||||
|
/// Retail <c>CPhysicsObj::SetPositionInternal(CTransition const*)</c>
|
||||||
|
/// (<c>0x00515330</c>) always commits both
|
||||||
|
/// <c>sphere_path.curr_pos.objcell_id</c> and its frame, including EnvCells.
|
||||||
|
/// The ordinary <see cref="Position"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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
|
// 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.
|
// 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
|
// 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
|
// 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 +
|
// 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;
|
// [0,192) wrap). Idempotent within a cell. A SEEDED indoor body carries the same
|
||||||
// unseeded bodies (ObjCellId==0, e.g. remote entities) and indoor cells are skipped.
|
// 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)
|
private void SyncCellPositionDelta(Vector3 delta)
|
||||||
{
|
{
|
||||||
uint cell = CellPosition.ObjCellId;
|
uint cell = CellPosition.ObjCellId;
|
||||||
if ((cell & 0xFFFFu) is not (>= 1u and <= 0x40u))
|
if (cell == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
Vector3 local = CellPosition.Frame.Origin + delta;
|
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;
|
uint adjusted = cell;
|
||||||
if (LandDefs.AdjustToOutside(ref adjusted, ref local))
|
if (LandDefs.AdjustToOutside(ref adjusted, ref local))
|
||||||
CellPosition = new Position(adjusted, new CellFrame(local, CellPosition.Frame.Orientation));
|
CellPosition = new Position(adjusted, new CellFrame(local, CellPosition.Frame.Orientation));
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ public sealed class PhysicsEngine
|
||||||
public void RemoveLandblock(uint landblockId)
|
public void RemoveLandblock(uint landblockId)
|
||||||
{
|
{
|
||||||
_landblocks.Remove(landblockId);
|
_landblocks.Remove(landblockId);
|
||||||
|
ShadowObjects.DeregisterStaticOwnersForLandblock(landblockId);
|
||||||
ShadowObjects.RemoveLandblock(landblockId);
|
ShadowObjects.RemoveLandblock(landblockId);
|
||||||
DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply
|
DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply
|
||||||
|
|
||||||
|
|
@ -141,6 +142,41 @@ public sealed class PhysicsEngine
|
||||||
DataCache?.CellGraph.RemoveLandblock(landblockId);
|
DataCache?.CellGraph.RemoveLandblock(landblockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public void DemoteLandblockToTerrain(uint landblockId)
|
||||||
|
{
|
||||||
|
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||||
|
if (_landblocks.TryGetValue(canonical, out var landblock))
|
||||||
|
{
|
||||||
|
_landblocks[canonical] = landblock with
|
||||||
|
{
|
||||||
|
Cells = Array.Empty<CellSurface>(),
|
||||||
|
Portals = Array.Empty<PortalPlane>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Find the landblock that contains the given world-space XY position and
|
/// Find the landblock that contains the given world-space XY position and
|
||||||
/// return its ID plus world-space origin offsets. Returns false when no
|
/// return its ID plus world-space origin offsets. Returns false when no
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ public sealed class ShadowObjectRegistry
|
||||||
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
|
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
|
||||||
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
|
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
|
||||||
private readonly HashSet<uint> _suspendedEntities = new();
|
private readonly HashSet<uint> _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<uint, HashSet<uint>> _withdrawnPrefixesByOwner = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
|
/// 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)
|
public void RefloodLandblock(uint landblockId)
|
||||||
{
|
{
|
||||||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||||||
var toReflood = new List<uint>();
|
var toReflood = new HashSet<uint>();
|
||||||
|
|
||||||
foreach (var kvp in _entityReg)
|
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)
|
foreach (uint entityId in toReflood)
|
||||||
{
|
{
|
||||||
var reg = _entityReg[entityId];
|
var reg = _entityReg[entityId];
|
||||||
|
_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawnBeforeReflood);
|
||||||
if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes))
|
if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes))
|
||||||
{
|
{
|
||||||
RegisterMultiPart(entityId, reg.EntityWorldPos, reg.EntityWorldRot, shapes,
|
RegisterMultiPart(entityId, reg.EntityWorldPos, reg.EntityWorldRot, shapes,
|
||||||
|
|
@ -408,6 +418,22 @@ public sealed class ShadowObjectRegistry
|
||||||
reg.CollisionType, reg.CylHeight, reg.Scale,
|
reg.CollisionType, reg.CylHeight, reg.Scale,
|
||||||
reg.State, reg.Flags, reg.SeedCellId, reg.IsStatic);
|
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);
|
_entityShapes.Remove(entityId);
|
||||||
_entityReg.Remove(entityId);
|
_entityReg.Remove(entityId);
|
||||||
_suspendedEntities.Remove(entityId);
|
_suspendedEntities.Remove(entityId);
|
||||||
|
_withdrawnPrefixesByOwner.Remove(entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public void DeregisterStaticOwnersForLandblock(uint landblockId)
|
||||||
|
{
|
||||||
|
uint prefix = landblockId & 0xFFFF0000u;
|
||||||
|
var owners = new List<uint>();
|
||||||
|
foreach (var (entityId, registration) in _entityReg)
|
||||||
|
{
|
||||||
|
if (registration.IsStatic
|
||||||
|
&& (registration.SeedCellId & 0xFFFF0000u) == prefix)
|
||||||
|
{
|
||||||
|
owners.Add(entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (uint entityId in owners)
|
||||||
|
Deregister(entityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -491,6 +540,18 @@ public sealed class ShadowObjectRegistry
|
||||||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||||||
var toRemove = new List<uint>();
|
var toRemove = new List<uint>();
|
||||||
|
|
||||||
|
foreach (var (entityId, cells) in _entityToCells)
|
||||||
|
{
|
||||||
|
if (!cells.Exists(cell => (cell & 0xFFFF0000u) == lbPrefix))
|
||||||
|
continue;
|
||||||
|
if (!_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn))
|
||||||
|
{
|
||||||
|
withdrawn = new HashSet<uint>();
|
||||||
|
_withdrawnPrefixesByOwner[entityId] = withdrawn;
|
||||||
|
}
|
||||||
|
withdrawn.Add(lbPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var kvp in _cells)
|
foreach (var kvp in _cells)
|
||||||
{
|
{
|
||||||
if ((kvp.Key & 0xFFFF0000u) == lbPrefix)
|
if ((kvp.Key & 0xFFFF0000u) == lbPrefix)
|
||||||
|
|
@ -520,6 +581,7 @@ public sealed class ShadowObjectRegistry
|
||||||
_entityShapes.Remove(eid);
|
_entityShapes.Remove(eid);
|
||||||
_entityReg.Remove(eid);
|
_entityReg.Remove(eid);
|
||||||
_suspendedEntities.Remove(eid);
|
_suspendedEntities.Remove(eid);
|
||||||
|
_withdrawnPrefixesByOwner.Remove(eid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -549,6 +611,18 @@ public sealed class ShadowObjectRegistry
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int RetainedRegistrationCount => _entityReg.Count;
|
public int RetainedRegistrationCount => _entityReg.Count;
|
||||||
|
|
||||||
|
/// <summary>Number of owner/prefix repair markers awaiting a future reflood.</summary>
|
||||||
|
public int WithdrawnPrefixMarkerCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
foreach (var prefixes in _withdrawnPrefixesByOwner.Values)
|
||||||
|
count += prefixes.Count;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Suspended logical registrations awaiting spatial re-entry.</summary>
|
/// <summary>Suspended logical registrations awaiting spatial re-entry.</summary>
|
||||||
public int SuspendedRegistrationCount => _suspendedEntities.Count;
|
public int SuspendedRegistrationCount => _suspendedEntities.Count;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,17 @@ public sealed class CellGraph
|
||||||
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
|
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove only a landblock's indoor environment cells while preserving its
|
||||||
|
/// outdoor terrain registration. Used by Near-to-Far streaming demotion.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveEnvCellsForLandblock(uint landblockPrefix)
|
||||||
|
{
|
||||||
|
uint lb = landblockPrefix & 0xFFFF0000u;
|
||||||
|
foreach (var id in new List<uint>(_envCells.Keys))
|
||||||
|
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The universal id->cell resolver (retail CObjCell::GetVisible).</summary>
|
/// <summary>The universal id->cell resolver (retail CObjCell::GetVisible).</summary>
|
||||||
public ObjCell? GetVisible(uint id)
|
public ObjCell? GetVisible(uint id)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,8 @@ public static class LandblockLoader
|
||||||
|
|
||||||
// When landblockId is non-zero, namespace stab Ids globally:
|
// When landblockId is non-zero, namespace stab Ids globally:
|
||||||
// 0xC0XXYY00 + n, where XX = lbX byte, YY = lbY byte
|
// 0xC0XXYY00 + n, where XX = lbX byte, YY = lbY byte
|
||||||
// matching the scenery (0x80XXYY00) and interior (0x40XXYY00) patterns
|
// distinct from the 0x8XXYYIII scenery and 0x4XXYYIII interior
|
||||||
// in GameWindow.cs. The 0xC0 top byte distinguishes stabs from those.
|
// namespaces in GameWindow.cs. The 0xC0 top byte distinguishes stabs.
|
||||||
//
|
//
|
||||||
// Pre-Tier-1 callers (existing tests) pass landblockId=0 and get the
|
// Pre-Tier-1 callers (existing tests) pass landblockId=0 and get the
|
||||||
// legacy starting-from-1 monotonic Ids — compatible with their assertions
|
// legacy starting-from-1 monotonic Ids — compatible with their assertions
|
||||||
|
|
|
||||||
39
src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs
Normal file
39
src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
namespace AcDream.Core.World;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allocates stable, collision-free ids for procedurally generated scenery.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The top nibble is fixed at <c>0x8</c>, 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:
|
||||||
|
/// <c>0x8XXYYIII</c>. No consumer decodes the former byte-aligned
|
||||||
|
/// <c>0x80XXYYII</c> layout; they classify only on bit 31.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -99,7 +99,8 @@ public sealed class TeleportAnimSequencer
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Advance the machine by <paramref name="dt"/> seconds.
|
/// Advance the machine by <paramref name="dt"/> seconds.
|
||||||
/// <paramref name="worldReady"/> = destination collision landblock is resident.
|
/// <paramref name="worldReady"/> = 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.
|
/// Returns the current snapshot + edge-triggered events fired THIS tick.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
|
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
|
||||||
|
|
|
||||||
|
|
@ -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<WorldEntity>());
|
||||||
|
|
||||||
|
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<WorldEntity>());
|
||||||
|
var second = new LoadedLandblock(
|
||||||
|
0x1235FFFFu,
|
||||||
|
new LandBlock(),
|
||||||
|
Array.Empty<WorldEntity>());
|
||||||
|
|
||||||
|
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<ulong> ReadyIds { get; } = new();
|
||||||
|
public Dictionary<ulong, int> ReferenceCounts { get; } = new();
|
||||||
|
public List<ulong> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ArgumentOutOfRangeException>(
|
||||||
|
() => 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<WorldEntity>(),
|
||||||
|
new[] { envCellGeometryId });
|
||||||
|
state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
|
||||||
|
|
||||||
|
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<WorldEntity>()),
|
||||||
|
tier: LandblockStreamTier.Far);
|
||||||
|
|
||||||
|
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
|
||||||
|
|
||||||
|
state.AddEntitiesToExistingLandblock(
|
||||||
|
id,
|
||||||
|
Array.Empty<WorldEntity>(),
|
||||||
|
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<WorldEntity>()),
|
||||||
|
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<LandblockStreamResult>();
|
||||||
|
int ensureCalls = 0;
|
||||||
|
var shell = new EnvCellShellPlacement(
|
||||||
|
0x12360001u,
|
||||||
|
geometryId,
|
||||||
|
0x0D000001u,
|
||||||
|
2,
|
||||||
|
ImmutableArray.Create<ushort>(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<LoadedCell>(),
|
||||||
|
new[] { shell });
|
||||||
|
var build = new LandblockBuild(
|
||||||
|
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||||
|
envCells);
|
||||||
|
outbox.Enqueue(new LandblockStreamResult.Loaded(
|
||||||
|
id,
|
||||||
|
LandblockStreamTier.Near,
|
||||||
|
build,
|
||||||
|
new AcDream.Core.Terrain.LandblockMeshData(
|
||||||
|
Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||||
|
Array.Empty<uint>())));
|
||||||
|
var controller = new StreamingController(
|
||||||
|
enqueueLoad: (_, _) => { },
|
||||||
|
enqueueUnload: _ => { },
|
||||||
|
drainCompletions: max =>
|
||||||
|
{
|
||||||
|
var batch = new List<LandblockStreamResult>();
|
||||||
|
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<LandblockStreamResult>();
|
||||||
|
int ensureCalls = 0;
|
||||||
|
var shell = new EnvCellShellPlacement(
|
||||||
|
0x12360001u,
|
||||||
|
geometryId,
|
||||||
|
0x0D000001u,
|
||||||
|
2,
|
||||||
|
ImmutableArray.Create<ushort>(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<LoadedCell>(),
|
||||||
|
new[] { shell });
|
||||||
|
var nearBuild = new LandblockBuild(
|
||||||
|
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||||
|
envCells);
|
||||||
|
var mesh = new AcDream.Core.Terrain.LandblockMeshData(
|
||||||
|
Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||||
|
Array.Empty<uint>());
|
||||||
|
outbox.Enqueue(new LandblockStreamResult.Promoted(id, nearBuild, mesh));
|
||||||
|
var controller = new StreamingController(
|
||||||
|
enqueueLoad: (_, _) => { },
|
||||||
|
enqueueUnload: _ => { },
|
||||||
|
drainCompletions: max =>
|
||||||
|
{
|
||||||
|
var batch = new List<LandblockStreamResult>();
|
||||||
|
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<WorldEntity>()));
|
||||||
|
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<LandblockStreamResult>();
|
||||||
|
int ensureCalls = 0;
|
||||||
|
var appliedBuilds = new List<LandblockBuild>();
|
||||||
|
var demotedWhileStaticPresent = new List<uint>();
|
||||||
|
var shell = new EnvCellShellPlacement(
|
||||||
|
0x12360001u,
|
||||||
|
geometryId,
|
||||||
|
0x0D000001u,
|
||||||
|
2,
|
||||||
|
ImmutableArray.Create<ushort>(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<LoadedCell>(),
|
||||||
|
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<AcDream.Core.Terrain.TerrainVertex>(),
|
||||||
|
Array.Empty<uint>());
|
||||||
|
outbox.Enqueue(new LandblockStreamResult.Promoted(id, nearBuild, mesh));
|
||||||
|
var controller = new StreamingController(
|
||||||
|
enqueueLoad: (_, _) => { },
|
||||||
|
enqueueUnload: _ => { },
|
||||||
|
drainCompletions: max =>
|
||||||
|
{
|
||||||
|
var batch = new List<LandblockStreamResult>();
|
||||||
|
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<WorldEntity>()));
|
||||||
|
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<LandblockStreamResult>(),
|
||||||
|
(_, _) => { },
|
||||||
|
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<WorldEntity>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ReadinessMeshAdapter : IWbMeshAdapter
|
||||||
|
{
|
||||||
|
public HashSet<ulong> ReadyIds { get; } = new();
|
||||||
|
public Dictionary<ulong, int> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Numerics;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.App.World;
|
using AcDream.App.World;
|
||||||
using AcDream.Content.Vfx;
|
using AcDream.Content.Vfx;
|
||||||
|
|
@ -57,7 +58,7 @@ public sealed class RecallTeleportAnimationTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InstalledRecall_RetiresBeforeTeleportHiddenStateIsDispatched()
|
public void InstalledRecall_HiddenEnterWorldPublishesFinishedCyclicPoseWithoutAdvancingTime()
|
||||||
{
|
{
|
||||||
string datDir = @"C:\Turbine\Asheron's Call";
|
string datDir = @"C:\Turbine\Asheron's Call";
|
||||||
if (!File.Exists(Path.Combine(datDir, "client_portal.dat")))
|
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);
|
aceDuration += (high - anim.LowFrame) / Math.Abs(anim.Framerate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const float updateStep = 1f / 60f;
|
// Advance returns a reusable scratch buffer; snapshot the authored tail
|
||||||
double elapsed = 0.0;
|
// before HandleEnterWorld/SampleCurrentPose overwrites that buffer.
|
||||||
bool hidden = false;
|
PartTransform[] recallTail = sequencer.Advance((float)aceDuration).ToArray();
|
||||||
bool hiddenPacketReady = false;
|
Assert.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(
|
|
||||||
sequencer.CurrentNodeDiag.IsLooping,
|
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<PartTransform> 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,15 +66,56 @@ public class PhysicsBodyCellSyncTests
|
||||||
Assert.Equal(0u, body.CellPosition.ObjCellId);
|
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]
|
[Fact]
|
||||||
public void ContinuousTracking_LongWalkAcrossCellsAndBlock_NeverGoesStale()
|
public void ContinuousTracking_LongWalkAcrossCellsAndBlock_NeverGoesStale()
|
||||||
{
|
{
|
||||||
// Refutes the "CellPosition goes stale" concern (incl. the indoor-transit case):
|
// Refutes outdoor CellPosition drift: the Position setter re-derives the
|
||||||
// because the Position setter re-derives the cell via AdjustToOutside on EVERY
|
// cell via AdjustToOutside on every write, so the carried (cell, local)
|
||||||
// write, CellPosition continuously tracks the outdoor cell under the body's WORLD
|
// pair continuously tracks the body across cells and landblocks.
|
||||||
// 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.
|
|
||||||
var body = new PhysicsBody();
|
var body = new PhysicsBody();
|
||||||
body.SnapToCell(0xC95B0001u, new Vector3(1000f, 2000f, 12f), new Vector3(10f, 10f, 12f));
|
body.SnapToCell(0xC95B0001u, new Vector3(1000f, 2000f, 12f), new Vector3(10f, 10f, 12f));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.World.Cells;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace AcDream.Core.Tests.Physics;
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
@ -31,4 +32,123 @@ public class PhysicsEngineResidencyTests
|
||||||
Assert.True(eng.IsLandblockTerrainResident(0xA9B40019u)); // cell-resolved id, same LB
|
Assert.True(eng.IsLandblockTerrainResident(0xA9B40019u)); // cell-resolved id, same LB
|
||||||
Assert.False(eng.IsLandblockTerrainResident(0xC6A90019u)); // different 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<CellSurface>(),
|
||||||
|
Array.Empty<PortalPlane>(),
|
||||||
|
0f,
|
||||||
|
0f);
|
||||||
|
cache.CellGraph.Add(new EnvCell(
|
||||||
|
0xA9B40174u,
|
||||||
|
Matrix4x4.Identity,
|
||||||
|
Matrix4x4.Identity,
|
||||||
|
Vector3.Zero,
|
||||||
|
Vector3.One,
|
||||||
|
Array.Empty<CellPortal>(),
|
||||||
|
Array.Empty<uint>(),
|
||||||
|
false,
|
||||||
|
null));
|
||||||
|
|
||||||
|
engine.DemoteLandblockToTerrain(landblockId);
|
||||||
|
|
||||||
|
Assert.True(engine.IsLandblockTerrainResident(landblockId));
|
||||||
|
Assert.Null(cache.CellGraph.GetVisible(0xA9B40174u));
|
||||||
|
Assert.IsType<LandCell>(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<CellSurface>(),
|
||||||
|
Array.Empty<PortalPlane>(),
|
||||||
|
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<CellSurface>(),
|
||||||
|
Array.Empty<PortalPlane>(),
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,132 @@ public class ShadowObjectRegistryTests
|
||||||
entry => entry.EntityId == entityId);
|
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
|
// Per-cell query surface (BR-7 / A6.P4 2026-06-11): GetObjectsInCell IS
|
||||||
// the query — retail CObjCell::find_obj_collisions iterates only the
|
// the query — retail CObjCell::find_obj_collisions iterates only the
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ public class LandblockStreamerTests
|
||||||
buildMeshOrNull: (_, _) => stubMesh);
|
buildMeshOrNull: (_, _) => stubMesh);
|
||||||
|
|
||||||
streamer.Start();
|
streamer.Start();
|
||||||
streamer.EnqueueLoad(0xA9B4FFFEu);
|
streamer.EnqueueLoad(0xA9B4FFFEu, generation: 42);
|
||||||
|
|
||||||
// Spin until the worker produces a completion, with a 2s timeout.
|
// Spin until the worker produces a completion, with a 2s timeout.
|
||||||
LandblockStreamResult? result = null;
|
LandblockStreamResult? result = null;
|
||||||
|
|
@ -43,6 +43,7 @@ public class LandblockStreamerTests
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(result);
|
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(result);
|
||||||
Assert.Equal(0xA9B4FFFEu, loaded.LandblockId);
|
Assert.Equal(0xA9B4FFFEu, loaded.LandblockId);
|
||||||
|
Assert.Equal(42ul, loaded.Generation);
|
||||||
Assert.Same(stubLandblock, loaded.Landblock);
|
Assert.Same(stubLandblock, loaded.Landblock);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,7 +248,7 @@ public class LandblockStreamerTests
|
||||||
using var streamer = new LandblockStreamer(loadLandblock: _ => null);
|
using var streamer = new LandblockStreamer(loadLandblock: _ => null);
|
||||||
|
|
||||||
streamer.Start();
|
streamer.Start();
|
||||||
streamer.EnqueueUnload(0xABCD0000u);
|
streamer.EnqueueUnload(0xABCD0000u, generation: 43);
|
||||||
|
|
||||||
LandblockStreamResult? result = null;
|
LandblockStreamResult? result = null;
|
||||||
for (int i = 0; i < SpinMaxIterations && result is null; i++)
|
for (int i = 0; i < SpinMaxIterations && result is null; i++)
|
||||||
|
|
@ -259,6 +260,7 @@ public class LandblockStreamerTests
|
||||||
|
|
||||||
var unloaded = Assert.IsType<LandblockStreamResult.Unloaded>(result);
|
var unloaded = Assert.IsType<LandblockStreamResult.Unloaded>(result);
|
||||||
Assert.Equal(0xABCD0000u, unloaded.LandblockId);
|
Assert.Equal(0xABCD0000u, unloaded.LandblockId);
|
||||||
|
Assert.Equal(43ul, unloaded.Generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,22 @@ public class StreamingControllerDungeonGateTests
|
||||||
Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
|
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]
|
[Fact]
|
||||||
public void InitializeKnownLoginCenter_DungeonPerformsOneCollapseWithoutReloadChurn()
|
public void InitializeKnownLoginCenter_DungeonPerformsOneCollapseWithoutReloadChurn()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using AcDream.App.Streaming;
|
using AcDream.App.Streaming;
|
||||||
using AcDream.Core.Terrain;
|
using AcDream.Core.Terrain;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
|
|
@ -10,10 +11,11 @@ namespace AcDream.Core.Tests.Streaming;
|
||||||
|
|
||||||
public class StreamingControllerPriorityApplyTests
|
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(canonicalId, LandblockStreamTier.Near,
|
||||||
new LoadedLandblock(canonicalId, new LandBlock(), Array.Empty<WorldEntity>()),
|
new LoadedLandblock(canonicalId, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||||
new LandblockMeshData(Array.Empty<TerrainVertex>(), Array.Empty<uint>()));
|
new LandblockMeshData(Array.Empty<TerrainVertex>(), Array.Empty<uint>()),
|
||||||
|
generation);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PriorityLandblock_isApplied_evenWhenBeyondPerFrameCap()
|
public void PriorityLandblock_isApplied_evenWhenBeyondPerFrameCap()
|
||||||
|
|
@ -21,10 +23,10 @@ public class StreamingControllerPriorityApplyTests
|
||||||
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
|
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
|
||||||
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
|
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
|
||||||
{
|
{
|
||||||
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)),
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 181)),
|
||||||
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)),
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 182)),
|
||||||
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)),
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 183)),
|
||||||
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)),
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 184)),
|
||||||
LoadedOf(priority),
|
LoadedOf(priority),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -58,11 +60,11 @@ public class StreamingControllerPriorityApplyTests
|
||||||
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
|
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
|
||||||
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
|
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
|
||||||
{
|
{
|
||||||
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)),
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 181)),
|
||||||
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)),
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 182)),
|
||||||
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)),
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 183)),
|
||||||
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)),
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 184)),
|
||||||
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 4)),
|
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 185)),
|
||||||
LoadedOf(priority),
|
LoadedOf(priority),
|
||||||
});
|
});
|
||||||
var applied = new List<uint>();
|
var applied = new List<uint>();
|
||||||
|
|
@ -101,9 +103,9 @@ public class StreamingControllerPriorityApplyTests
|
||||||
// as the normal throttle, just relocated — until the caller clears
|
// as the normal throttle, just relocated — until the caller clears
|
||||||
// PriorityLandblockId (the TAS MaxContinue safety net does this on timeout).
|
// PriorityLandblockId (the TAS MaxContinue safety net does this on timeout).
|
||||||
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
|
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
|
||||||
uint otherId0 = StreamingRegion.EncodeLandblockIdForTest(1, 0);
|
uint otherId0 = StreamingRegion.EncodeLandblockIdForTest(169, 181);
|
||||||
uint otherId1 = StreamingRegion.EncodeLandblockIdForTest(1, 1);
|
uint otherId1 = StreamingRegion.EncodeLandblockIdForTest(169, 182);
|
||||||
uint otherId2 = StreamingRegion.EncodeLandblockIdForTest(1, 2);
|
uint otherId2 = StreamingRegion.EncodeLandblockIdForTest(169, 183);
|
||||||
|
|
||||||
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
|
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
|
||||||
{
|
{
|
||||||
|
|
@ -146,4 +148,417 @@ public class StreamingControllerPriorityApplyTests
|
||||||
// (c) no double-apply: applied count matches completions enqueued
|
// (c) no double-apply: applied count matches completions enqueued
|
||||||
Assert.Equal(3, applied.Count);
|
Assert.Equal(3, applied.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForceReloadWindow_DiscardsBufferedCompletionsFromOldWindow()
|
||||||
|
{
|
||||||
|
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
|
||||||
|
var outbox = new Queue<LandblockStreamResult>(
|
||||||
|
Enumerable.Range(0, 6)
|
||||||
|
.Select(i => (LandblockStreamResult)LoadedOf(
|
||||||
|
StreamingRegion.EncodeLandblockIdForTest(169, 181 + i))));
|
||||||
|
var applied = new List<uint>();
|
||||||
|
var state = new GpuWorldState();
|
||||||
|
var ctrl = new StreamingController(
|
||||||
|
enqueueLoad: (_, _) => { },
|
||||||
|
enqueueUnload: _ => { },
|
||||||
|
drainCompletions: max =>
|
||||||
|
{
|
||||||
|
var batch = new List<LandblockStreamResult>();
|
||||||
|
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<LandblockStreamResult>();
|
||||||
|
var applied = new List<uint>();
|
||||||
|
var state = new GpuWorldState();
|
||||||
|
var ctrl = new StreamingController(
|
||||||
|
enqueueLoad: (_, _) => { },
|
||||||
|
enqueueUnload: _ => { },
|
||||||
|
drainCompletions: max =>
|
||||||
|
{
|
||||||
|
var batch = new List<LandblockStreamResult>();
|
||||||
|
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<TerrainVertex>(), Array.Empty<uint>());
|
||||||
|
var promotedBuild = new LandblockBuild(
|
||||||
|
new LoadedLandblock(outdoorId, new LandBlock(), new[] { staticEntity }));
|
||||||
|
var outbox = new Queue<LandblockStreamResult>();
|
||||||
|
outbox.Enqueue(new LandblockStreamResult.Promoted(
|
||||||
|
outdoorId,
|
||||||
|
promotedBuild,
|
||||||
|
mesh,
|
||||||
|
Generation: 0));
|
||||||
|
var terrainApplied = new List<uint>();
|
||||||
|
var terrainRemoved = new List<uint>();
|
||||||
|
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<LandblockStreamResult>();
|
||||||
|
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<WorldEntity>()),
|
||||||
|
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<LandblockStreamResult>();
|
||||||
|
var loads = new List<(uint Id, LandblockStreamJobKind Kind)>();
|
||||||
|
var applied = new List<uint>();
|
||||||
|
var state = new GpuWorldState();
|
||||||
|
state.AddLandblock(
|
||||||
|
new LoadedLandblock(target, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||||
|
tier: LandblockStreamTier.Far);
|
||||||
|
var ctrl = new StreamingController(
|
||||||
|
enqueueLoad: (id, kind) => loads.Add((id, kind)),
|
||||||
|
enqueueUnload: _ => { },
|
||||||
|
drainCompletions: max =>
|
||||||
|
{
|
||||||
|
var batch = new List<LandblockStreamResult>();
|
||||||
|
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<TerrainVertex>(), Array.Empty<uint>())));
|
||||||
|
|
||||||
|
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<TerrainVertex>(), Array.Empty<uint>());
|
||||||
|
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
|
||||||
|
{
|
||||||
|
new LandblockStreamResult.Promoted(id, build, mesh),
|
||||||
|
new LandblockStreamResult.Promoted(id, build, mesh),
|
||||||
|
});
|
||||||
|
var applied = new List<uint>();
|
||||||
|
var loadedCallbacks = new List<uint>();
|
||||||
|
var state = new GpuWorldState();
|
||||||
|
var ctrl = new StreamingController(
|
||||||
|
enqueueLoad: (_, _) => { },
|
||||||
|
enqueueUnload: _ => { },
|
||||||
|
drainCompletions: max =>
|
||||||
|
{
|
||||||
|
var batch = new List<LandblockStreamResult>();
|
||||||
|
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<LandblockStreamResult>();
|
||||||
|
var applied = new List<LandblockBuild>();
|
||||||
|
var state = new GpuWorldState();
|
||||||
|
var ctrl = new StreamingController(
|
||||||
|
enqueueLoad: (_, _) => { },
|
||||||
|
enqueueUnload: _ => { },
|
||||||
|
drainCompletions: max =>
|
||||||
|
{
|
||||||
|
var batch = new List<LandblockStreamResult>();
|
||||||
|
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<TerrainVertex>(), Array.Empty<uint>())));
|
||||||
|
|
||||||
|
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<LandblockStreamResult>();
|
||||||
|
var loads = new List<(uint Id, LandblockStreamJobKind Kind, ulong Generation)>();
|
||||||
|
var applied = new List<uint>();
|
||||||
|
var state = new GpuWorldState();
|
||||||
|
var ctrl = new StreamingController(
|
||||||
|
enqueueLoad: (id, kind, generation) => loads.Add((id, kind, generation)),
|
||||||
|
enqueueUnload: (_, _) => { },
|
||||||
|
drainCompletions: max =>
|
||||||
|
{
|
||||||
|
var batch = new List<LandblockStreamResult>();
|
||||||
|
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<LandblockStreamResult>();
|
||||||
|
var loads = new List<(uint Id, LandblockStreamJobKind Kind, ulong Generation)>();
|
||||||
|
var applied = new List<uint>();
|
||||||
|
var ctrl = new StreamingController(
|
||||||
|
enqueueLoad: (id, kind, generation) => loads.Add((id, kind, generation)),
|
||||||
|
enqueueUnload: (_, _) => { },
|
||||||
|
drainCompletions: max =>
|
||||||
|
{
|
||||||
|
var batch = new List<LandblockStreamResult>();
|
||||||
|
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<LandblockStreamResult>();
|
||||||
|
var state = new GpuWorldState();
|
||||||
|
var ctrl = new StreamingController(
|
||||||
|
enqueueLoad: (_, _) => { },
|
||||||
|
enqueueUnload: _ => { },
|
||||||
|
drainCompletions: max =>
|
||||||
|
{
|
||||||
|
var batch = new List<LandblockStreamResult>();
|
||||||
|
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<WorldEntity>()),
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ public class StreamingControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DrainingUnloadedResult_RemovesFromState()
|
public void DrainingUnloadedResult_RemovesUnownedLandblockFromState()
|
||||||
{
|
{
|
||||||
var state = new GpuWorldState();
|
var state = new GpuWorldState();
|
||||||
var fake = new FakeStreamer();
|
var fake = new FakeStreamer();
|
||||||
|
|
@ -103,7 +103,10 @@ public class StreamingControllerTests
|
||||||
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
|
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
|
||||||
(_, _) => { }, state, nearRadius: 2, farRadius: 2);
|
(_, _) => { }, 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<WorldEntity>());
|
var lb = new LoadedLandblock(landblockId, new LandBlock(), System.Array.Empty<WorldEntity>());
|
||||||
state.AddLandblock(lb);
|
state.AddLandblock(lb);
|
||||||
fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(landblockId));
|
fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(landblockId));
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ public class StreamingControllerTwoTierTests
|
||||||
// AddEntitiesToExistingLandblock canonicalizes incoming ids.
|
// AddEntitiesToExistingLandblock canonicalizes incoming ids.
|
||||||
uint lbId = 0x3232FFFFu;
|
uint lbId = 0x3232FFFFu;
|
||||||
var lb = new LoadedLandblock(lbId, Heightmap: null!, Entities: System.Array.Empty<WorldEntity>());
|
var lb = new LoadedLandblock(lbId, Heightmap: null!, Entities: System.Array.Empty<WorldEntity>());
|
||||||
state.AddLandblock(lb);
|
state.AddLandblock(lb, tier: LandblockStreamTier.Far);
|
||||||
Assert.Empty(state.Entities);
|
Assert.Empty(state.Entities);
|
||||||
|
|
||||||
// Streamer pushes a Promoted result carrying the full near landblock.
|
// Streamer pushes a Promoted result carrying the full near landblock.
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,19 @@ public class CellGraphTests
|
||||||
Assert.Null(g.GetVisible(0xA9B40014u));
|
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<LandCell>(g.GetVisible(0xA9B40014u));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Neighbor_ResolvesPortalOtherCellId()
|
public void Neighbor_ResolvesPortalOtherCellId()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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<InvalidDataException>(
|
||||||
|
() => ProceduralSceneryIdAllocator.Allocate(0x12u, 0x36u, ref counter));
|
||||||
|
Assert.Contains("4096-entry", error.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue