fix(portal): synchronize destination presentation state

This commit is contained in:
Erik 2026-07-16 21:17:13 +02:00
parent 4b1bceefbb
commit e95f55f25b
42 changed files with 2815 additions and 288 deletions

View file

@ -44,6 +44,76 @@ Copy this block when adding a new issue:
---
## #218 — Portal silhouette pose, destination reveal, and indoor observer snap
**Status:** IN-PROGRESS — root fixes implemented 2026-07-16; pending connected visual gate
**Severity:** HIGH
**Filed:** 2026-07-16
**Component:** portal VFX / streaming / physics / outbound movement
**Description:** The purple player silhouette in portal space sampled the tail
of the recall action instead of the finished Ready pose. Some outdoor exits
briefly revealed grey/unloaded landblocks during the view-plane zoom. A retail
observer watching acdream enter a dungeon saw movement while commands were
active, but stopping snapped acdream back to the dungeon entrance.
**Root cause / status:** These were three lifecycle truths hidden by the same
portal gate. Hidden entities skipped part-pose composition entirely, so
`HandleEnterWorld` changed the sequence cursor to Ready without publishing that
pose before the zero-time Hidden PES emitted the silhouette. Portal readiness
checked collision residency but not actual mesh/texture render readiness; the
captured destination additionally failed whole landblock builds when dense
procedural scenery exceeded the obsolete 256-id namespace. Finally,
`PhysicsBody.CellPosition` advanced only outdoors: indoor resolves updated the
controller cell but left the canonical outbound frame at the portal entrance.
The implementation now samples Hidden sequence poses without advancing time,
joins render and collision readiness before world reveal, uses a collision-free
`0x8XXYYIII` 4,096-entry scenery namespace, and commits every successful indoor
transition's full cell/frame before movement serialization. Render readiness
tracks both static GfxObjs and EnvCell shell geometry through completed WB
uploads and requires the complete priority ring to be Near-tier. Stale worker
completions carry a hard-recenter generation, so even overlapping old loads and
unloads cannot overwrite the replacement window. A Near load demoted before
its first publication is converted to the equivalent terrain-only Far payload
instead of leaving a hole. Mesh upload no longer manufactures a second logical
owner; static and synthetic EnvCell geometry pins release symmetrically, while
zero-owner late uploads stay evictable. EnvCell publication replays its
schema-aware preparation request after those pins are installed, closing an
eviction race without routing synthetic ids through generic GfxObj decode. The
bounded CPU cache retains texture payloads and re-stages evicted meshes exactly
once instead of returning a never-uploaded or blank cache hit. A self-contained
promotion that supersedes a queued Far load now publishes directly as a real
Near landblock, so readiness cannot wait forever for a base the streamer
intentionally cancelled; later Far completion cannot overwrite that Near tier.
Near-to-Far now tears down the full Near-only App/Core layer while preserving
terrain, and repeated current-generation Near completions are ignored before
they can duplicate statics, scripts, pins, or callbacks. Static collision is
removed by owner before prefix cleanup, including footprints flooded across a
landblock seam, while server-live/dynamic registrations remain refloodable.
Full unload uses the same owner rule; surviving owners seeded across the seam
track withdrawn prefixes so a later reload restores their missing collision
rows.
**Files:** `src/AcDream.Core/Physics/AnimationSequencer.cs`;
`src/AcDream.App/Rendering/GameWindow.cs`;
`src/AcDream.App/Streaming/StreamingController.cs`;
`src/AcDream.App/Streaming/GpuWorldState.cs`;
`src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`;
`src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs`;
`src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs`;
`src/AcDream.Core/Physics/PhysicsBody.cs`;
`src/AcDream.App/Input/PlayerMovementController.cs`.
**Research:** `docs/research/2026-07-16-portal-completion-pseudocode.md`.
**Acceptance:** Recall/portal Hidden particles outline the finished standing
pose; outdoor exits reveal only fully rendered terrain/scenery; and a retail
observer sees acdream run and stop repeatedly inside a dungeon without any
snap back to the entrance.
---
## #217 — Character windows did not receive live 64-bit experience updates
**Status:** DONE — 2026-07-13, connected gate user-confirmed

View file

@ -66,7 +66,7 @@ accepted-divergence entries (#96, #49, #50).
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted). **#145 refinement (2026-06-22):** an OUTDOOR teleport additionally requires the destination's OWN landblock terrain to be resident (`PhysicsEngine.IsLandblockTerrainResident(destCell)`, which `StreamingController` priority-applies so it flips in ~hundreds of ms) before placing — and `SampleTerrainZ` is short-circuited behind it. On a teleport OUT of a dungeon the collapsed SOURCE dungeon stays resident at the same streaming-local coords and answers `SampleTerrainZ` (terrain 0), so without the load check the resolve ran against the dungeon's cells and rooted the player at a dungeon-frame cell id (ACE then rejected all movement as "failed transition"). Decision in `TeleportWorldReady`; the hold→materialize→regain-control transit is driven by `TeleportAnimSequencer` (the retail portal/view-plane lifecycle, ticked in `OnUpdate`), which replaced the retired `TeleportArrivalController` | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportWorldReady` ~5512 + the TAS transit tick in `OnUpdate`) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. Login placement keeps #135's split: a hydratable indoor claim gates on its EnvCell floor (`IsSpawnCellReady`) rather than meaningless dungeon terrain; outdoor placement gates on terrain. **#218 refinement (2026-07-16):** F751 portal-space exit joins BOTH publication domains in `TeleportWorldReady`: indoor requires the center landblock's Near-tier static/EnvCell mesh set uploaded plus the EnvCell in physics, while outdoor requires that Near-tier render readiness plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Rendering/GameWindow.cs` (`TeleportWorldReady` + TAS transit tick); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail completing its blocking cell load before `EndTeleportAnimation`: neither an empty collision world, a terrain-only Far shell, nor a published-but-not-drawable GPU landblock may be revealed. Indoor does not require a terrain heightmap, only the owning render landblock and the exact EnvCell. | Gate opens early → grey/untextured reveal, free-fall, wrong-cell rooting, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → portal transit reaches its existing loud timeout/forced-placement diagnostic instead of silently hanging forever. | retail synchronous cell load before SetPosition / `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |

View file

