fix(streaming): commit EnvCell landblocks atomically (#214)

Carry one complete cell payload with each streaming completion and publish visibility, physics, and render state on the render thread. Remove the obsolete post-readiness login reload that triggered a duplicate dungeon build.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-13 18:45:24 +02:00
parent 85980fe13f
commit b0c175afc0
26 changed files with 973 additions and 617 deletions

View file

@ -46,6 +46,49 @@ Copy this block when adding a new issue:
---
## #214 — Dungeon login could replace a complete EnvCell render with a partial second load
**Status:** IN-PROGRESS — architectural fix complete 2026-07-13, pending user visual gate
**Severity:** HIGH
**Component:** streaming / EnvCell rendering / portal visibility
**Description:** Logging directly into the starter dungeon could leave its room
shells untextured or absent after a relaunch even though the same dungeon had
rendered correctly before logout.
**Root cause:** Two independent load jobs published through shared, process-wide
pending collections. The login spawn first pre-collapsed streaming onto
`0x8C04FFFF`, then an obsolete post-spawn `ForceReloadWindow` reset the collapse
and enqueued the same dungeon again. `EnvCellRenderer.RegisterCell` mutated a
live landblock's pending lists from the worker while `FinalizeLandblock` drained
and replaced them on the render thread; the global portal-visibility bag had the
same ownership flaw across landblocks. The captured failure committed 1,123
shells, then the duplicate completion replaced them with its remaining 11.
**Resolution:** A worker now builds one private, immutable
`EnvCellLandblockBuild` per `LandblockBuild`. That exact payload travels with its
streaming completion and is committed on the render thread to
`CellVisibility`, `EnvCellRenderer`, and the cell physics cache. There are no
shared pending cell/shell collections to drain or cross-contaminate. The login
path now calls `InitializeKnownLoginCenter`: sealed dungeons pre-collapse once;
outdoor spawns wait for the first correctly centered tick. The old forced reload
was removed because `StreamingReadinessGate` already prevents guessed-center
loads before the real player spawn.
**Files:** `src/AcDream.App/Streaming/LandblockBuild.cs`;
`src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs`;
`src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs`;
`src/AcDream.App/Rendering/CellVisibility.cs`;
`src/AcDream.App/Rendering/GameWindow.cs`.
**Research:** `docs/research/2026-07-13-envcell-landblock-transaction.md`.
**Acceptance:** Log out inside the starter dungeon, relaunch, and enter the same
character. The dungeon is fully textured on its first frame, remains complete,
and the log contains exactly one login dungeon collapse with no second reload.
---
## #213 — Retail client commands were sent to ACE as chat text
**Status:** IN-PROGRESS — command-family gate passed except two corrective fixes, pending re-gate

View file

@ -394,6 +394,24 @@ bypass; no Scissor over-include). This is a **consolidation of existing machiner
(`PortalVisibilityBuilder` + `ClipFrame`), not a rewrite. Do NOT add a fourth special-case
gate to mask a seam — that anti-pattern produced the patchwork.
### Streaming publication ownership
The streaming worker may read dats and build CPU payloads, but it must not mutate
live render-frame registries. A near-tier job produces one `LandblockBuild` whose
optional `EnvCellLandblockBuild` owns the complete portal-cell and drawable-shell
sets for that landblock. The exact build object crosses the `LandblockStreamer`
completion channel. On the render thread, `StreamingController` applies that one
completion to `CellVisibility`, cell physics state, `EnvCellRenderer`, and finally
`GpuWorldState` during the same update-frame drain.
This is the publication boundary: per-job builders are private and single-use;
completed payloads are immutable snapshots; live landblock stores are replaced as
complete units. Process-wide pending bags or renderer-owned pending lists are not
valid streaming seams because a duplicate load or a second landblock can drain or
replace state produced by another job. CPU mesh extraction may still be scheduled
onto `ObjectMeshManager`'s thread-safe work queue, but the worker never publishes
partially hydrated cell membership or shell placement to the renderer.
## Roadmap Model
The old R1-R8 architecture sequence was a useful early refactor sketch, but it

View file

@ -183,7 +183,7 @@ src/AcDream.App/
│ ├── TextureCache.cs # (already exists)
│ ├── ParticleRenderer.cs # (already exists)
│ ├── Sky/ # (already exists)
│ ├── Wb/ # (already exists — WB seam)
│ ├── Wb/ # WB seam + EnvCellLandblockBuild transaction
│ └── Vfx/ # (already exists)
├── Net/
│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect
@ -191,7 +191,7 @@ src/AcDream.App/
│ └── LiveEntityRuntime.cs # owns per-entity state dicts + ServerGuid↔entity.Id translation
├── Interaction/
│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
├── Streaming/ # (already exists)
├── Streaming/ # LandblockStreamer + immutable LandblockBuild completion
├── Input/ # (already exists)
├── Audio/ # (already exists)
└── Plugins/ # (already exists)

View file