@ -125,6 +125,27 @@ before installing retail's distant light. The lifecycle, camera, animation,
and draw ordering are acdream-owned ports of `gmSmartBoxUI`; WorldBuilder never
implemented this UI viewport.
**Portal destination render-readiness seam (2026-07-16).**
`GpuWorldState.IsRenderReady` does not treat dictionary publication as a draw
barrier. `LandblockSpawnAdapter` retains the complete required-id set for each
landblock: atlas-tier GfxObjs plus the synthetic geometry ids prepared by the
independent EnvCell shell pipeline. `WbMeshAdapter.IsRenderDataReady` opens the
gate only after `ObjectMeshManager` has real GPU render data. Its bounded CPU
cache retains texture payloads and stages a missing GPU object through a
deduplicated upload queue on cache hit, covering eviction and revisit churn.
GPU upload is deliberately not an ownership acquire: atlas GfxObjs use their
landblock/entity pins, while synthetic EnvCell geometry uses a no-generic-decode
pin balanced from each landblock snapshot. A late upload after all owners have
released enters the evictable LRU instead of resurrecting a reference. After
publication pins the synthetic ids, the controller replays their immutable
environment/cell-structure/surface preparation descriptors; if a formerly
unowned mesh was evicted between worker scheduling and publication, this
schema-aware replay re-stages it without a generic GfxObj lookup.
Near-to-Far demotion is a separate App transaction: it releases EnvCell
rendering and landblock mesh pins while retaining the terrain slot. Core's
matching physics demotion preserves the terrain surface but removes indoor
cells, portals, buildings, and static shadow registrations.
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling
algorithm, **check this inventory first**. If we already extracted it (🟢
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has
@ -194,6 +215,13 @@ Retail `TexMerge::CopyAndTile` (`0x00503580`) and `TexMerge::Merge`
| `SceneryHelpers` | Displace / RotateObj / ScaleObj / ObjAlign / CheckSlope |
| `SceneryInstance` | Per-spawn instance data |
acdream's streamed projection assigns generated instances local runtime IDs
through `AcDream.Core.World.ProceduralSceneryIdAllocator`. The namespace is
`0x8XXYYIII` (full X/Y bytes plus a 12-bit counter); bit 31 remains the stable
scenery classification seam. This is projection identity, not placement
behavior. The former 8-bit counter rejected dense retail-DAT landblocks before
their render transaction could publish (#218).
### Static objects (buildings, slabs, props — Setup + GfxObj + ObjDesc)
| Component | What it does |

View file

@ -27,7 +27,8 @@ active enchantment, then recall through the DAT-driven portal presentation.
3. **Pending user visual gate:** connected projectile/self-buff casts plus the
complete retained magic UI interaction checklist.
4. **Pending user visual gate:** two-client portal-out/materialization observer
presentation before M3 closes.
presentation before M3 closes, including #218's finished Hidden silhouette,
fully rendered destination reveal, and no indoor stop-time position snap.
Opportunistic UI/physics fixes are no longer the work-order driver. They enter
the active slice only when they block this demo or represent a severe regression.

View file

@ -332,6 +332,37 @@ the Lifestone recall boundary and a MagicPortal casting timeline. The user
confirmed `/ls` and spell-recall arrivals no longer replay their animation
tails; additional portal visual polish is explicitly deferred.
**2026-07-16 corrective portal gate (#218).** A side-by-side retail observer
check found three distinct state-publication defects after the arrival-tail
fix. Hidden now recomposes the post-`HandleEnterWorld` cyclic pose without
advancing animation time before its zero-time purple PES hooks run. Portal
space now waits for both completed static/EnvCell mesh uploads (including the
texture flush) and collision residency; dense
outdoor landblocks no longer abort at the obsolete 256-entry procedural
scenery id boundary (`0x8XXYYIII`, 4,096 entries). Finally, successful indoor
transitions commit the full resolved EnvCell/frame into the canonical outbound
Position, preventing stop-time snaps to the dungeon entrance on retail
observers. GPU-evicted meshes are re-staged from a bounded texture-complete CPU
cache instead of becoming false-ready or permanently absent. Portal readiness
also requires Near-tier ownership throughout the ring; stale worker completions
carry a hard-recenter generation and cannot overwrite a newer tier/window, and
a first Near completion demoted in flight is published as its equivalent
terrain-only Far payload rather than dropped. Upload completion is separate
from logical mesh ownership; landblock/static/EnvCell pins now balance across
unload and zero-owner late uploads remain evictable. A self-contained promotion
that supersedes a queued Far load publishes directly as a real Near landblock;
there is no provisional transaction waiting indefinitely for a base that the
streamer intentionally cancelled. Near-to-Far now has an explicit cross-layer
demotion transaction that preserves terrain while retiring EnvCells, indoor
physics/buildings, static collision, lights, translucency state, entities, and
render pins. Cross-landblock static shadow footprints are removed by logical
owner while dynamic registrations remain refloodable; duplicate Near
completions are publication-idempotent. Full unload shares the static-owner
teardown, and surviving adjacent owners remember withdrawn prefixes so their
cross-seam rows return when a landblock is promoted again.
Automated gates are green; the connected single-/two-client visual gate is
pending.
---
#### (historical M1.5 working notes below)

View file

@ -24,7 +24,7 @@ OUTSIDE SLICE — DrawLandscapeThroughOutsideView (RetailPViewRenderer.cs:214-23
SKYRENDERER — src/AcDream.App/Rendering/Sky/SkyRenderer.cs (WB SkyboxRenderManager port): RenderSky draws the pre-scene partition (Properties bit 0x01 clear), RenderWeather the post-scene partition (bit set) (:106-144, 219-235 — correct retail bit semantics, citing MakeObject 0x00506ee0). GL state: depth test OFF + depth mask OFF + cull off + per-submesh additive/alpha blend (:194-210) — equivalent to retail's DEPTHTEST_ALWAYS/no-write. View translation zeroed = camera-centred sky (:175-178). Weather 120 offset applied as a CAMERA-RELATIVE model translation for bit4 && !bit8 objects (:307-308). Fog-override bit 0x02 skip implemented (:240-241). sky.vert writes gl_ClipDistance from the terrain-clip UBO (src/AcDream.App/Rendering/Shaders/sky.vert:153) so the doorway slices genuinely clip sky and rain.
SCENERY — SceneryGenerator.Generate (src/AcDream.Core/World/SceneryGenerator.cs:86 via WbSceneryAdapter) → GameWindow.BuildSceneryEntitiesForStreaming (GameWindow.cs:5290-5473): scenery becomes WorldEntity ids 0x80XXYYII with MeshRefs and NO ParentCellId (5463-5472) → lands in the Outdoor partition bucket → drawn once per outside slice with per-entity frustum cull + slice clip planes. Building suppression at generation uses a 9×9 vertex-grid set derived from building origins (5310-5316). There are no per-LandCell object lists outdoors — the bucket is flat.
SCENERY — SceneryGenerator.Generate (src/AcDream.Core/World/SceneryGenerator.cs:86 via WbSceneryAdapter) → GameWindow.BuildSceneryEntitiesForStreaming (GameWindow.cs:5290-5473): scenery becomes WorldEntity ids 0x8XXYYIII (bit-31 class + X8/Y8/counter12) with MeshRefs and NO ParentCellId (5463-5472) → lands in the Outdoor partition bucket → drawn once per outside slice with per-entity frustum cull + slice clip planes. Building suppression at generation uses a 9×9 vertex-grid set derived from building origins (5310-5316). There are no per-LandCell object lists outdoors — the bucket is flat.
## DIVERGENCES

View 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.

View file

@ -1133,7 +1133,7 @@ public sealed class PlayerMovementController
body: _body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: LocalEntityId);
_body.Position = resolved.Position;
_body.CommitTransitionPosition(resolved.CellId, resolved.Position);
PhysicsObjUpdate.CommitSetPositionTransition(
_body,
resolved.InContact,
@ -1660,7 +1660,7 @@ public sealed class PlayerMovementController
bool prevContact = _body.InContact;
bool prevOnWalkable = _body.OnWalkable;
_body.Position = resolveResult.Position;
_body.CommitTransitionPosition(resolveResult.CellId, resolveResult.Position);
if (physicsTickRan)
{
_prevPhysicsPos = oldTickEndPos;

View file

@ -2714,8 +2714,8 @@ public sealed class GameWindow : IDisposable
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
enqueueUnload: _streamer.EnqueueUnload,
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation),
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
state: _worldState,
@ -2743,10 +2743,12 @@ public sealed class GameWindow : IDisposable
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
_envCellRenderer?.RemoveLandblock(id); // Phase A8
},
demoteNearLayer: DemoteLoadedLandblockToTerrain,
// #138: restore retained server objects when a landblock reloads
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
// objects it thinks we still know, so we re-project them ourselves.
onLandblockLoaded: RehydrateServerEntitiesForLandblock);
onLandblockLoaded: RehydrateServerEntitiesForLandblock,
ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin);
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
@ -7058,14 +7060,12 @@ public sealed class GameWindow : IDisposable
}
/// <summary>
/// worldReady for the TAS transit: is the player's teleport destination resident so we
/// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell
/// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around
/// the player, <see cref="TeleportNearRingRadius"/>) being registered — not just the
/// single destination landblock — so portal space exits onto a loaded, collidable world
/// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no
/// "only one landblock loaded"). The streaming controller priority-applies that same ring,
/// so it flips fast. An impossible claim (indoor cell id outside the dat's NumCells)
/// worldReady for the TAS transit: is the player's teleport destination BOTH rendered
/// and collidable so we can materialize? Retail crosses this edge after its blocking cell
/// load. acdream's async equivalent must join the render publication barrier
/// (<see cref="StreamingController.IsRenderNeighborhoodResident"/>) with physics
/// residency. Indoor destinations require the center render landblock + EnvCell; outdoor
/// destinations require both domains for the priority near ring. An impossible claim
/// returns true so the TAS stops holding and the forced placement surfaces the failure
/// loudly rather than holding forever.
/// </summary>
@ -7073,9 +7073,14 @@ public sealed class GameWindow : IDisposable
{
if (IsSpawnClaimUnhydratable(destCell)) return true;
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
int renderRadius = indoor ? 0 : TeleportNearRingRadius;
bool renderReady = _streamingController?.IsRenderNeighborhoodResident(
destCell,
renderRadius) == true;
return indoor
? _physicsEngine.IsSpawnCellReady(destCell)
: _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
? renderReady && _physicsEngine.IsSpawnCellReady(destCell)
: renderReady
&& _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
}
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
@ -7560,6 +7565,13 @@ public sealed class GameWindow : IDisposable
completedEnvCells);
}
private void EnsureEnvCellMeshesAfterPin(
AcDream.App.Rendering.Wb.EnvCellLandblockBuild build)
{
if (_wbMeshAdapter?.MeshManager is { } meshManager)
AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(build, meshManager);
}
/// <summary>
/// Pre-reads (under the worker's <c>_datLock</c>) every dat object
/// <see cref="ApplyLoadedTerrainLocked"/> consumes, so the apply makes zero
@ -7682,15 +7694,13 @@ public sealed class GameWindow : IDisposable
(lbY - _liveCenterY) * 192f,
0f);
// Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF
// where XX = landblock X coord (bits 24-31), YY = Y coord (bits 16-23).
// Both must go into our ID so landblocks don't collide.
// Format: 0x80 | XX | YY | local_index(8 bits) = 0x80XXYY_II.
// 256 slots per landblock is enough (SceneryGenerator caps ~200).
// Per-landblock id namespace. The top nibble identifies procedural
// scenery and the full X/Y bytes plus a 12-bit counter prevent dense
// DAT-generated landblocks from overflowing into a neighbour's range.
// See ProceduralSceneryIdAllocator for the audited 0x8XXYYIII layout.
uint lbXByte = (lb.LandblockId >> 24) & 0xFFu;
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
uint sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8);
uint localIndex = 0;
uint sceneryCounter = 0;
foreach (var spawn in spawns)
{
@ -7836,13 +7846,12 @@ public sealed class GameWindow : IDisposable
}
}
if (localIndex > 0xFFu)
throw new InvalidDataException(
$"Landblock 0x{lb.LandblockId:X8} exceeds the 256-entry procedural scenery id namespace.");
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = sceneryIdBase + localIndex++,
Id = AcDream.Core.World.ProceduralSceneryIdAllocator.Allocate(
lbXByte,
lbYByte,
ref sceneryCounter),
SourceGfxObjOrSetupId = spawn.ObjectId,
Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset,
Rotation = spawn.Rotation,
@ -8135,6 +8144,31 @@ public sealed class GameWindow : IDisposable
return result;
}
/// <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>
/// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick
/// whenever a new landblock's terrain + entities are ready for GPU upload.
@ -8568,7 +8602,7 @@ public sealed class GameWindow : IDisposable
// 0xC0XXYY00+n layout per LandblockLoader.cs:55. Their BSP
// collision covers the whole structure; the mesh-AABB-fallback
// path below is for canopy-only-BSP procedural scenery
// (0x80XXYY00+n) and produces a redundant 1.5m-clamped
// (0x8XXYYIII) and produces a redundant 1.5m-clamped
// invisible disc at the stab's mesh origin — the user-reported
// "thin air" collision inside cottages. Gate the fallback to
// exclude stabs. Spec:
@ -10746,20 +10780,15 @@ public sealed class GameWindow : IDisposable
&& _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false)
continue;
// Retail UpdatePositionInternal keeps a narrow Hidden path alive:
// no PartArray animation and no physics integration, but
// PositionManager::adjust_offset plus the manager tail still run.
// Scripts and particles tick in their shared passes below.
if (serverGuid != 0 && _liveEntities?.IsHidden(serverGuid) == true)
{
// Hidden suppresses the PartArray/mesh, not the effect owner.
// Remote PositionManager composition already ran in the
// unconditional live-entity pass; publish again for the local
// player and for retained objects without remote motion.
// Frozen part-local poses remain unchanged until UnHide.
_effectPoses.UpdateRoot(ae.Entity);
continue;
}
// Retail Hidden suppresses PartArray TIME ADVANCE, not part-pose
// composition. set_hidden -> HandleEnterWorld can move CSequence's
// cursor from a finished link/action to the first cyclic node; the
// next hidden CPhysicsObj::UpdateObjectInternal (0x005156B0)
// performs UpdatePositionInternal and then reaches set_frame ->
// CPartArray::UpdateParts, publishing that newly-current pose before
// the zero-time Hidden PES creates its silhouette particles.
bool hidden = serverGuid != 0
&& _liveEntities?.IsHidden(serverGuid) == true;
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
// The server broadcasts UpdatePosition at ~5-10Hz for distant
@ -10774,7 +10803,8 @@ public sealed class GameWindow : IDisposable
// runaway when the sequencer's velocity and the server's reality
// disagree (e.g. server is rubber-banding the entity). Retail
// uses a similar clamp at PhysicsObj::IsInterpolationComplete.
if (ae.Sequencer is not null
if (!hidden
&& ae.Sequencer is not null
&& serverGuid != 0
&& serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm)
@ -10839,7 +10869,9 @@ public sealed class GameWindow : IDisposable
rmDiag.LastSeqStateLogTime = nowSec;
}
}
seqFrames = ae.Sequencer.Advance(dt);
seqFrames = hidden
? ae.Sequencer.SampleCurrentPose()
: ae.Sequencer.Advance(dt);
// Capture hooks now, but deliver them only after every final
// part transform and equipped-child root has been published.
@ -10847,21 +10879,25 @@ public sealed class GameWindow : IDisposable
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
// reads this frame's pose, never the previous frame's pose.
// AnimationDone/UseTime remain paired with the deferred drain.
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
if (!hidden)
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
}
else
{
// Legacy path (entities without a MotionTable / sequencer).
int span = ae.HighFrame - ae.LowFrame;
if (span <= 0) continue;
ae.CurrFrame += dt * ae.Framerate;
if (ae.CurrFrame > ae.HighFrame)
if (span <= 0 && !hidden) continue;
if (!hidden)
{
float over = ae.CurrFrame - ae.LowFrame;
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
ae.CurrFrame += dt * ae.Framerate;
if (ae.CurrFrame > ae.HighFrame)
{
float over = ae.CurrFrame - ae.LowFrame;
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
}
else if (ae.CurrFrame < ae.LowFrame)
ae.CurrFrame = ae.LowFrame;
}
else if (ae.CurrFrame < ae.LowFrame)
ae.CurrFrame = ae.LowFrame;
}
int partCount = ae.PartTemplate.Count;
@ -12357,8 +12393,8 @@ public sealed class GameWindow : IDisposable
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
enqueueUnload: _streamer.EnqueueUnload,
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(id, kind, generation),
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
state: _worldState,
@ -12381,7 +12417,9 @@ public sealed class GameWindow : IDisposable
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
_envCellRenderer?.RemoveLandblock(id); // Phase A8
});
},
demoteNearLayer: DemoteLoadedLandblockToTerrain,
ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin);
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;
Console.WriteLine(

View file

@ -11,12 +11,11 @@ namespace AcDream.App.Rendering.Wb;
/// <para>
/// <b>Key composition:</b> entries are keyed by the tuple
/// <c>(EntityId, LandblockHint)</c>, NOT by <c>EntityId</c> alone. Issue #53
/// uncovered that <c>entity.Id</c> is NOT globally unique across all
/// static-entity hydration paths: scenery (<c>0x80XXYY00 + localIndex</c>)
/// and interior cells (<c>0x40XXYY00 + localCounter</c>, X-byte fixed
/// 2026-06-11 — it used to be discarded entirely, #119) overflow at >256
/// items per landblock, wrapping into the <c>lbY</c> byte and producing
/// cross-LB collisions in dense forest/urban LBs outside Holtburg. Keying
/// uncovered that older hydration IDs were not globally unique across all
/// static-entity paths: their former byte-aligned counters overflowed at
/// more than 256 items per landblock and wrapped into the <c>lbY</c> byte.
/// The current <c>0x8XXYYIII</c> scenery and <c>0x4XXYYIII</c> interior
/// allocators reserve 12-bit counters and fail before aliasing. Keying
/// by the tuple is correct-by-construction ONLY when the hint identifies the
/// entity's OWNING landblock — callers must derive it via
/// <c>WbDrawDispatcher.ResolveCacheLandblockHint</c> (the entity's
@ -64,8 +63,8 @@ internal sealed class EntityClassificationCache
/// <summary>
/// Look up an entity's cached classification. Keyed by both
/// <paramref name="entityId"/> AND <paramref name="landblockHint"/> to
/// disambiguate entities whose Ids collide across landblocks (e.g.,
/// scenery's <c>0x80LLBB00 + localIndex</c> overflow at >256 items/LB).
/// preserve defensive isolation if a future hydration path introduces an
/// entity-id collision across landblocks.
/// Returns <c>true</c> with the entry on hit; <c>false</c> with
/// <paramref name="entry"/> set to <c>null</c> on miss.
/// </summary>

View file

@ -9,4 +9,19 @@ public interface IWbMeshAdapter
{
void IncrementRefCount(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;
}

View file

@ -15,13 +15,14 @@ namespace AcDream.App.Rendering.Wb;
/// <para>
/// On load: walks the landblock's atlas-tier entities, collects unique
/// GfxObj ids from their <c>MeshRefs</c>, calls
/// <c>IncrementRefCount</c> per id. Snapshots the id-set per landblock so
/// unload can match the load 1:1.
/// <c>IncrementRefCount</c> per id, and pins each specialized EnvCell geometry
/// id without starting generic GfxObj decode. Snapshots both id-sets per
/// landblock so unload can match the load 1:1.
/// </para>
///
/// <para>
/// On unload: looks up the snapshot, calls <c>DecrementRefCount</c> per id,
/// drops the snapshot. Unknown / never-loaded landblocks no-op.
/// On unload: looks up both snapshots, calls <c>DecrementRefCount</c> per id,
/// drops the snapshots. Unknown / never-loaded landblocks no-op.
/// </para>
///
/// <para>
@ -34,11 +35,9 @@ namespace AcDream.App.Rendering.Wb;
/// </para>
///
/// <para>
/// Thread safety: the underlying <see cref="IWbMeshAdapter"/> implementation
/// uses <c>ConcurrentDictionary</c>, so the streaming worker thread may call
/// this safely. The internal snapshot dictionary is NOT thread-safe and must
/// be called from a single streaming thread (the same thread that fires
/// AddLandblock / RemoveLandblock events).
/// Thread safety: the internal snapshots are intentionally not synchronized.
/// <see cref="AcDream.App.Streaming.GpuWorldState"/> invokes every load, unload, and readiness query
/// on the owning render/update thread.
/// </para>
/// </summary>
public sealed class LandblockSpawnAdapter
@ -48,6 +47,10 @@ public sealed class LandblockSpawnAdapter
// Maps landblock id → unique GfxObj ids registered for that landblock.
// Written on load, read+cleared on unload. Single-threaded (streaming worker).
private readonly Dictionary<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)
{
@ -61,7 +64,9 @@ public sealed class LandblockSpawnAdapter
/// unique atlas-tier GfxObj id that has not already been registered for
/// this landblock.
/// </summary>
public void OnLandblockLoaded(LoadedLandblock landblock)
public void OnLandblockLoaded(
LoadedLandblock landblock,
IEnumerable<ulong>? additionalReadinessIds = null)
{
System.ArgumentNullException.ThrowIfNull(landblock);
@ -80,14 +85,51 @@ public sealed class LandblockSpawnAdapter
{
_idsByLandblock[landblock.LandblockId] = unique;
foreach (var id in unique) _adapter.IncrementRefCount(id);
return;
}
else
{
foreach (var id in unique)
{
if (registered.Add(id))
_adapter.IncrementRefCount(id);
}
}
foreach (var id in unique)
if (!_additionalReadinessIdsByLandblock.TryGetValue(
landblock.LandblockId,
out var additional))
{
if (registered.Add(id))
_adapter.IncrementRefCount(id);
additional = new HashSet<ulong>();
_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>
@ -99,6 +141,9 @@ public sealed class LandblockSpawnAdapter
{
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
foreach (var id in unique) _adapter.DecrementRefCount(id);
if (_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
foreach (var id in additional) _adapter.DecrementRefCount(id);
_idsByLandblock.Remove(landblockId);
_additionalReadinessIdsByLandblock.Remove(landblockId);
}
}

View 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();
}
}
}

View file

@ -105,7 +105,7 @@ namespace AcDream.App.Rendering.Wb {
public bool IsDisposed { get; private set; }
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, 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();
// 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 CpuMeshUploadCache _cpuMeshCache;
private readonly ConcurrentQueue<ObjectMeshData> _stagedMeshData = new();
public ConcurrentQueue<ObjectMeshData> StagedMeshData => _stagedMeshData;
private readonly MeshUploadStagingQueue _stagedMeshData = new();
/// <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
@ -141,17 +139,28 @@ namespace AcDream.App.Rendering.Wb {
/// on re-prepare) and gives up loudly past <see cref="MaxUploadRetries"/>.
/// </summary>
public bool UploadOrRequeue(ObjectMeshData meshData) {
if (UploadMeshData(meshData) is not null)
if (UploadMeshData(meshData) is not null) {
_stagedMeshData.Complete(meshData.ObjectId);
return false; // success (incl. legitimate 0-vertex → empty render data)
if (HasRenderData(meshData.ObjectId))
}
if (HasRenderData(meshData.ObjectId)) {
_stagedMeshData.Complete(meshData.ObjectId);
return false; // raced to present by another path
}
meshData.UploadAttempts++;
if (meshData.UploadAttempts < MaxUploadRetries)
return true; // re-stage for next frame
_stagedMeshData.Complete(meshData.ObjectId);
Console.WriteLine($"[up-retry] 0x{meshData.ObjectId:X10} upload failed {meshData.UploadAttempts}x — giving up (was the #125 silent sticky drop; a GL error is being surfaced, not hidden)");
return false;
}
internal bool TryDequeueStagedMeshData(out ObjectMeshData? data) =>
_stagedMeshData.TryDequeue(out data);
internal void RequeueStagedMeshData(ObjectMeshData data) =>
_stagedMeshData.Requeue(data);
public GlobalMeshBuffer? GlobalBuffer { get; }
private readonly bool _useModernRendering;
@ -167,7 +176,8 @@ namespace AcDream.App.Rendering.Wb {
// did — immediate enqueue, surviving a later throw in the same
// Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the
// extractor's up-to-4 concurrent decode workers.
_extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Enqueue(data));
_cpuMeshCache = new CpuMeshUploadCache(_maxCpuCacheSize);
_extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Stage(data));
_useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless;
if (_useModernRendering) {
GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL);
@ -180,7 +190,7 @@ namespace AcDream.App.Rendering.Wb {
/// </summary>
public ObjectRenderData? GetRenderData(ulong id) {
if (_renderData.TryGetValue(id, out var data)) {
_usageCount.AddOrUpdate(id, 1, (_, count) => count + 1);
_ownership.Acquire(id);
if (data.IsSetup) {
foreach (var (partId, _) in data.SetupParts) {
@ -223,7 +233,7 @@ namespace AcDream.App.Rendering.Wb {
/// Increment reference count for an object (e.g. when a landblock starts using it).
/// </summary>
public void IncrementRefCount(ulong id) {
_usageCount.AddOrUpdate(id, 1, (_, count) => count + 1);
_ownership.Acquire(id);
lock (_lruList) {
_lruList.Remove(id);
}
@ -258,7 +268,7 @@ namespace AcDream.App.Rendering.Wb {
/// Decrement reference count and unload GPU resources if no longer needed.
/// </summary>
public void DecrementRefCount(ulong id) {
var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1);
var newCount = _ownership.Release(id);
if (newCount <= 0) {
// Instead of unloading, move to LRU
lock (_lruList) {
@ -272,8 +282,8 @@ namespace AcDream.App.Rendering.Wb {
/// Decrement reference count and unload if no longer needed.
/// </summary>
public void ReleaseRenderData(ulong id) {
if (_usageCount.TryGetValue(id, out var count) && count > 0) {
var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1);
if (_ownership.IsOwned(id)) {
var newCount = _ownership.Release(id);
if (newCount <= 0) {
// Instead of unloading, move to LRU
lock (_lruList) {
@ -291,9 +301,9 @@ namespace AcDream.App.Rendering.Wb {
var idToEvict = _lruList.First!.Value;
_lruList.RemoveFirst();
if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) {
if (!_ownership.IsOwned(idToEvict)) {
UnloadObject(idToEvict);
_usageCount.TryRemove(idToEvict, out _);
_ownership.Remove(idToEvict);
}
}
}
@ -309,18 +319,15 @@ namespace AcDream.App.Rendering.Wb {
var idToEvict = _lruList.First!.Value;
_lruList.RemoveFirst();
if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) {
if (!_ownership.IsOwned(idToEvict)) {
UnloadObject(idToEvict);
_usageCount.TryRemove(idToEvict, out _);
_ownership.Remove(idToEvict);
}
}
}
// Also clear CPU mesh cache
lock (_cpuMeshCache) {
_cpuMeshCache.Clear();
_cpuLruList.Clear();
}
_cpuMeshCache.Clear();
}
public struct EnvCellGeomRequest {
@ -338,13 +345,8 @@ namespace AcDream.App.Rendering.Wb {
if (IsDisposed || HasRenderData(geomId)) return Task.FromResult<ObjectMeshData?>(null);
// Check CPU cache first
lock (_cpuMeshCache) {
if (_cpuMeshCache.TryGetValue(geomId, out var cachedData)) {
_cpuLruList.Remove(geomId);
_cpuLruList.AddLast(geomId);
return Task.FromResult<ObjectMeshData?>(cachedData);
}
}
if (_cpuMeshCache.TryGetAndStage(geomId, _stagedMeshData, out var cachedData))
return Task.FromResult(cachedData);
// Return existing task if already running or queued
if (_preparationTasks.TryGetValue(geomId, out var existing)) {
@ -381,13 +383,8 @@ namespace AcDream.App.Rendering.Wb {
if (IsDisposed || HasRenderData(id)) return Task.FromResult<ObjectMeshData?>(null);
// Check CPU cache first
lock (_cpuMeshCache) {
if (_cpuMeshCache.TryGetValue(id, out var cachedData)) {
_cpuLruList.Remove(id);
_cpuLruList.AddLast(id);
return Task.FromResult<ObjectMeshData?>(cachedData);
}
}
if (_cpuMeshCache.TryGetAndStage(id, _stagedMeshData, out var cachedData))
return Task.FromResult(cachedData);
// Return existing task if already running or queued
if (_preparationTasks.TryGetValue(id, out var existing)) {
@ -476,16 +473,8 @@ namespace AcDream.App.Rendering.Wb {
data = _extractor.PrepareMeshData(id, isSetup, CancellationToken.None);
}
if (data != null) {
lock (_cpuMeshCache) {
if (_cpuMeshCache.Count >= _maxCpuCacheSize) {
var oldest = _cpuLruList.First!.Value;
_cpuLruList.RemoveFirst();
_cpuMeshCache.Remove(oldest);
}
_cpuMeshCache[id] = data;
_cpuLruList.AddLast(id);
}
_stagedMeshData.Enqueue(data);
_cpuMeshCache.Store(data);
_stagedMeshData.Stage(data);
}
tcs.TrySetResult(data);
}
@ -538,26 +527,7 @@ namespace AcDream.App.Rendering.Wb {
try {
if (_renderData.TryGetValue(meshData.ObjectId, out var existing)) {
_preparationTasks.TryRemove(meshData.ObjectId, out _);
if (existing.IsSetup) {
foreach (var (partId, _) in existing.SetupParts) {
IncrementRefCount(partId);
lock (_lruList) {
_lruList.Remove(partId);
}
}
}
else {
// Increment ref counts for all textures in this GfxObj
foreach (var batch in existing.Batches) {
if (batch.Atlas != null) {
batch.Atlas.AddTexture(batch.Key, Array.Empty<byte>());
}
}
}
IncrementRefCount(meshData.ObjectId);
lock (_lruList) {
_lruList.Remove(meshData.ObjectId);
}
UpdateLruAfterUpload(meshData.ObjectId);
return existing;
}
@ -588,7 +558,6 @@ namespace AcDream.App.Rendering.Wb {
MemorySize = 1024 // Small overhead for the setup itself
};
_renderData.TryAdd(meshData.ObjectId, data);
IncrementRefCount(meshData.ObjectId);
_currentGpuMemory += data.MemorySize;
// Increment ref counts for all parts
@ -596,6 +565,8 @@ namespace AcDream.App.Rendering.Wb {
IncrementRefCount(partId);
}
UpdateLruAfterUpload(meshData.ObjectId);
return data;
}
@ -619,15 +590,12 @@ namespace AcDream.App.Rendering.Wb {
renderData.DIDDegrade = meshData.DIDDegrade;
renderData.SelectionSphere = meshData.SelectionSphere;
_renderData.TryAdd(meshData.ObjectId, renderData);
IncrementRefCount(meshData.ObjectId);
_currentGpuMemory += renderData.MemorySize;
UpdateLruAfterUpload(meshData.ObjectId);
// Clear texture data after upload to save RAM
foreach (var batchList in meshData.TextureBatches.Values) {
foreach (var batch in batchList) {
batch.TextureData = Array.Empty<byte>();
}
}
// Keep the bounded CPU cache's texture payload intact. GPU LRU
// eviction may need to upload this same prepared mesh again;
// clearing these bytes made a cache hit produce blank textures.
return renderData;
}
catch (Exception ex) {
@ -636,6 +604,14 @@ namespace AcDream.App.Rendering.Wb {
}
}
private void UpdateLruAfterUpload(ulong id) {
lock (_lruList) {
_lruList.Remove(id);
if (!_ownership.MarkUploadComplete(id))
_lruList.AddLast(id);
}
}
/// <summary>
/// Gets bounding box for an object (for frustum culling).
/// </summary>

View file

@ -151,6 +151,15 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
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/>
public void IncrementRefCount(ulong id)
{
@ -199,6 +208,16 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_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>
/// #128 self-heal (2026-06-11): re-request a mesh load at the POINT OF
/// USE. Registration-time re-arming was insufficient — a preparation
@ -252,14 +271,16 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
// a genuine defect surfaces loudly instead of the old silent sticky drop.
List<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))
(requeue ??= new()).Add(meshData);
}
if (requeue is not null)
foreach (var m in requeue)
_meshManager.StagedMeshData.Enqueue(m);
_meshManager.RequeueStagedMeshData(m);
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
var pendingBefore = texProbe

View file

@ -67,6 +67,7 @@ public sealed class GpuWorldState
}
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
private readonly Dictionary<uint, LandblockStreamTier> _tierByLandblock = new();
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
/// <summary>
@ -75,6 +76,12 @@ public sealed class GpuWorldState
/// Drained into <see cref="_loaded"/> in <see cref="AddLandblock"/>.
/// </summary>
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>
/// 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 bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
public bool IsNearTier(uint landblockId) =>
_tierByLandblock.TryGetValue(landblockId, out var tier)
&& tier == LandblockStreamTier.Near;
public bool IsNearTierOrPending(uint landblockId) =>
IsNearTier(landblockId) || _pendingNearTierLandblocks.Contains(landblockId);
public bool IsRenderReady(uint landblockId) =>
_loaded.ContainsKey(landblockId)
&& (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true);
public bool IsLiveEntityVisible(uint serverGuid) =>
serverGuid != 0 && _visibleLiveGuids.Contains(serverGuid);
@ -204,8 +219,17 @@ public sealed class GpuWorldState
public int PersistentGuidCount => _persistentGuids.Count;
public int PendingVisibilityTransitionCount => _visibilityTransitions.Count;
public void AddLandblock(LoadedLandblock landblock)
public void AddLandblock(
LoadedLandblock landblock,
IEnumerable<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,
// merge them into the LoadedLandblock record before storing. The
// record's Entities field is IReadOnlyList; we replace the whole
@ -222,9 +246,29 @@ public sealed class GpuWorldState
_pendingByLandblock.Remove(landblock.LandblockId);
}
HashSet<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;
_tierByLandblock[landblock.LandblockId] = tier;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
_wbSpawnAdapter.OnLandblockLoaded(
_loaded[landblock.LandblockId],
mergedRenderIds);
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
// LiveEntityRuntime owns activation for live objects. This static-only
@ -397,10 +441,13 @@ public sealed class GpuWorldState
}
_pendingByLandblock.Remove(canonical);
_pendingRenderIdsByLandblock.Remove(canonical);
_pendingNearTierLandblocks.Remove(canonical);
if (retainedLive.Count > 0)
_pendingByLandblock[canonical] = retainedLive;
_aabbs.Remove(canonical);
_tierByLandblock.Remove(canonical);
if (_loaded.Remove(canonical))
RebuildFlatView();
}
@ -612,6 +659,18 @@ public sealed class GpuWorldState
// protects against future callers that mirror live projection placement's
// cell-resolved-id pattern.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
// A promotion may have parked its Near layer before the Far base load
// exists. Demotion must retire that pending tier just as completely as
// an installed tier, while preserving live server projections.
_pendingNearTierLandblocks.Remove(canonical);
_pendingRenderIdsByLandblock.Remove(canonical);
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
{
pending.RemoveAll(entity => entity.ServerGuid == 0);
if (pending.Count == 0)
_pendingByLandblock.Remove(canonical);
}
if (!_loaded.TryGetValue(canonical, out var lb)) return;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
@ -642,12 +701,7 @@ public sealed class GpuWorldState
lb.LandblockId,
lb.Heightmap,
retainedLive);
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
{
pending.RemoveAll(entity => entity.ServerGuid == 0);
if (pending.Count == 0)
_pendingByLandblock.Remove(canonical);
}
_tierByLandblock[canonical] = LandblockStreamTier.Far;
RebuildFlatView();
}
@ -664,7 +718,10 @@ public sealed class GpuWorldState
/// callers may pass cell-resolved ids and they will key correctly.
/// </para>
/// </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.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
@ -677,14 +734,25 @@ public sealed class GpuWorldState
_pendingByLandblock[canonical] = bucket;
}
bucket.AddRange(entities);
return;
_pendingNearTierLandblocks.Add(canonical);
if (additionalRenderIds is not null)
{
if (!_pendingRenderIdsByLandblock.TryGetValue(canonical, out var renderIds))
{
renderIds = new HashSet<ulong>();
_pendingRenderIdsByLandblock[canonical] = renderIds;
}
renderIds.UnionWith(additionalRenderIds);
}
return false;
}
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
merged.AddRange(lb.Entities);
merged.AddRange(entities);
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
_tierByLandblock[canonical] = LandblockStreamTier.Near;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]);
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical], additionalRenderIds);
// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity.
// All entities arriving via this path are atlas-tier by construction
@ -697,6 +765,7 @@ public sealed class GpuWorldState
}
RebuildFlatView();
return true;
}
private void RebuildFlatView()

View file

@ -12,8 +12,13 @@ namespace AcDream.App.Streaming;
/// </summary>
public abstract record LandblockStreamJob(uint LandblockId)
{
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
public sealed record Load(
uint LandblockId,
LandblockStreamJobKind Kind,
ulong Generation = 0) : LandblockStreamJob(LandblockId);
public sealed record Unload(
uint LandblockId,
ulong Generation = 0) : LandblockStreamJob(LandblockId);
/// <summary>
/// Control job: drop every queued (not-yet-started) Load from the worker's
@ -32,7 +37,7 @@ public abstract record LandblockStreamJob(uint LandblockId)
/// an unload notification (tells the render thread to release GPU state
/// for this landblock id).
/// </summary>
public abstract record LandblockStreamResult(uint LandblockId)
public abstract record LandblockStreamResult(uint LandblockId, ulong Generation)
{
/// <summary>
/// A landblock load completed. <see cref="Tier"/> distinguishes Far
@ -43,15 +48,17 @@ public abstract record LandblockStreamResult(uint LandblockId)
uint LandblockId,
LandblockStreamTier Tier,
LandblockBuild Build,
LandblockMeshData MeshData
) : LandblockStreamResult(LandblockId)
LandblockMeshData MeshData,
ulong Generation = 0
) : LandblockStreamResult(LandblockId, Generation)
{
public Loaded(
uint landblockId,
LandblockStreamTier tier,
LoadedLandblock landblock,
LandblockMeshData meshData)
: this(landblockId, tier, new LandblockBuild(landblock), meshData)
LandblockMeshData meshData,
ulong generation = 0)
: this(landblockId, tier, new LandblockBuild(landblock), meshData, generation)
{
}
@ -69,14 +76,16 @@ public abstract record LandblockStreamResult(uint LandblockId)
public sealed record Promoted(
uint LandblockId,
LandblockBuild Build,
LandblockMeshData MeshData
) : LandblockStreamResult(LandblockId)
LandblockMeshData MeshData,
ulong Generation = 0
) : LandblockStreamResult(LandblockId, Generation)
{
public Promoted(
uint landblockId,
LoadedLandblock landblock,
LandblockMeshData meshData)
: this(landblockId, new LandblockBuild(landblock), meshData)
LandblockMeshData meshData,
ulong generation = 0)
: this(landblockId, new LandblockBuild(landblock), meshData, generation)
{
}
@ -84,8 +93,13 @@ public abstract record LandblockStreamResult(uint LandblockId)
public IReadOnlyList<WorldEntity> Entities => Landblock.Entities;
}
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);
public sealed record Failed(
uint LandblockId,
string Error,
ulong Generation = 0) : LandblockStreamResult(LandblockId, Generation);
public sealed record Unloaded(
uint LandblockId,
ulong Generation = 0) : LandblockStreamResult(LandblockId, Generation);
/// <summary>
/// The worker loop itself crashed with an unhandled exception. Not tied
@ -94,5 +108,5 @@ public abstract record LandblockStreamResult(uint LandblockId)
/// than retrying a single landblock later. LandblockId is 0 by
/// convention; readers should pattern-match on the type, not the id.
/// </summary>
public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0);
public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0, 0);
}

View file

@ -143,23 +143,26 @@ public sealed class LandblockStreamer : IDisposable
/// <see cref="LandblockStreamResult.Loaded"/> (or
/// <see cref="LandblockStreamResult.Failed"/>) to the outbox.
/// </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)
throw new ObjectDisposedException(nameof(LandblockStreamer));
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}");
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind));
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind, generation));
}
/// <summary>
/// Non-blocking enqueue. The worker posts a
/// <see cref="LandblockStreamResult.Unloaded"/> to the outbox.
/// </summary>
public void EnqueueUnload(uint landblockId)
public void EnqueueUnload(uint landblockId, ulong generation = 0)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId, generation));
}
/// <summary>
@ -335,7 +338,7 @@ public sealed class LandblockStreamer : IDisposable
if (build is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "LandblockLoader.Load returned null"));
load.LandblockId, "LandblockLoader.Load returned null", load.Generation));
break;
}
var lb = build.Landblock;
@ -345,18 +348,18 @@ public sealed class LandblockStreamer : IDisposable
if (promotedMesh is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "buildMeshOrNull returned null"));
load.LandblockId, "buildMeshOrNull returned null", load.Generation));
break;
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
load.LandblockId, build, promotedMesh));
load.LandblockId, build, promotedMesh, load.Generation));
break;
}
var mesh = _buildMeshOrNull(load.LandblockId, lb);
if (mesh is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "buildMeshOrNull returned null"));
load.LandblockId, "buildMeshOrNull returned null", load.Generation));
break;
}
var tier = load.Kind == LandblockStreamJobKind.LoadFar
@ -375,17 +378,19 @@ public sealed class LandblockStreamer : IDisposable
build = new LandblockBuild(lb);
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
load.LandblockId, tier, build, mesh));
load.LandblockId, tier, build, mesh, load.Generation));
}
catch (Exception ex)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, ex.ToString()));
load.LandblockId, ex.ToString(), load.Generation));
}
break;
case LandblockStreamJob.Unload unload:
_outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(unload.LandblockId));
_outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(
unload.LandblockId,
unload.Generation));
break;
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using AcDream.Core.World;
@ -17,11 +18,12 @@ namespace AcDream.App.Streaming;
/// </summary>
public sealed class StreamingController
{
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
private readonly Action<uint> _enqueueUnload;
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
private readonly Action<uint, ulong> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain;
private readonly Action<uint>? _demoteNearLayer;
private readonly Action? _clearPendingLoads;
/// <summary>
@ -34,8 +36,16 @@ public sealed class StreamingController
/// tests that don't exercise re-hydration.
/// </summary>
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 StreamingRegion? _region;
// Hard streaming boundaries advance this token. Worker completions carry
// the generation captured at enqueue time, so an old overlapping load or
// unload cannot mutate the replacement window after a portal recenter.
private ulong _generation;
// True while streaming is collapsed to the single dungeon landblock the
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
@ -127,6 +137,43 @@ public sealed class StreamingController
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
public int DeferredApplyBacklog => _deferredApply.Count;
/// <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 +
// re-uploads the WHOLE window) and the landblock count it dropped last time.
public int ForceReloadCount { get; private set; }
@ -135,8 +182,40 @@ public sealed class StreamingController
// Completions that were drained past a priority item get buffered here
// so they still apply over subsequent frames without loss.
private readonly List<LandblockStreamResult> _deferredApply = new();
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> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
@ -145,19 +224,44 @@ public sealed class StreamingController
int nearRadius,
int farRadius,
Action<uint>? removeTerrain = null,
Action<uint>? demoteNearLayer = 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;
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_clearPendingLoads = clearPendingLoads;
_onLandblockLoaded = onLandblockLoaded;
_state = state;
NearRadius = nearRadius;
FarRadius = farRadius;
}
private void EnqueueLoad(uint id, LandblockStreamJobKind kind) =>
_enqueueLoad(id, kind, _generation);
private void EnqueueUnload(uint id) => _enqueueUnload(id, _generation);
private void DemoteLandblock(uint id)
{
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
// App-owned Near resources need the still-present static entity list
// for exact light/translucency owner cleanup. Retire them before
// GpuWorldState drops the static layer and its render pins.
_demoteNearLayer?.Invoke(canonical);
_state.RemoveEntitiesFromLandblock(canonical);
}
private void AdvanceGeneration()
{
_generation = unchecked(_generation + 1);
}
/// <summary>
@ -263,18 +367,18 @@ public sealed class StreamingController
{
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
var bootstrap = _region.ComputeFirstTickDiff();
foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in bootstrap.ToLoadNear) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in bootstrap.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
_region.MarkResidentFromBootstrap();
}
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
{
var diff = _region.RecenterTo(observerCx, observerCy);
foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id);
foreach (var id in diff.ToPromote) EnqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
foreach (var id in diff.ToLoadNear) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in diff.ToLoadFar) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in diff.ToDemote) DemoteLandblock(id);
foreach (var id in diff.ToUnload) EnqueueUnload(id);
}
}
@ -293,10 +397,12 @@ public sealed class StreamingController
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
_collapsed = true;
_collapsedCenter = centerId;
AdvanceGeneration();
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
foreach (var id in _state.LoadedLandblockIds)
if (id != centerId) _enqueueUnload(id);
if (id != centerId) EnqueueUnload(id);
// Pin a radius-0 region so RecenterTo never re-expands while inside,
// and so the post-exit rebuild starts from a clean, consistent state.
@ -306,7 +412,9 @@ public sealed class StreamingController
// The dungeon landblock itself must be (or become) loaded. If a prior
// ClearPendingLoads cancelled its queued load, re-enqueue it.
if (!_state.IsLoaded(centerId))
_enqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
EnqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
else if (!_state.IsNearTier(centerId))
EnqueueLoad(centerId, LandblockStreamJobKind.PromoteToNear);
}
/// <summary>
@ -321,7 +429,7 @@ public sealed class StreamingController
// Always preserve the true dungeon landblock (_collapsedCenter), never the
// per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
foreach (var id in _state.LoadedLandblockIds)
if (id != _collapsedCenter) _enqueueUnload(id);
if (id != _collapsedCenter) EnqueueUnload(id);
}
/// <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}) " +
$"(was collapsed on 0x{_collapsedCenter:X8})");
_collapsed = false;
AdvanceGeneration();
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
foreach (var id in _state.LoadedLandblockIds)
if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id);
if (!rebuilt.Resident.Contains(id)) EnqueueUnload(id);
var boot = rebuilt.ComputeFirstTickDiff();
foreach (var id in boot.ToLoadNear)
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in boot.ToLoadFar)
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
rebuilt.MarkResidentFromBootstrap();
_region = rebuilt;
}
@ -383,6 +492,9 @@ public sealed class StreamingController
public void ForceReloadWindow()
{
_collapsed = false;
AdvanceGeneration();
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
var ids = new List<uint>(_state.LoadedLandblockIds);
ForceReloadCount++; // [FRAME-DIAG] churn counter
@ -424,6 +536,11 @@ public sealed class StreamingController
if (chunk.Count == 0) break;
foreach (var result in chunk)
{
// Reject obsolete hard-window work before it can occupy the
// metered deferred queue. ApplyResult repeats this invariant
// for direct callers and future drain paths.
if (IsStaleGeneration(result))
continue;
if (result is LandblockStreamResult.Unloaded
|| IsWithinPriorityRing(ResultLandblockId(result)))
ApplyResult(result); // free (unload) or behind-the-fade near ring
@ -449,19 +566,120 @@ public sealed class StreamingController
/// </summary>
private void ApplyResult(LandblockStreamResult result)
{
if (IsStaleGeneration(result))
{
// A worker can finish one old load/unload after a hard recenter.
// Landblock id membership cannot distinguish overlapping windows;
// generation is the logical streaming incarnation boundary.
return;
}
if (result is LandblockStreamResult.Unloaded
&& _region?.TryGetDesiredTier(result.LandblockId, out _) == true)
{
// Normal recenter does not advance the hard-window generation. A
// rapid away->back can therefore re-own an id while its same-
// generation unload is already in the worker outbox. Current
// region ownership wins; the old unload must not tear it down.
return;
}
if (result is LandblockStreamResult.Loaded
or LandblockStreamResult.Promoted)
{
if (_region is null
|| !_region.TryGetDesiredTier(result.LandblockId, out var desiredTier))
{
// A hard recenter/collapse can leave one worker job already in
// flight. Its completion belongs to the old region and must not
// resurrect a landblock the new StreamingRegion never owns.
return;
}
bool isNearCompletion = result is LandblockStreamResult.Promoted
or LandblockStreamResult.Loaded { Tier: LandblockStreamTier.Near };
if (isNearCompletion && _state.IsNearTier(result.LandblockId))
{
// Normal recenter can enqueue a second high-priority Near job
// while an earlier one is already in flight. The streamer
// supersedes Far/Unload work but intentionally does not dedupe
// high-priority jobs. Publication is idempotent at this owner:
// never append statics, replay defaults, or reapply the complete
// Near transaction to an already-Near landblock.
return;
}
if (desiredTier == LandblockStreamTier.Far && isNearCompletion)
{
// Recenter can demote a Near job before its first completion.
// StreamingRegion already considers that landblock Far, so it
// deliberately emits no second LoadFar job. If no terrain is
// resident yet, publish the completed heightmap/mesh as a Far
// result while discarding its entity and EnvCell layers. This
// is the same payload a fresh LoadFar would have produced and
// closes the in-flight Near -> desired Far lifecycle without a
// duplicate worker read or a permanent terrain hole.
if (!_state.IsLoaded(result.LandblockId))
{
switch (result)
{
case LandblockStreamResult.Loaded loaded:
ApplyAsFar(loaded.Build, loaded.MeshData);
break;
case LandblockStreamResult.Promoted promoted:
ApplyAsFar(promoted.Build, promoted.MeshData);
break;
}
}
return;
}
}
switch (result)
{
case LandblockStreamResult.Loaded loaded:
if (loaded.Tier == LandblockStreamTier.Far
&& _state.IsNearTier(loaded.LandblockId))
{
// This Far completion was queued before a newer Near load
// or promotion. Applying it would erase the entity/cell
// layer that now owns the landblock.
break;
}
_applyTerrain(loaded.Build, loaded.MeshData);
_state.AddLandblock(loaded.Landblock);
_state.AddLandblock(
loaded.Landblock,
loaded.Build.EnvCells?.Shells.Select(shell => shell.GeometryId),
loaded.Tier);
EnsureEnvCellMeshesAfterPin(loaded.Build);
// #138: after the landblock is in _loaded (so live projection
// hot-paths), restore any retained server objects ACE won't
// re-send. Fired AFTER AddLandblock, never before.
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
break;
case LandblockStreamResult.Promoted promoted:
// PromoteToNear carries a complete build and mesh because the
// streamer deliberately lets it supersede a queued LoadFar. If
// that Far job never started, publish this as the real Near
// landblock; if the base is already resident, merge only the
// Near layer so existing live projections retain identity.
_applyTerrain(promoted.Build, promoted.MeshData);
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
var promotedRenderIds =
promoted.Build.EnvCells?.Shells.Select(shell => shell.GeometryId);
if (_state.IsLoaded(promoted.LandblockId))
{
_state.AddEntitiesToExistingLandblock(
promoted.LandblockId,
promoted.Entities,
promotedRenderIds);
}
else
{
_state.AddLandblock(
promoted.Landblock,
promotedRenderIds,
LandblockStreamTier.Near);
}
EnsureEnvCellMeshesAfterPin(promoted.Build);
_onLandblockLoaded?.Invoke(promoted.LandblockId);
break;
case LandblockStreamResult.Unloaded unloaded:
@ -479,6 +697,31 @@ public sealed class StreamingController
}
}
private void ApplyAsFar(LandblockBuild completedBuild, LandblockMeshData meshData)
{
var completed = completedBuild.Landblock;
var farLandblock = new LoadedLandblock(
completed.LandblockId,
completed.Heightmap,
Array.Empty<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>
/// Returns the landblock id associated with <paramref name="result"/>.
/// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by

View file

@ -171,6 +171,25 @@ public sealed class StreamingRegion
internal static uint EncodeLandblockIdForTest(int lbX, int 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>
/// 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

View file

@ -566,9 +566,7 @@ public sealed class AnimationSequencer
if (_core.CurrAnim == null && rootMotionFrame is null)
return BuildIdentityFrame(partCount);
if (dt <= 0f)
return _core.CurrAnim == null
? BuildIdentityFrame(partCount)
: BuildBlendedFrame();
return SampleCurrentPose();
_core.Update(dt, rootMotionFrame);
@ -577,6 +575,19 @@ public sealed class AnimationSequencer
: BuildBlendedFrame();
}
/// <summary>
/// Samples the current sequence frame without advancing animation time or
/// dispatching hooks. This is retail's hidden-object
/// <c>CPhysicsObj::set_frame -&gt; CPartArray::SetFrame -&gt; 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>
/// Retrieve and clear the list of hooks that fired since the last call.
/// Empty when no frame boundary was crossed. Safe to call multiple

View file

@ -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
// frame-invariant under translation, so the same delta applies to the local origin.
// AdjustToOutside then recomputes the cell index from the local (intra-landblock 24 m
// cell crossings) AND wraps + bumps the landblock on a 192 m crossing — the outdoor
// membership + canonicalization in one call (get_outside_lcoord + lcoord_to_gid +
// [0,192) wrap). Idempotent within a cell. Runs only for a SEEDED OUTDOOR cell;
// unseeded bodies (ObjCellId==0, e.g. remote entities) and indoor cells are skipped.
// [0,192) wrap). Idempotent within a cell. A SEEDED indoor body carries the same
// landblock-relative delta but keeps its EnvCell identity until the transition result
// commits the exact destination via CommitTransitionPosition. Unseeded bodies are skipped.
private void SyncCellPositionDelta(Vector3 delta)
{
uint cell = CellPosition.ObjCellId;
if ((cell & 0xFFFFu) is not (>= 1u and <= 0x40u))
if (cell == 0)
return;
Vector3 local = CellPosition.Frame.Origin + delta;
if ((cell & 0xFFFFu) is not (>= 1u and <= 0x40u))
{
CellPosition = new Position(
cell,
new CellFrame(local, CellPosition.Frame.Orientation));
return;
}
uint adjusted = cell;
if (LandDefs.AdjustToOutside(ref adjusted, ref local))
CellPosition = new Position(adjusted, new CellFrame(local, CellPosition.Frame.Orientation));

View file

@ -121,6 +121,7 @@ public sealed class PhysicsEngine
public void RemoveLandblock(uint landblockId)
{
_landblocks.Remove(landblockId);
ShadowObjects.DeregisterStaticOwnersForLandblock(landblockId);
ShadowObjects.RemoveLandblock(landblockId);
DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply
@ -141,6 +142,41 @@ public sealed class PhysicsEngine
DataCache?.CellGraph.RemoveLandblock(landblockId);
}
/// <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>
/// Find the landblock that contains the given world-space XY position and
/// return its ID plus world-space origin offsets. Returns false when no

View file

@ -29,6 +29,10 @@ public sealed class ShadowObjectRegistry
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
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>
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by
@ -367,7 +371,7 @@ public sealed class ShadowObjectRegistry
public void RefloodLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toReflood = new List<uint>();
var toReflood = new HashSet<uint>();
foreach (var kvp in _entityReg)
{
@ -390,11 +394,17 @@ public sealed class ShadowObjectRegistry
}
}
}
if (_withdrawnPrefixesByOwner.TryGetValue(kvp.Key, out var prefixes)
&& prefixes.Contains(lbPrefix))
{
toReflood.Add(kvp.Key);
}
}
foreach (uint entityId in toReflood)
{
var reg = _entityReg[entityId];
_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawnBeforeReflood);
if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes))
{
RegisterMultiPart(entityId, reg.EntityWorldPos, reg.EntityWorldRot, shapes,
@ -408,6 +418,22 @@ public sealed class ShadowObjectRegistry
reg.CollisionType, reg.CylHeight, reg.Scale,
reg.State, reg.Flags, reg.SeedCellId, reg.IsStatic);
}
// Register is also the authoritative movement/replacement API and
// therefore clears obsolete markers. Only this streaming reflood
// operation preserves the still-missing prefixes across replacement.
if (withdrawnBeforeReflood is not null)
_withdrawnPrefixesByOwner[entityId] = withdrawnBeforeReflood;
if (_entityToCells.TryGetValue(entityId, out var refreshedCells)
&& refreshedCells.Exists(cell =>
(cell & 0xFFFF0000u) == lbPrefix)
&& _withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn))
{
withdrawn.Remove(lbPrefix);
if (withdrawn.Count == 0)
_withdrawnPrefixesByOwner.Remove(entityId);
}
}
}
@ -475,6 +501,29 @@ public sealed class ShadowObjectRegistry
_entityShapes.Remove(entityId);
_entityReg.Remove(entityId);
_suspendedEntities.Remove(entityId);
_withdrawnPrefixesByOwner.Remove(entityId);
}
/// <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>
@ -491,6 +540,18 @@ public sealed class ShadowObjectRegistry
uint lbPrefix = landblockId & 0xFFFF0000u;
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)
{
if ((kvp.Key & 0xFFFF0000u) == lbPrefix)
@ -520,6 +581,7 @@ public sealed class ShadowObjectRegistry
_entityShapes.Remove(eid);
_entityReg.Remove(eid);
_suspendedEntities.Remove(eid);
_withdrawnPrefixesByOwner.Remove(eid);
}
}
}
@ -549,6 +611,18 @@ public sealed class ShadowObjectRegistry
/// </summary>
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>
public int SuspendedRegistrationCount => _suspendedEntities.Count;