@ -26,6 +26,15 @@ our tree (see CLAUDE.md for the full breakdown):
`ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`, texture cache,
shader infra, EnvCell/portal/scenery/terrain-blending pipeline classes.
**EnvCell streaming seam:** `EnvCellLandblockBuildBuilder` is an acdream-owned
adapter around the extracted WB rendering path. One streaming job privately builds
the complete portal-cell + shell-placement payload, and `EnvCellRenderer.CommitLandblock`
publishes that completed snapshot on the render thread. WB's geometry-id arithmetic
and mesh preparation remain unchanged; the transaction wrapper exists because
acdream streams asynchronously while the WB editor's manager owned its own loading
loop. Do not reintroduce worker-side `RegisterCell` calls or shared pending cell
collections.
`DatCollectionAdapter` bridges our `IDatCollection` to the `IDatReaderWriter`
interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26
internal `_dats.*` call sites — above the 20-site inline-swap threshold).

View file

@ -0,0 +1,106 @@
# EnvCell landblock transaction — dungeon-login texture loss
## Symptom and captured evidence
Logging the `lundberg` account directly into starter dungeon landblock
`0x8C04FFFF` produced an untextured/incomplete world after relaunch. The capture
in `logs/lundberg-dungeon-relaunch-20260713-182018.out.log` established the
ordering:
```text
streaming: dungeon collapse -> 0x8C04FFFF
streaming: dungeon collapse -> 0x8C04FFFF
[late-register] ... 1123 already committed
[finalize-replace] ... DISCARDING 1123 committed instances, replacing with 11 pending
```
The old renderer diagnostics came from commit `0a38d934`. They proved that the
first job completed a full dungeon and a second job then overwrote it with the
small tail of a concurrently mutated pending list. This was not a texture decode
or GPU upload failure: the complete cell-shell set was discarded before draw.
## Root causes
There were two causes, both required for the observed deterministic failure:
1. Commit `9b06a9b8` added a post-login `ForceReloadWindow` to recover from a
guessed-center streaming window. Commit `fa9aedca` later added
`StreamingReadinessGate`, which prevents that guessed window from being built
at all, but the force reload remained. A sealed-dungeon login therefore
pre-collapsed once during spawn and collapsed a second time after the forced
reset.
2. The streaming worker published partial state while it was still building.
`EnvCellRenderer.RegisterCell` appended to landblock-owned pending lists and a
global `_pendingCells` bag collected portal cells. The render thread drained
those mutable collections independently. Neither collection identified the
streaming job that produced an item, so duplicate and concurrent landblock
completions could consume or replace each other's state.
The second cause is the architectural defect. Removing only the duplicate load
would make the current reproduction rarer, but leave the race available to
normal unload/reload and promotion timing.
## Retail/WB boundary
No retail gameplay algorithm changes here. The cell portal construction remains
the existing mechanical port of retail `PView::InitCell @ 0x005A4B70`, including
the dat `PortalSide` bit and reciprocal portal indexing. Geometry-id arithmetic
remains the verbatim WorldBuilder `EnvCellRenderManager.cs:94-103` port.
This change is an acdream streaming ownership correction: retail does not expose
partially hydrated cell arrays to its draw traversal, while acdream must bridge a
background CPU loader to a single OpenGL/render thread.
## Transaction design
Worker-side pseudocode:
```text
builder = new EnvCellLandblockBuildBuilder(landblockId)
for each EnvCell in this one landblock:
read dat cell + CellStruct
append visibility cell to builder-local storage
append drawable shell placement to builder-local storage
build = builder.Build() // one-way publication; immutable snapshot
schedule CPU mesh preparation(build) // queue only; no live renderer mutation
return LandblockBuild(terrain/entities, build)
```
Render-thread pseudocode:
```text
completion = streamer.Drain()
validate completion.EnvCells belongs to completion.Landblock
replace this landblock's complete CellVisibility set
cache this landblock's cell physics structs
replace this landblock's complete EnvCellRenderer snapshot
publish the LoadedLandblock to GpuWorldState
```
The invariant is: **one streaming completion owns one complete EnvCell payload;
only the render thread may publish it to live per-frame state.** A later reload
may replace an earlier snapshot, but it can only replace it with another complete
snapshot from that exact job—never a shared-list remainder.
## Login sequencing correction
`StreamingController.InitializeKnownLoginCenter` now encodes the post-readiness-
gate behavior:
- sealed dungeon: pre-collapse exactly once before the first normal tick;
- outdoor: enqueue nothing until the first tick at the server-confirmed center.
The obsolete `_pendingForceReloadWindow` path was deleted. This removes the
duplicate dungeon job without adding a timing guard, retry, or suppression flag.
## Regression coverage
- A 1,123-shell build produces a 1,123-instance committed snapshot.
- Independently built 1,123- and 11-shell transactions cannot share storage.
- A builder can publish only once.
- Visibility replacement affects only the matching landblock and rejects a
foreign cell before mutating existing state.
- `LandblockStreamer` carries the exact `LandblockBuild`/EnvCell transaction
instance from worker to consumer.
- Repeated sealed-dungeon login initialization enqueues one collapse/load; an
outdoor login waits for its first correctly centered tick.