View file

@ -63,6 +63,17 @@ public sealed class CellGraph
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-&gt;cell resolver (retail CObjCell::GetVisible).</summary>
public ObjCell? GetVisible(uint id)
{

View file

@ -40,8 +40,8 @@ public static class LandblockLoader
// When landblockId is non-zero, namespace stab Ids globally:
// 0xC0XXYY00 + n, where XX = lbX byte, YY = lbY byte
// matching the scenery (0x80XXYY00) and interior (0x40XXYY00) patterns
// in GameWindow.cs. The 0xC0 top byte distinguishes stabs from those.
// distinct from the 0x8XXYYIII scenery and 0x4XXYYIII interior
// namespaces in GameWindow.cs. The 0xC0 top byte distinguishes stabs.
//
// Pre-Tier-1 callers (existing tests) pass landblockId=0 and get the
// legacy starting-from-1 monotonic Ids — compatible with their assertions

View 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++;
}
}

View file

@ -99,7 +99,8 @@ public sealed class TeleportAnimSequencer
/// <summary>
/// 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.
/// </summary>
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)

View file

@ -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);
}
}

View file

@ -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));
}
}

View file

@ -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);
}
}

View file

@ -1,3 +1,4 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.World;
using AcDream.Content.Vfx;
@ -57,7 +58,7 @@ public sealed class RecallTeleportAnimationTests
}
[Fact]
public void InstalledRecall_RetiresBeforeTeleportHiddenStateIsDispatched()
public void InstalledRecall_HiddenEnterWorldPublishesFinishedCyclicPoseWithoutAdvancingTime()
{
string datDir = @"C:\Turbine\Asheron's Call";
if (!File.Exists(Path.Combine(datDir, "client_portal.dat")))
@ -81,38 +82,29 @@ public sealed class RecallTeleportAnimationTests
aceDuration += (high - anim.LowFrame) / Math.Abs(anim.Framerate);
}
const float updateStep = 1f / 60f;
double elapsed = 0.0;
bool hidden = false;
bool hiddenPacketReady = false;
var frame = new RetailLiveFrameCoordinator(
dt =>
{
if (!hidden)
sequencer.Advance(dt);
},
() =>
{
if (hiddenPacketReady)
hidden = true;
},
() => { },
() => { });
// The recall motion is dispatched after an object's UseTime phase.
// On the first later frame whose timestamp reaches ACE's scheduled
// teleport boundary, retail advances the PartArray and only then
// consumes the Hidden SetState from the inbound queue.
while (elapsed < aceDuration)
{
hiddenPacketReady = elapsed + updateStep >= aceDuration;
frame.Tick(updateStep);
elapsed += updateStep;
}
Assert.True(hidden);
Assert.True(
// Advance returns a reusable scratch buffer; snapshot the authored tail
// before HandleEnterWorld/SampleCurrentPose overwrites that buffer.
PartTransform[] recallTail = sequencer.Advance((float)aceDuration).ToArray();
Assert.False(
sequencer.CurrentNodeDiag.IsLooping,
"Recall must reach the Ready cyclic tail before Hidden freezes PartArray playback.");
"At ACE's exact teleport boundary the authored recall link is still the current node.");
// set_hidden -> CPartArray::HandleEnterWorld ->
// MotionTableManager::HandleEnterWorld removes link animations and
// selects the first cyclic (Ready) node. Hidden then suppresses time
// advance, but set_frame/UpdateParts still samples this new pose.
sequencer.Manager.HandleEnterWorld();
IReadOnlyList<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);
}
}

View file

@ -66,15 +66,56 @@ public class PhysicsBodyCellSyncTests
Assert.Equal(0u, body.CellPosition.ObjCellId);
}
[Fact]
public void PositionDelta_InsideEnvCell_AdvancesCanonicalLocalFrame()
{
var body = new PhysicsBody();
body.SnapToCell(
0x8C0401ADu,
worldPos: new Vector3(12f, -30f, 4f),
cellLocal: new Vector3(80f, 40f, 4f));
body.Position += new Vector3(3f, -2f, 0.5f);
Assert.Equal(0x8C0401ADu, body.CellPosition.ObjCellId);
Assert.Equal(new Vector3(83f, 38f, 4.5f), body.CellPosition.Frame.Origin);
}
[Fact]
public void CommitTransitionPosition_InsideDungeon_AdoptsResolvedEnvCellAndFrame()
{
var body = new PhysicsBody();
body.SnapToCell(
0x8C0401ADu,
worldPos: new Vector3(12f, -30f, 4f),
cellLocal: new Vector3(80f, 40f, 4f));
body.CommitTransitionPosition(
0x8C0401AEu,
new Vector3(17f, -27f, 4f));
Assert.Equal(0x8C0401AEu, body.CellPosition.ObjCellId);
Assert.Equal(new Vector3(85f, 43f, 4f), body.CellPosition.Frame.Origin);
Assert.Equal(new Vector3(17f, -27f, 4f), body.Position);
}
[Fact]
public void CommitTransitionPosition_WithoutSeed_DoesNotInventCanonicalCell()
{
var body = new PhysicsBody();
body.CommitTransitionPosition(0x8C0401ADu, new Vector3(1f, 2f, 3f));
Assert.Equal(0u, body.CellPosition.ObjCellId);
Assert.Equal(new Vector3(1f, 2f, 3f), body.Position);
}
[Fact]
public void ContinuousTracking_LongWalkAcrossCellsAndBlock_NeverGoesStale()
{
// Refutes the "CellPosition goes stale" concern (incl. the indoor-transit case):
// because the Position setter re-derives the cell via AdjustToOutside on EVERY
// write, CellPosition continuously tracks the outdoor cell under the body's WORLD
// position. The body is always world-space, so the world delta is frame-invariant —
// there is no separate "interior" frame to desync. On any exit it is the correct
// cell, never stale.
// Refutes outdoor CellPosition drift: the Position setter re-derives the
// cell via AdjustToOutside on every write, so the carried (cell, local)
// pair continuously tracks the body across cells and landblocks.
var body = new PhysicsBody();
body.SnapToCell(0xC95B0001u, new Vector3(1000f, 2000f, 12f), new Vector3(10f, 10f, 12f));

View file

@ -1,6 +1,7 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World.Cells;
using Xunit;
namespace AcDream.Core.Tests.Physics;
@ -31,4 +32,123 @@ public class PhysicsEngineResidencyTests
Assert.True(eng.IsLandblockTerrainResident(0xA9B40019u)); // cell-resolved id, same LB
Assert.False(eng.IsLandblockTerrainResident(0xC6A90019u)); // different LB
}
[Fact]
public void DemoteLandblockToTerrain_RemovesEnvCellsButPreservesTerrainResidency()
{
const uint landblockId = 0xA9B4FFFFu;
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
engine.AddLandblock(
landblockId,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<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);
}
}

View file

@ -207,6 +207,132 @@ public class ShadowObjectRegistryTests
entry => entry.EntityId == entityId);
}
[Fact]
public void RefloodLandblock_RestoresAdjacentOwnedFootprintWithdrawnByUnload()
{
const uint adjacentOwnerLb = 0xAAB40000u;
const uint ownerCell = adjacentOwnerLb | 1u;
const uint touchedCell = LbId | 57u; // west block cell (7,0)
const uint entityId = 32u;
var reg = new ShadowObjectRegistry();
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(
adjacentOwnerLb,
new TerrainSurface(new byte[81], new float[256]),
new Vector3(192f, 0f, 0f));
reg.DataCache = cache;
reg.Register(
entityId,
0x01000006u,
new Vector3(192.5f, 12f, 50f),
Quaternion.Identity,
2f,
192f,
0f,
adjacentOwnerLb,
seedCellId: ownerCell,
isStatic: false);
Assert.Contains(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId);
Assert.Contains(reg.GetObjectsInCell(ownerCell), e => e.EntityId == entityId);
reg.RemoveLandblock(LbId);
Assert.DoesNotContain(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId);
Assert.Contains(reg.GetObjectsInCell(ownerCell), e => e.EntityId == entityId);
Assert.Equal(1, reg.WithdrawnPrefixMarkerCount);
reg.RefloodLandblock(LbId);
Assert.Contains(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId);
Assert.Contains(reg.GetObjectsInCell(ownerCell), e => e.EntityId == entityId);
Assert.Equal(0, reg.WithdrawnPrefixMarkerCount);
}
[Fact]
public void AuthoritativeMove_ClearsObsoleteWithdrawnPrefix()
{
const uint adjacentOwnerLb = 0xAAB40000u;
const uint ownerCell = adjacentOwnerLb | 1u;
const uint touchedCell = LbId | 57u;
const uint entityId = 33u;
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(
adjacentOwnerLb,
new TerrainSurface(new byte[81], new float[256]),
new Vector3(192f, 0f, 0f));
var reg = new ShadowObjectRegistry { DataCache = cache };
reg.Register(
entityId, 0x01000007u, new Vector3(192.5f, 12f, 50f),
Quaternion.Identity, 2f, 192f, 0f, adjacentOwnerLb,
seedCellId: ownerCell, isStatic: false);
reg.RemoveLandblock(LbId);
Assert.Equal(1, reg.WithdrawnPrefixMarkerCount);
reg.UpdatePosition(
entityId,
new Vector3(204f, 12f, 50f),
Quaternion.Identity,
192f,
0f,
adjacentOwnerLb,
seedCellId: ownerCell);
Assert.Equal(0, reg.WithdrawnPrefixMarkerCount);
reg.RefloodLandblock(LbId);
Assert.DoesNotContain(reg.GetObjectsInCell(touchedCell), e => e.EntityId == entityId);
}
[Fact]
public void RefloodLandblock_RetiresOnlyCurrentMarker_WhenTwoPrefixesWereWithdrawn()
{
const uint ownerLb = 0xAAB50000u;
const uint ownerCell = ownerLb | 1u;
const uint westLb = 0xA9B50000u;
const uint southLb = 0xAAB40000u;
const uint westCell = westLb | 57u;
const uint southCell = southLb | 8u;
const uint entityId = 35u;
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(
ownerLb,
new TerrainSurface(new byte[81], new float[256]),
new Vector3(192f, 192f, 0f));
var reg = new ShadowObjectRegistry { DataCache = cache };
reg.Register(
entityId, 0x01000009u, new Vector3(192.5f, 192.5f, 50f),
Quaternion.Identity, 2f, 192f, 192f, ownerLb,
seedCellId: ownerCell, isStatic: false);
Assert.Contains(reg.GetObjectsInCell(westCell), e => e.EntityId == entityId);
Assert.Contains(reg.GetObjectsInCell(southCell), e => e.EntityId == entityId);
reg.RemoveLandblock(westLb);
reg.RemoveLandblock(southLb);
Assert.Equal(2, reg.WithdrawnPrefixMarkerCount);
reg.RefloodLandblock(westLb);
Assert.Equal(1, reg.WithdrawnPrefixMarkerCount);
reg.RefloodLandblock(southLb);
Assert.Equal(0, reg.WithdrawnPrefixMarkerCount);
Assert.Contains(reg.GetObjectsInCell(westCell), e => e.EntityId == entityId);
Assert.Contains(reg.GetObjectsInCell(southCell), e => e.EntityId == entityId);
}
[Fact]
public void RemoveLandblock_DirectStaticRetirement_ClearsWithdrawnMarker()
{
const uint entityId = 34u;
var reg = new ShadowObjectRegistry();
reg.Register(
entityId, 0x01000008u, new Vector3(12f, 12f, 50f),
Quaternion.Identity, 1f, OffX, OffY, LbId,
isStatic: true);
reg.RemoveLandblock(LbId);
Assert.Equal(0, reg.RetainedRegistrationCount);
Assert.Equal(0, reg.WithdrawnPrefixMarkerCount);
}
// -----------------------------------------------------------------------
// Per-cell query surface (BR-7 / A6.P4 2026-06-11): GetObjectsInCell IS
// the query — retail CObjCell::find_obj_collisions iterates only the

View file

@ -29,7 +29,7 @@ public class LandblockStreamerTests
buildMeshOrNull: (_, _) => stubMesh);
streamer.Start();
streamer.EnqueueLoad(0xA9B4FFFEu);
streamer.EnqueueLoad(0xA9B4FFFEu, generation: 42);
// Spin until the worker produces a completion, with a 2s timeout.
LandblockStreamResult? result = null;
@ -43,6 +43,7 @@ public class LandblockStreamerTests
Assert.NotNull(result);
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(result);
Assert.Equal(0xA9B4FFFEu, loaded.LandblockId);
Assert.Equal(42ul, loaded.Generation);
Assert.Same(stubLandblock, loaded.Landblock);
}
@ -247,7 +248,7 @@ public class LandblockStreamerTests
using var streamer = new LandblockStreamer(loadLandblock: _ => null);
streamer.Start();
streamer.EnqueueUnload(0xABCD0000u);
streamer.EnqueueUnload(0xABCD0000u, generation: 43);
LandblockStreamResult? result = null;
for (int i = 0; i < SpinMaxIterations && result is null; i++)
@ -259,6 +260,7 @@ public class LandblockStreamerTests
var unloaded = Assert.IsType<LandblockStreamResult.Unloaded>(result);
Assert.Equal(0xABCD0000u, unloaded.LandblockId);
Assert.Equal(43ul, unloaded.Generation);
}
[Fact]

View file

@ -163,6 +163,22 @@ public class StreamingControllerDungeonGateTests
Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
}
[Fact]
public void PreCollapse_CenterResidentOnlyAsFarTier_EnqueuesPromotion()
{
var h = Make();
uint center = Encode(0, 7);
h.State.AddLandblock(MakeLb(0, 7), tier: LandblockStreamTier.Far);
h.Ctrl.PreCollapseToDungeon(0, 7);
Assert.False(h.State.IsNearTier(center));
Assert.Contains(h.Loads, load =>
load.Id == center && load.Kind == LandblockStreamJobKind.PromoteToNear);
Assert.DoesNotContain(h.Loads, load =>
load.Id == center && load.Kind == LandblockStreamJobKind.LoadNear);
}
[Fact]
public void InitializeKnownLoginCenter_DungeonPerformsOneCollapseWithoutReloadChurn()
{

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.Streaming;
using AcDream.Core.Terrain;
using AcDream.Core.World;
@ -10,10 +11,11 @@ namespace AcDream.Core.Tests.Streaming;
public class StreamingControllerPriorityApplyTests
{
private static LandblockStreamResult.Loaded LoadedOf(uint canonicalId)
private static LandblockStreamResult.Loaded LoadedOf(uint canonicalId, ulong generation = 0)
=> new(canonicalId, LandblockStreamTier.Near,
new LoadedLandblock(canonicalId, new LandBlock(), Array.Empty<WorldEntity>()),
new LandblockMeshData(Array.Empty<TerrainVertex>(), Array.Empty<uint>()));
new LandblockMeshData(Array.Empty<TerrainVertex>(), Array.Empty<uint>()),
generation);
[Fact]
public void PriorityLandblock_isApplied_evenWhenBeyondPerFrameCap()
@ -21,10 +23,10 @@ public class StreamingControllerPriorityApplyTests
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
{
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 181)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 182)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 183)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 184)),
LoadedOf(priority),
});
@ -58,11 +60,11 @@ public class StreamingControllerPriorityApplyTests
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
{
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 4)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 181)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 182)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 183)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 184)),
LoadedOf(StreamingRegion.EncodeLandblockIdForTest(169, 185)),
LoadedOf(priority),
});
var applied = new List<uint>();
@ -101,9 +103,9 @@ public class StreamingControllerPriorityApplyTests
// as the normal throttle, just relocated — until the caller clears
// PriorityLandblockId (the TAS MaxContinue safety net does this on timeout).
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
uint otherId0 = StreamingRegion.EncodeLandblockIdForTest(1, 0);
uint otherId1 = StreamingRegion.EncodeLandblockIdForTest(1, 1);
uint otherId2 = StreamingRegion.EncodeLandblockIdForTest(1, 2);
uint otherId0 = StreamingRegion.EncodeLandblockIdForTest(169, 181);
uint otherId1 = StreamingRegion.EncodeLandblockIdForTest(169, 182);
uint otherId2 = StreamingRegion.EncodeLandblockIdForTest(169, 183);
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
{
@ -146,4 +148,417 @@ public class StreamingControllerPriorityApplyTests
// (c) no double-apply: applied count matches completions enqueued
Assert.Equal(3, applied.Count);
}
[Fact]
public void ForceReloadWindow_DiscardsBufferedCompletionsFromOldWindow()
{
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
var outbox = new Queue<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));
}
}

View file

@ -95,7 +95,7 @@ public class StreamingControllerTests
}
[Fact]
public void DrainingUnloadedResult_RemovesFromState()
public void DrainingUnloadedResult_RemovesUnownedLandblockFromState()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
@ -103,7 +103,10 @@ public class StreamingControllerTests
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
(_, _) => { }, state, nearRadius: 2, farRadius: 2);
const uint landblockId = 0x3232FFFFu;
// The current region is centered on 0x3232. A completed unload for a
// landblock it no longer owns must remove state; an unload for 0x3232
// itself is now correctly rejected by the away->back lifecycle gate.
const uint landblockId = 0x2020FFFFu;
var lb = new LoadedLandblock(landblockId, new LandBlock(), System.Array.Empty<WorldEntity>());
state.AddLandblock(lb);
fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(landblockId));

View file

@ -96,7 +96,7 @@ public class StreamingControllerTwoTierTests
// AddEntitiesToExistingLandblock canonicalizes incoming ids.
uint lbId = 0x3232FFFFu;
var lb = new LoadedLandblock(lbId, Heightmap: null!, Entities: System.Array.Empty<WorldEntity>());
state.AddLandblock(lb);
state.AddLandblock(lb, tier: LandblockStreamTier.Far);
Assert.Empty(state.Entities);
// Streamer pushes a Promoted result carrying the full near landblock.

View file

@ -56,6 +56,19 @@ public class CellGraphTests
Assert.Null(g.GetVisible(0xA9B40014u));
}
[Fact]
public void RemoveEnvCellsForLandblock_PreservesTerrain()
{
var g = new CellGraph();
g.Add(Env(0xA9B40174u));
g.RegisterTerrain(0xA9B40000u, FlatTerrain(), Vector3.Zero);
g.RemoveEnvCellsForLandblock(0xA9B4FFFFu);
Assert.Null(g.GetVisible(0xA9B40174u));
Assert.IsType<LandCell>(g.GetVisible(0xA9B40014u));
}
[Fact]
public void Neighbor_ResolvesPortalOtherCellId()
{

View file

@ -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);
}
}