feat(render): Phase A8 — indoor visibility + streaming fixes batch

Lands the working A8 indoor-rendering and streaming fixes accumulated this
session. User has verified these visually to some degree (e.g. lifestone /
translucent meshes confirmed fine under the FrontFace flip; bridge / wall /
collision regressions confirmed fixed after travel); not every path has been
exhaustively gated. The cellar-flap defect remains OPEN and will be solved
the retail-faithful way via a dedicated brainstorm (see handoff docs).

Rendering core (reviewed, high confidence):
- EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of
  the 80B CPU InstanceData struct the shader never expected — fixes the
  transform/texture "explosion" for any draw with >1 instance (cells that
  dedupe to a shared cellGeomId). Real root cause.
- WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI
  layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into
  same-cull runs with absolute uDrawIDOffset per run).
- EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
  WorldEntity.BuildingShellAnchorCellId so building shells scope to their
  dat-derived building cell instead of rendering everywhere.
- RenderOutsideInAcdream (look into buildings from outside) +
  CollectVisiblePortalBuildings frustum cull of portal bounds.
- Sky-when-inside-building + per-cell audit probe + GL-state probe.

Streaming / perf (test-covered; not independently code-reviewed this session):
- Near/far priority queues so near work wins over far; PromoteToNear carries
  full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids
  rebuilding the animated-lookup dict in the hot draw path. Fixes the
  bridge-not-appearing / missing-walls / broken-collision-after-travel
  regressions and improves post-transition FPS.

Tooling + docs:
- tools/A8CellAudit: offline dat cell/portal/building dumper (portals +
  buildings modes) — reproduces the cellar-flap investigation with no launch.
- docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil
  double-duty finding + the WB-recursive design decision + brainstorm prompt),
  entity-taxonomy, replan, issue-78 visibility investigation.

Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert
provisional pos.w clamp, and the probe families are kept (env-var gated, zero
cost when off) because the pending option-2 cellar-flap brainstorm needs them.
Strip in the option-2 ship commit.

Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8
visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-29 10:14:50 +02:00
parent e415bb3863
commit 5dc4140c11
38 changed files with 3965 additions and 277 deletions

View file

@ -1192,7 +1192,7 @@ or +small fix if different. Not blocking M1.
## #71 — WorldPicker Stage B — polygon refine for retail-accurate clicks
**Status:** OPEN
**Severity:** LOW (Stage A — screen-rect picker — is sufficient for M1)
**Severity:** MEDIUM (Stage A now causes real play mis-picks through open doors/windows)
**Filed:** 2026-05-16
**Component:** selection / picker
@ -1212,6 +1212,15 @@ to the visible mesh — under-pick what looks like empty space inside
the rect, catch visible mesh that pokes past the sphere boundary
(creature outstretched arm, sign edge).
**New evidence (2026-05-28 / Phase A8 visual gate):** User stood outside
a Holtburg building, saw a vendor through an open doorway/window, clicked
the visible vendor, and acdream selected the door instead:
`[B.4b] pick guid=0x7A9B4015 name=Door`. This is exactly the Stage A
failure mode: the open door's projected `Setup.SelectionSphere` rect is
closer than the vendor's rect, even though the visible door polygon is not
under the cursor. The fix is polygon refinement against visible GfxObj
triangles plus current animated part transforms; do not special-case doors.
**Acceptance:** Pipe per-part GfxObj visual polygons through a
`PickPolygonProvider` interface (don't duplicate mesh decoding —
hook the existing `ObjectMeshManager` cached data). Two-tier in
@ -1222,8 +1231,8 @@ frame edges.
**Estimated scope:** Medium (~4-6 hours). Defer until visual
verification surfaces a Stage A miss in real play. The user
confirmed 2026-05-16 that "I can click on longer ranges now so
good" — Stage A is enough for M1's "click an NPC" demo.
confirmed 2026-05-28 that the door/vendor case is now observable in real
play, so this should be scheduled soon after A8 rather than left as polish.
---

View file

@ -0,0 +1,206 @@
# Issue #78 + cellar-stairs visibility culling — investigation report
**Date:** 2026-05-25 PM (continuation session)
**Status:** REPORT-ONLY. Awaiting user (a) camera-rotation falsification test and (b) approach selection before any code work.
**Predecessor handoff:** [`docs/research/2026-05-25-issue-100-shipped-and-culling-handoff.md`](2026-05-25-issue-100-shipped-and-culling-handoff.md)
---
## Symptom
Two visible defects share one root cause:
1. **Cellar-stairs (observed 2026-05-25 PM, evidence for #78):** standing in a Holtburg cottage cellar with the camera at certain angles, the outdoor terrain mesh renders as a sharp-edged grass rectangle covering the cellar stair geometry. **Clears when camera moves closer** (cottage walls + stair treads geometrically occlude). Gameplay unaffected — player can walk up/down normally.
2. **Inn-wall stabs (#78, filed 2026-05-19):** standing inside the Holtburg Inn looking at the floor or walls, the user sees other buildings in the distance at their correct world position + scale, visible THROUGH the floor and walls.
The user has NOT yet run the camera-rotation falsification test (Phase 1a of the handoff). Until they do, the diagnosis below is "high confidence" but not certain.
Sibling: **#95** (dungeon portal-graph blowup) is the same visibility subsystem but a different specific failure (over-inclusion). scen5 log shows `visibleCells` per cell reaching **295** (worse than the 135-145 filed).
---
## Hypotheses (ranked)
### H1 — Indoor-camera gate missing on outdoor render passes (HIGH confidence)
**Mechanism:** `TerrainModernRenderer.Draw` and `WbDrawDispatcher` render outdoor geometry unconditionally regardless of whether the camera is inside an EnvCell. Retail and WorldBuilder both gate the outdoor passes by the indoor portal-walk result. acdream does neither.
**Evidence FOR (strong):**
- Retail anchor verified: `PView::DrawCells` at `acclient_2013_pseudo_c.txt:432709` gates `LScape::draw` (outdoor terrain dispatch) by `if (outside_view.view_count > 0)`. `outside_view.view_count` is only incremented during the indoor portal BFS (`PView::ConstructView`) when a portal targets `other_cell_id == 0xFFFFFFFF` (outdoor sentinel). When no portal sees outside, the entire outdoor pass is skipped.
- Retail's per-mesh draw (`RenderDeviceD3D::DrawMesh` line 429245) iterates `Render::PortalList->view_count` and skips meshes that straddle 0 sub-views. **No stencil** — retail uses screen-space polygon clipping via `PView::GetClip`.
- WB anchor verified: `VisibilityManager.RenderInsideOut` (lines 73-239) uses **stencil**: mark current-building portals stencil=1, punch portal regions to far depth, draw EnvCells unconditionally, then `terrain/scenery/statics` gated by `glStencilFunc(Equal, 1, 0x01)`. The top-level loop already skips the unconditional terrain draw via `if (!isInside) terrainManager.Render(...)` at GameScene.cs:965.
- acdream audit verified the gate is missing: `WbDrawDispatcher.cs:360-362` gates by `entity.ParentCellId.HasValue && !visibleCellIds.Contains(...)`. When `ParentCellId == null` (outdoor stabs, scenery, live-spawned entities), the boolean short-circuits to `cellInVis = true` — the entity passes regardless of `visibleCellIds`.
- `TerrainModernRenderer.Draw` (lines 191-208) only does per-slot frustum cull. No `visibleCellIds` parameter, no indoor-camera awareness.
- Patch geometry size (~24 m × 24 m rectangle) matches a terrain cell footprint — that's a polygon, not a precision artifact.
- "Clears when closer" matches geometric occlusion: cottage walls + stair treads come to occlude the offending terrain cells screen-space as the camera approaches. A 1 cm depth-buffer Z-fight (#100's nudge) at 2-5 m camera distance with 24-bit depth has sub-millimeter resolving power; precision is not the bottleneck.
**Evidence AGAINST:**
- User has not yet run the camera-rotation test. If the patch flickers/shimmers when rotating the camera in place, the diagnosis pivots to Z-precision.
**How to falsify:** Stand at the spot showing the cellar-stairs artifact, look at the grass patch, rotate the camera slowly without moving the character. Polygon-stable edges that track predictably with the view = culling (H1). Flickering / shimmering = Z-precision (H2).
### H2 — Residual Z-fight from #100's nudge (LOW confidence)
The 1 cm shader nudge from issue #100 might be insufficient at certain Z values or with shader precision quirks.
**Evidence FOR:** Same code area was just touched.
**Evidence AGAINST:** Predecessor research already established 1 cm @ 24-bit depth has sub-mm resolving at gameplay camera distances. Patch is rectangular polygon, not thin Z-fight strip. "Clears when closer" reverses precision direction.
**How to falsify:** Same camera-rotation test.
### H3 — #95 portal-traversal blowup is independent of H1 (HIGH confidence it IS independent)
**Mechanism:** `CellVisibility.GetVisibleCells` BFS over portals lacks termination/cap-depth logic. Network hubs expose 100+ outbound portals to disconnected dungeons, all marked visible. scen5 log shows up to 295 cells in one visible set.
**Evidence FOR independence:**
- H1 is an **asymmetric over-render** (outdoor passes ignore indoor state).
- H3 is a **symmetric over-inclusion** (BFS doesn't terminate properly).
- A fix to H1 would gate WHEN to render outdoor; H3's fix is to bound WHICH indoor cells the BFS includes.
- Different code paths: H1 lives in `TerrainModernRenderer.Draw` + `WbDrawDispatcher`; H3 lives in `CellVisibility.GetVisibleCells`.
**Conclusion:** H1 and H3 should be **separate fixes**. Closing H1 will close cellar-stairs + the outdoor-stab side of #78 but NOT close #95. The next phase should plan H1 in scope and decide whether H3 fits in the same milestone (M1.5).
---
## What we've ruled out
- **It's not the #100 cell-collapse bug returning.** `hiddenTerrainCells` plumbing was fully removed in `a64e6f2`; terrain mesh now correctly renders everywhere on the landblock per retail. The new artifact's mechanism is "outdoor geometry visible at all when indoor," not "incorrect terrain mesh shape."
- **It's not a depth-precision issue (high confidence, pending falsification).** Patch shape + "clears closer" both contradict Z-fight.
- **It's not a `ParentCellId` propagation bug.** Audit confirmed that interior cell static objects (`GameWindow.BuildInteriorEntitiesForStreaming:5476`) and cell-mesh entities (line 5416) both receive non-null `ParentCellId = envCellId`. The dispatcher's existing filter already correctly culls them when the camera is in a different building. The bug is the OPPOSITE direction (outdoor entities w/ `ParentCellId == null` always pass).
- **It's not WB extraction divergence.** Phase O extracted ~33 WB files into `src/AcDream.App/Rendering/Wb/` but the `VisibilityManager` / `RenderInsideOut` pipeline was NOT extracted — that code never existed in our tree.
- **It's not a missing camera-cell signal at the render layer.** `cameraInsideCell`, `visibility.VisibleCellIds`, and `visibility.HasExitPortalVisible` are all already computed in `GameWindow.cs:6970-6984` and live in scope at the two `Draw` call sites (lines 7074 + 7110). No new plumbing required.
---
## Approach options for the fix
Three viable approaches, with tradeoffs:
### Approach A — WB-style stencil (recommended for first ship)
Port `VisibilityManager.RenderInsideOut`'s stencil pipeline to acdream. Two-pass render: (1) mark current-building portal silhouettes in stencil, (2) gate outdoor passes by `glStencilFunc(Equal, 1, 0x01)`.
**Pros:**
- Closest to acdream's existing modern GL pipeline (we already use stencil for nothing else; adding one stencil bit is cheap).
- WB is acdream's documented rendering base (per CLAUDE.md). Cross-reference checked against retail confirms WB's intent matches retail's, just via a different mechanism.
- Handles the "see outside through open door" case correctly — terrain renders through portal silhouettes only.
- Reusable for both outdoor terrain AND outdoor entities (single stencil gate applies to all subsequent draws).
**Cons:**
- Multi-pass render adds GPU cost (small — one stencil pass per current-building's portals).
- Requires a portal-mesh upload pipeline (WB has one in `PortalRenderManager.cs:488-628`; we'd port it).
- More LOC than Approach C.
**Estimated scope:** 4-6 tasks, 1-2 weeks of implementation + verification.
### Approach B — Retail-faithful polygon-clip sub-views
Port `PView::ConstructView` + `PView::GetClip` + `Render::PortalList` from retail. Per-mesh viewport set to clipped portal polygon.
**Pros:**
- 100% retail-faithful.
**Cons:**
- Requires per-draw viewport scissor changes — current rendering uses bindless + MDI with one viewport per pass. Wedging per-mesh viewport in would break the modern pipeline's batching.
- Multi-week port. Out of scope for one session.
**Estimated scope:** 8-12 tasks, 4-6 weeks. Defer to a future milestone if needed.
### Approach C — Ship-now binary gate
When `cameraInsideCell && !visibility.HasExitPortalVisible`, skip outdoor terrain pass entirely and gate `WbDrawDispatcher` to exclude `ParentCellId == null` entities.
**Pros:**
- Smallest change. ~2-3 tasks. Closes the cellar-stairs symptom and the sealed-interior side of #78 immediately.
- All required state already computed (`HasExitPortalVisible` from `CellVisibility.GetVisibleCells` line 404).
**Cons:**
- Under-renders when player can see outside through an open door/window (renders nothing instead of clipping correctly). This is regressive vs. today for the doorway-view case.
- Per CLAUDE.md "no workarounds": this *is* a symptom-gate rather than a root-cause fix. **Would need explicit user approval.** Approach A is the correct shape; Approach C is a temporary patch.
**Estimated scope:** 2-3 tasks, 1-2 days.
---
## Recommended next step
1. **User runs the camera-rotation falsification test (~60 seconds).** Spawn at Holtburg, walk into a cottage cellar, find the angle showing the grass patch, rotate the camera in place without moving. Report what happens.
- Polygon-stable → confirms H1, proceed.
- Flickering → pivots to H2, this report needs major revision.
2. **If H1 confirmed: user picks Approach A vs C.** Recommendation: **Approach A (WB-style stencil)**. Per CLAUDE.md's "no workarounds" rule, the right thing is to port the stencil pipeline, not gate at the symptom site. Approach C is offered only if the user wants to close cellar-stairs immediately and defer doorway-view correctness as known-incomplete; that's an explicit workaround that needs user sign-off.
3. **#95 should NOT be in scope for this work.** Different mechanism, different code path. File continues as separate work in M1.5.
4. **Phase identifier:** the handoff proposes A8 (visibility) alongside A6 (physics) and A7 (lighting). I'll defer naming to the user.
5. **CLAUDE.md update for #100 ship:** the handoff calls this out as pending. Recommendation: add a brief #100 ship entry mentioning the cellar-stairs finding linked to #78. Out of scope for investigate mode; will happen at the start of the implementation session.
---
## What this is NOT
This is NOT a #100 regression. The terrain Z-nudge ship works correctly; the new artifact has a different root cause (indoor-camera gate on outdoor passes was already missing pre-#100#100 just made it more visible by removing the terrain-cell hide mechanism that incidentally masked it inside building footprints).
This is NOT a depth-precision fix. The 1cm nudge is correctly sized; larger nudges would break coplanar-floor disambiguation elsewhere.
This is NOT a `ParentCellId` data fix. Interior entities are correctly tagged.
This is NOT covered by Phase O's WB extraction. The visibility-management code was deliberately NOT extracted.
---
## Reference appendix
### Retail anchors (acclient_2013_pseudo_c.txt)
| Line | Symbol | Role |
|---|---|---|
| 92635 | `SmartBox::RenderNormalMode` | Per-frame top-level dispatcher (indoor vs outdoor branch) |
| 267912 | `LScape::draw` | Outdoor terrain dispatch |
| 311397 | `CEnvCell::find_visible_child_cell` | Point-in-visible-cell query |
| 311878 | `CEnvCell::grab_visible_cells` | Loads outdoor on `seen_outside` |
| 427843 | `RenderDeviceD3D::DrawInside` | Indoor entry point |
| 429245 | `RenderDeviceD3D::DrawMesh` | **Per-mesh portal-sub-view loop** |
| 430027 | `RenderDeviceD3D::DrawBlock` | Outdoor landblock dispatch |
| 432709 | **`PView::DrawCells`** | **The `outside_view.view_count > 0` gate** |
| 433750 | `PView::ConstructView` | BFS portal walk |
### WorldBuilder anchors
| File:Line | Role |
|---|---|
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239` | `RenderInsideOut` — full stencil pipeline |
| Same file:241-359 | `RenderOutsideIn` — outdoor branch |
| Same file:47-71 | `PrepareVisibility` — visible cell set |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs:880-1008` | Main render dispatch (lines 965, 988 are the gates) |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:488-628` | Portal mesh upload |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/CameraController.cs:142-174` | Camera-cell tracking (portal raycasts) |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/PortalStencil.frag:7-16` | Stencil shader (writes `gl_FragDepth = 1.0`) |
### acdream extension points (audit-verified)
| File:Line | Current behavior | Extension required |
|---|---|---|
| `src/AcDream.App/Rendering/CellVisibility.cs:222-232` | Returns `VisibilityResult` with `VisibleCellIds`, `HasExitPortalVisible`, `CameraCell` | None — state already in place |
| `src/AcDream.App/Rendering/GameWindow.cs:6970-6984` | Computes `cameraInsideCell` and `playerInsideCell` per frame | None — values already in scope |
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:360-374` | Gates by `ParentCellId ∈ visibleCellIds`; outdoor entities (null) always pass | Add second gate: when `cameraInsideCell == true` and entity is outdoor (`ParentCellId == null`), require stencil pass or skip entirely |
| `src/AcDream.App/Rendering/TerrainModernRenderer.cs:191-208` | Frustum-only cull; renders all loaded landblocks | Add parameter for stencil pass / indoor-camera state |
| `src/AcDream.App/Rendering/GameWindow.cs:7074` | `_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb)` | Add `cameraInsideCell` (or equivalent) parameter |
| `src/AcDream.App/Rendering/GameWindow.cs:7110` | `WbDrawDispatcher.Draw(... visibleCellIds: visibility?.VisibleCellIds, ...)` | Add `cameraInsideCell` parameter |
| `src/AcDream.Core/Rendering/RenderingDiagnostics.cs:75-77` | Existing probe flag registry (mirror of `PhysicsDiagnostics`) | Add `ProbeVisibilityEnabled` from `ACDREAM_PROBE_VIS=1` |
### Issues family map
| ID | Symptom | Closes with H1 fix? |
|---|---|---|
| #78 | Outdoor stabs visible through inn floor/walls | YES (same root cause) |
| Cellar-stairs (NEW) | Outdoor terrain visible inside cottage cellar | YES (same root cause; new evidence for #78) |
| #95 | Portal-graph visibility blowup (visibleCells up to 295) | NO — independent (different code path) |
| #79/#80/#81/#93/#94 | Indoor lighting bugs | Maybe — #93 explicitly suspects "indoor visibility culling for lights" sub-cause; lighting subsystem may share infrastructure with visibility-gate but not directly impacted |
### Workflow notes (from CLAUDE.md "How to operate")
- "No workarounds without explicit approval" — Approach C is a workaround; Approach A is the correct shape.
- Visual verification is the user's job; can't be automated.
- Phase ID for visibility work is undecided. User picks at implementation-session start.
- Per the milestones doc, this is M1.5 scope; cellar-stairs is on the M1.5 critical path because it blocks the building/cellar half of the M1.5 demo.

View file

@ -0,0 +1,137 @@
# Phase A8 re-plan — entity taxonomy investigation
**Date:** 2026-05-26
**Phase:** A8 — Indoor-cell visibility culling RE-PLAN
**Predecessor handoff:** [docs/research/2026-05-26-a8-revert-handoff.md](2026-05-26-a8-revert-handoff.md)
**Status:** Report-only. Awaiting user approval of recommended fix-shape before Phase 2 (plan writing).
**Empirical context (added during investigation):** the bug exists on `main` too — verified by side-by-side launch of `main` vs `HEAD = fef6c61`. Both branches show outdoor buildings/terrain visible through the walls of a cottage when standing inside. The bug is **fundamental**, not a regression in this worktree's 149-commit divergence. The A8 framing in the predecessor handoff stands.
---
## TL;DR
The retail data model, WorldBuilder's data model, and the comment at `GameWindow.cs:5175-5178` all agree on a single architectural fact: **building shells are tagged distinctly from outdoor scenery at the data layer.** acdream's `LandblockLoader` reads both `LandBlockInfo.Objects` (scenery) and `LandBlockInfo.Buildings` (shells) into the same `WorldEntity` pool with no tag, destroying the distinction. The fix is to add `WorldEntity.IsBuildingShell: bool` at the loader, propagate it through hydration, and use it in the `WbDrawDispatcher.EntitySet` partition. This is **retail-faithful** (matches `BuildInfo` array) and **WB-faithful** (matches `SceneryInstance.IsBuilding`).
GL state order from the A8 Round 3 learning (MarkAndPunch BEFORE indoor draw) is confirmed correct by reading WorldBuilder's `VisibilityManager.RenderInsideOut`.
Far-side-portal (WB "Step 5", 3-stencil-bit) is deferred. First-ship approximation: only stencil-mark the **camera's own cell's** portals, not BFS-extended `VisibleCellIds`.
---
## The seven entity classes in acdream's runtime
| # | Class | `ParentCellId` | `Id` prefix | `ServerGuid` | Source field |
|---|---|---|---|---|---|
| 1 | Cell mesh | set | `0x40xxxxxx` | 0 | `EnvCell.EnvironmentId` |
| 2 | Cell static object | set | `0x40xxxxxx` | 0 | `EnvCell.StaticObjects` |
| 3 | **Building shell stab** | **null** | `0xC0xxxxxx` | 0 | **`LandBlockInfo.Buildings`** |
| 4 | **Outdoor scenery stab** | **null** | `0xC0xxxxxx` | 0 | **`LandBlockInfo.Objects`** |
| 5 | Procedural scenery | null | `0x80xxxxxx` | 0 | `SceneryGenerator` (terrain table) |
| 6a | Live animated | null | `0x10xxxxxx` | ≠0 | `CreateObject` packet |
| 6b | Live static | null | `0x10xxxxxx` | ≠0 | `CreateObject` packet |
**Classes 3 and 4 are indistinguishable at runtime today** (identical field shape after hydration). This is the load-bearing wrong assumption from the A8 attempt.
### Code anchors (acdream)
- `src/AcDream.Core/World/LandblockLoader.cs:62-71` — Objects (Class 4) loop
- `src/AcDream.Core/World/LandblockLoader.cs:74-87` — Buildings (Class 3) loop, **same `nextId++` counter, same WorldEntity shape**
- `src/AcDream.App/Rendering/GameWindow.cs:5129-5137` — hydration pass-through, no distinction preserved
- `src/AcDream.App/Rendering/GameWindow.cs:5175-5178` — the comment that proves the distinction is intentional in dat:
> *"Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are static scenery placeholders themselves (rocks, tree clusters) that retail does NOT use to suppress scenery generation."*
---
## How retail tags buildings (cross-reference 1)
`CLandBlock::init_buildings` (`acclient_2013_pseudo_c.txt:313854-313920`) reads `CLandBlockInfo::buildings[]` — a **separate `BuildInfo**` array**, NOT a flag bit or ID-range scheme.
- `CLandBlockInfo.num_buildings` + `buildings[]` array (`acclient.h:31893-31905`)
- `BuildInfo` struct: `building_id`, `building_frame`, `num_portals`, `CBldPortal** portals` (`acclient.h:32035-32042`)
- Buildings hydrate via `CBuildingObj::makeBuilding()` (line 313879) and register into the landblock's `stablist[]` (per-landblock visible-cell set, line 313910)
- Visibility uses **stablist (portal PVS)**, NOT AABB-encloses-camera. `CEnvCell::grab_visible` walks `stab_list[i]` directly.
Conclusion: **retail explicitly distinguishes the two via separate dat arrays.** This is the data-model truth we should match.
## How WorldBuilder tags buildings (cross-reference 2)
WB uses **two manager classes** sharing one mesh pool:
- `StaticObjectRenderManager` — handles BOTH `LandBlockInfo.Objects` and `LandBlockInfo.Buildings`, tagging each `SceneryInstance.IsBuilding` (`StaticObjectRenderManager.cs:334-400`).
- `SceneryRenderManager` — handles ONLY procedural terrain-derived scenery (different class entirely, doesn't share the dat path).
Tagging happens at **hydration time** in `GenerateForLandblockAsync` (lines 315-427). The instance is then split into separate `StaticPartGroups` vs `BuildingPartGroups` for draw dispatch.
`BuildingPortalGPU` (`PortalRenderManager.cs:687-701`) holds `EnvCellIds: HashSet<uint>` populated at landblock generation (line 549) — the "this building contains these EnvCells" association. The set is **never re-computed at render time**.
WB's `RenderInsideOut` GL state order (`VisibilityManager.cs:73-239`):
1. Stencil bit 1 ← portal polygons (color/depth masks off)
2. `gl_FragDepth = 1.0` ← portal polygons (depth mask on, depth-func = Always)
3. **Interior EnvCells render WITHOUT stencil restriction** ← key step
4. Stencil-restricted (`Equal, 1`): terrain + scenery + buildings render only at portal silhouettes
5. (Step 5) 3-stencil-bit pipeline for cross-building visibility — DEFER
**WB's order = MarkAndPunch (Step 1 + 2) FIRST, then indoor cells (Step 3).** This matches A8 Round 3's correction. The handoff's GL-state-order conclusion stands.
---
## Recommended fix-shape (synthesized)
### Stage 1: Tag at hydration (`IsBuildingShell` flag)
Add `WorldEntity.IsBuildingShell: bool` (default false). In `LandblockLoader.cs`:
- Objects loop (line 62): `IsBuildingShell = false`
- Buildings loop (line 74): `IsBuildingShell = true`
In `GameWindow.cs:5129-5137` (hydration): copy `IsBuildingShell` from `e` to the hydrated entity. One-line change.
### Stage 2: Refine `WbDrawDispatcher.EntitySet` partition
Replace today's binary `IndoorOnly`/`OutdoorOnly` with:
- `IndoorPass``ParentCellId.HasValue || IsBuildingShell` (Classes 1, 2, 3)
- `OutdoorScenery``!ParentCellId.HasValue && !IsBuildingShell && (ServerGuid == 0)` (Classes 4, 5)
- `LiveDynamic``ServerGuid != 0` (Classes 6a, 6b)
`WalkEntitiesInto` updates one branch (the partition predicate). 26 dispatcher tests will need their fixture entities tagged correctly; otherwise behavior is the same.
### Stage 3: Re-wire render frame with WB's order
When camera is inside a cell:
1. Draw terrain (color in framebuffer)
2. **MarkAndPunch** (stencil = 1 + depth = 1.0 at portal silhouettes)
3. `WbDrawDispatcher.Draw(set: IndoorPass)` — cell mesh + cell statics + building shells. Stencil disabled, depth test normal. These write depth ON TOP of the 1.0 punch, correctly occluding the next stencil-gated pass.
4. Re-draw terrain (color writes only) with `StencilFunc(Equal, 1)` — terrain visible only at portal silhouettes.
5. `WbDrawDispatcher.Draw(set: OutdoorScenery)` with `StencilFunc(Equal, 1)` — outdoor scenery visible only at portal silhouettes.
6. `WbDrawDispatcher.Draw(set: LiveDynamic)` — stencil disabled, depth test on. Live entities draw freely; depth occludes them by walls and cell meshes already in the depth buffer.
When camera is outside: stencil work skipped entirely. Today's all-entities single draw stands (or substitute the three EntitySet calls with stencil disabled — depth still sorts them correctly).
### Stage 4: Far-side-portal approximation (defer Step 5)
Stencil-mark **only the camera's own cell's portals** in Step 2, not the BFS-extended `VisibleCellIds`. This trades cross-cell-portal visibility (rare visually) for correctness in the common case (no "see-through-wall on the other side of the room"). Track as a known limitation; revisit if visual gate flags it.
---
## Reasons for confidence
1. **Triple-cited**: retail (`BuildInfo` array), WB (`IsBuilding` flag), acdream's own code comment (5175-5178) all agree on the distinction.
2. **Tagging cost is microscopic** — one bool on `WorldEntity`, one branch in `LandblockLoader`. No new types, no new managers, no field migration.
3. **`EntitySet` enum is already in place** (dormant from Tasks 1-6). Refactor is reshaping its semantics, not introducing it.
4. **GL state order is validated** by both Round 3 of the A8 attempt and WB's reference. No remaining ambiguity.
5. **Live-dynamic separation handles the Round 1 character-disappears bug** (handoff §Round 1). They draw last, stencil disabled, depth-tested against everything else.
## Open questions for user approval
1. Use `IsBuildingShell` flag (recommended) vs separate `0xC1xxxxxx` ID-namespace? Flag is more explicit, retail-faithful, and trivially greppable. ID-namespace is one less field but invisible at the call site.
2. Defer Step 5 (far-side portals) and stencil-mark only camera's own cell? Recommendation: yes — ship simple, file follow-up.
3. Live-dynamic entities (Class 6b: dropped items) — draw in `LivePass` or accept "invisible from inside" until a richer flag exists? Recommendation: `LivePass`. They're rare visually, and the player benefits from seeing dropped items through the floor (gameplay nicety, not retail violation).
4. Cellar-stairs grass overlay from OUTSIDE: NOT A8 scope (no stencil runs when camera is outside). Open question for a future "deep-cell terrain occlusion" phase. Confirm we file this separately, not bundled.
---
## Reference anchors (still valid from predecessor handoff)
- WB stencil: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239`
- WB building-cell association: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:518-551`
- Retail building init: `docs/research/named-retail/acclient_2013_pseudo_c.txt:313854-313920`
- Retail building struct: `docs/research/named-retail/acclient.h:31893-31905`, `:32035-32042`, `:32094-32103`
- acdream LandblockLoader: `src/AcDream.Core/World/LandblockLoader.cs:62-87`
- acdream hydration: `src/AcDream.App/Rendering/GameWindow.cs:5093-5148`, `:5175-5178`

View file

@ -0,0 +1,168 @@
# Phase A8 Cellar-Flap Handoff - 2026-05-28
## Status
The remaining cottage/cellar artifact is still visible. The user sees a short-lived green terrain-like rectangle/flap over the cellar entrance or floor opening when entering/exiting a building and when the chase camera angle changes.
This thread hit the project rule: 3 visual-gated fixes failed for this specific artifact. Stop here. Do not ship a fourth speculative renderer change. The next step must be source-provenance instrumentation or an architecture comparison.
The client was stopped after the failed visual gate to avoid build/file locks.
## Last Good Context
The broader A8 indoor renderer is much improved and shippable aside from this artifact:
- Theory A front-face alignment plus per-batch `CullMode` fixed major missing/inside-out geometry.
- The `InstanceData` stride fix explained the texture explosion/distortion root cause.
- Building shell scoping, portal bounds, streaming/promotion fixes, and FPS fixes remain important.
- The current artifact is narrow: a green terrain-like flap at/near cellar entrance transitions.
## Failed Fixes In This Micro-Loop
### 1. Camera branch gate relaxation
Hypothesis: strict `PointInCell` camera-inside-building gating was causing the renderer to switch branches while the chase camera crossed walls/portals.
Change tried: allow the inside-out branch to continue based on `CameraCell.BuildingId`, without the strict `PointInCell` check.
Result: visual gate failed. User still saw the artifact. Change was reverted back to strict `PointInCell`.
Evidence ruled out: the artifact is not solely caused by the render branch dropping out when the camera is slightly outside the cell.
### 2. Portal stencil `pos.w` clamp
Hypothesis: near-zero clip W for portal stencil triangles was exploding the screen-space mask as the camera crossed a portal plane.
Change tried: restored WorldBuilder-style clamp in `src/AcDream.App/Rendering/Shaders/portal_stencil.vert`:
```glsl
vec4 pos = uViewProjection * vec4(aPosition, 1.0);
if (abs(pos.w) < 0.001)
pos.w = pos.w < 0.0 ? -0.001 : 0.001;
gl_Position = pos;
```
Result: visual gate failed. User still saw the artifact.
Interpretation: this may still be WB parity and may be worth keeping, but it is not the root cause of the cellar flap.
### 3. Visible-cell portal mask
Hypothesis: `RenderInsideOutAcdream` was punching outdoor terrain through all exit portals in the camera building, not just exits reached by the current portal traversal. A window/door portal could project over the cellar opening and let terrain draw through it.
Changes tried:
- Added `CellVisibility.TryGetCell(uint, out LoadedCell?)`.
- Added `IndoorCellStencilPipeline.DrawUploadedPortalMesh(...)` to draw the already-uploaded visible-cell portal mesh.
- Changed `RenderInsideOutAcdream` Step 1/2 to upload portal triangles from `visibleCellIds`, not `camBuildings`.
- Wrapped Step 4 terrain/outdoor scenery draw inside `if (didInsideStencil)`.
- Added pure math test `BuildTriangles_OnlyIncludesProvidedVisibleCells`.
Verification before launch:
```powershell
dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --no-restore
dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter FullyQualifiedName~IndoorCellStencilPipelineTests --no-restore
```
Both passed after shutting down build servers. The first parallel test attempt only failed because `VBCSCompiler` locked `AcDream.App.dll` while a parallel build was running.
Visual result: failed. User still saw the flap.
Evidence ruled out: the artifact is not explained only by building-wide vs visible-cell exit portal masking.
## Current Uncommitted Changes From This Micro-Loop
These are not visually validated as a fix:
- `src/AcDream.App/Rendering/Shaders/portal_stencil.vert`
- WB-style `pos.w` clamp.
- `src/AcDream.App/Rendering/CellVisibility.cs`
- `TryGetCell`.
- `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs`
- `DrawUploadedPortalMesh`.
- `src/AcDream.App/Rendering/GameWindow.cs`
- visible-cell portal mask in `RenderInsideOutAcdream`.
- Step 4 fail-closed when no portal mesh uploaded.
- `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs`
- visible-cell-only triangle generation test.
Do not commit these as "the fix" unless a later source-provenance check proves they are correct and harmless. If the next architecture route goes elsewhere, revert or split them honestly.
## What We Know About The Cellar Entrance
Prior inspection found:
- The cellar transition around Holtburg cottage cells `0xA9B40143 -> 0x0146 -> 0x0147` is indoor-to-indoor.
- The relevant transition portals are not literal `OtherCellId == 0xFFFF` outside exits.
- The portal cap polygons inspected there are `NoPos`.
- That made "cell mesh uploads the outdoor floor polygon" unlikely, but not proven impossible for the visible green patch.
## What The Failed Fixes Suggest
The green pixels may not be caused by the portal mask at all. We need to prove which render pass writes them before touching behavior again.
Candidate explanations now:
1. **The green patch is not Step 4 terrain.**
It could be an EnvCell surface with the wrong texture/material, stale texture binding, or an indoor static object surface resolving to a grass texture.
2. **The patch is Step 4 terrain, but the stencil/depth mask source is not the exit portal list.**
It may be a stale stencil/depth state leak, a Step 3/4 depth ordering issue, or a missing retail occlusion/scissor lifecycle detail.
3. **The cellar opening has a missing or late-loaded indoor object.**
If a hatch/stair/floor object should cover or occlude that area and is missing for one or more frames, outdoor/terrain pixels behind it become visible.
4. **Camera clipping is exposing an intentionally hidden surface.**
Retail camera collision is incomplete in acdream. If the camera clips through the wall/floor volume, the renderer may be showing a view retail never permits. This does not explain all cases by itself, because the user also sees it during transitions, but it must be separated from a renderer bug.
## Next Step - Evidence Only
Add a provenance diagnostic, not a fix. One visual launch should answer what writes the green flap.
Recommended diagnostic:
- Add an env-gated pass tint or disable switch for only the inside-out Step 4 terrain draw.
- Add a separate tint/disable for Step 4 `OutdoorScenery`.
- Add a tint/disable for Step 3 EnvCell opaque.
- Keep default behavior unchanged.
Example env names:
```text
ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_TERRAIN=1
ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_OUTDOOR=1
ACDREAM_A8_DIAG_DISABLE_INSIDE_ENVCELL_OPAQUE=1
```
Run only one diagnostic at a time:
- If disabling Step 4 terrain removes the green flap, the issue is still terrain leaking through the indoor view mask.
- If disabling Step 4 terrain does not remove it, stop looking at portal exits and inspect EnvCell/static texture/material assignment for the specific surface.
- If disabling EnvCell opaque removes it, dump the exact `CellStruct` polygon/material under the camera/screenshot area.
Do not ship the diagnostic switches. Strip them after the source is known.
## Launch/Test Notes
Last clean visual launch:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_A8_INDOOR_BRANCH = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug
```
The log files from the last launch were empty because this was a clean no-probe run:
- `a8-visible-cell-portalmask-cellar-flap-20260528-161702.out.log`
- `a8-visible-cell-portalmask-cellar-flap-20260528-161702.err.log`
## Stop Rule
This artifact has now consumed three visual failures in this micro-loop. Next session should not make another fix until it has hard evidence identifying the pass/polygon/source texture responsible for the green pixels.

View file

@ -0,0 +1,176 @@
# A8 cellar-flap — option-2 handoff + brainstorming kickoff (2026-05-28 PM)
## Purpose
The cellar flap is the **last** A8 indoor-rendering defect. Its root cause is
fully understood (offline-confirmed). The targeted fix (option 1) was tried,
**failed**, and the failure revealed a deeper architectural coupling. The
decision is to fix it the **retail-faithful way (option 2: WB-style recursive
portal visibility)** via a fresh `superpowers:brainstorming` session. This doc
is the single pickup point for that session.
## Current tree state (do NOT reset)
- Worktree: `.claude/worktrees/strange-albattani-3fc83c/`, branch
`claude/strange-albattani-3fc83c`, tip `e415bb3` — **all A8 work is
uncommitted** in a dirty tree.
- Build green. App tests pass (90 baseline; the 3 option-1 tests were removed).
- The option-1 code (`PortalMeshBuilder.CollectSameLevelPortalCells` +
`IsVerticalPortal` + 3 tests + the GameWindow call-site change) has been
**reverted/removed** — tree is back to the working-with-cellar-flap baseline.
- `tools/A8CellAudit` gained a `portals` mode this session (offline cell/portal
dumper) — **kept**, it's the investigation workhorse.
### What WORKS in the dirty tree (the valuable A8 batch — keep)
- EnvCellRenderer SSBO **stride fix** (mat4 upload, not 80-byte InstanceData).
- WB-style global `FrontFace(CW)` + per-batch `CullMode` through MDI.
- `EntitySet` partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
`BuildingShellAnchorCellId` scoping.
- `RenderOutsideInAcdream` (look into buildings from outside).
- `CollectVisiblePortalBuildings` frustum cull of portal bounds.
- Streaming near/far priority queues + `PromoteToNear` + the
`LandblockEntriesWithoutAnimatedIndex` hot-path fix (fixed bridge/wall/collision
regressions after travel).
- Temporary `ACDREAM_A8_DIAG_*` flags (strip before any commit).
### What DOESN'T work
- **Cellar flap** + the broader inside-out fragility (see the coupling below).
> **Decision point for the human:** the working A8 batch is large and
> uncommitted. Consider committing it (after stripping the `ACDREAM_A8_DIAG_*`
> flags) so the option-2 work starts from a clean baseline. Deferred per
> "don't commit yet," but flagged.
## The cellar flap — root cause (confirmed)
Full evidence: [`docs/research/2026-05-28-a8-cellar-flap-root-cause.md`](2026-05-28-a8-cellar-flap-root-cause.md).
Short version: the inside-out stencil mask flat-marks the exit portals
(windows/doors) of **every** visibility-BFS-reached cell. From the cellar
(`0xA9B40171`, **zero** exit portals), the BFS reaches the ground-floor cells
(`0x16F`, `0x170`) up the stairwell and marks **their** windows. Step 4 then
paints the whole outdoor world through those silhouettes wherever the cellar's
stairwell hole leaves them un-occluded. There is no constraint tying a deeper
cell's exit portal to the portal chain (the narrow stairwell) it was reached
through.
## ⭐ THE KEY FINDING — `didInsideStencil` double-duty coupling
This is the expensive lesson; do not re-pay it.
`RenderInsideOutAcdream` Step 4 (GameWindow.cs ~11167) wraps **both** the
terrain draw **and** the entire `OutdoorScenery` dispatcher draw (which includes
neighbor **building shells**, scenery, and the depth-repair pass) in:
```csharp
if (didInsideStencil) { ... terrain + OutdoorScenery ... }
```
where `didInsideStencil == (camera-side-filtered exit-portal mask is non-empty)`.
So the portal mask is doing **two jobs at once**:
- **Job A (intended):** gate "paint terrain/sky *through* the portal openings."
- **Job B (accidental):** decide "draw exterior geometry (shells/scenery/depth-repair) **at all**."
**Why option 1 failed:** option 1 correctly shrank the mask (same-level cells
only) so the cellar's mask went empty → `didInsideStencil=false` → **Step 4
skipped entirely** → exterior shells + terrain vanished → "walls transparent,
sky behind, terrain gone." The old flat mask (all visN cells) *papered over*
this by almost always keeping the mask non-empty.
**Consequence for ANY fix:** correctly scoping/clipping the portal mask is not
enough on its own — it will empty the mask in legitimate cases (looking at an
interior wall, sealed cellar) and kill exterior rendering. **Job A and Job B
must be decoupled** so exterior geometry draws regardless of whether any portal
is currently visible. This is true for option 2 as much as option 1.
## Decision: option 2 (WB-faithful recursive portal visibility)
Chosen over option 1 (decouple-only) because:
- The project mandate is faithful WB/retail porting; option 1 is a structural
deviation from WB's RenderInsideOut, and prior "cleaner redesign" deviations
were reverted.
- Option 2 handles every case (cellar, stacked floors, deep dungeons) without
per-case special-casing.
- It is large enough to deserve design-first (brainstorm), not a mid-session patch.
Note: option 2 still has to solve the Job-A/Job-B decoupling above — it's not
optional.
## Open design questions for the brainstorm (resolve BEFORE coding)
1. **Does WB even render a sealed sub-cell (cellar) via inside-out?** Check how
WB derives `_buildingsWithCurrentCell` (VisibilityManager.PrepareVisibility +
PortalRenderManager.GetBuildingPortalsByCellId). If WB *excludes* a cell with
no exit portals from the inside-out path, the "fix" may be a classification
change, not recursion. **Verify against WB source — don't assume recursion exists.**
2. **How does WB ACTUALLY constrain per-portal visibility?** Re-read
`VisibilityManager.cs` (RenderInsideOut/RenderOutsideIn) + `PortalRenderManager.cs`
end-to-end. Is the clipping (a) recursive portal traversal, (b) the 3-bit
stencil Step-5 pipeline, (c) pure Step-3 depth occlusion, or (d) the BSP
portal-graph in PrepareVisibility? Our port copied the *flat* Steps 1-4; the
constraint mechanism may live in code we didn't port.
3. **Job-A/Job-B decoupling.** Design how exterior geometry (shells + scenery +
depth-repair) draws independent of the portal mask, while terrain-through-portal
stays stencil-gated. This must land regardless of the recursion design.
4. **Stencil-bit budget + occlusion-query lifecycle** if the full WB Step-5
cross-building path is adopted (currently gated off via `ACDREAM_A8_STEP5`).
## Key source references for the brainstorm
WB (the algorithm being ported):
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs`
`PrepareVisibility` (47-71), `RenderInsideOut` (73-239), `RenderOutsideIn` (241+).
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs`
`RenderBuildingStencilMask`, `GetVisibleBuildingPortals`, `GetBuildingPortalsByCellId`.
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs`.
acdream (the current port):
- `src/AcDream.App/Rendering/GameWindow.cs``RenderInsideOutAcdream`
(~11012), `RenderOutsideInAcdream` (~196), the Step-4 `didInsideStencil` gate
(~11167). **This is where the Job-A/Job-B coupling lives.**
- `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs``PortalMeshBuilder`
(camera-side filter), `RenderBuildingStencilMask`, `DrawUploadedPortalMesh`.
- `src/AcDream.App/Rendering/Wb/BuildingLoader.cs` — building cell-set BFS
(mirrors WB PortalService; cellar IS expanded into building 0xA).
Retail oracle (if WB is ambiguous):
- `docs/research/named-retail/acclient_2013_pseudo_c.txt``CObjCell::find_visible_child_cell`
(≈311397), `PView::DrawCells` (≈432709). Retail uses screen-space polygon-clip
scissor recursion — the conceptual ancestor of "clip each portal to the chain."
Offline tooling:
- `tools/A8CellAudit``dotnet run -- portals <cellId...>` dumps a cell's
CellPortals (exit vs interior); `-- buildings <lb> <radius>` dumps
building→cell grouping. Reproduces the whole investigation in seconds, no launch.
## Brainstorming kickoff prompt (copy-paste into a fresh session)
> Use the `superpowers:brainstorming` skill. We're designing the retail-faithful
> fix for the A8 "cellar flap" — the last A8 indoor-rendering defect.
>
> Read first, in order:
> 1. `docs/research/2026-05-28-a8-cellar-flap-option2-handoff.md` (this doc) —
> current state, the confirmed root cause, the `didInsideStencil` double-duty
> finding, the decision, and the open design questions.
> 2. `docs/research/2026-05-28-a8-cellar-flap-root-cause.md` — the offline evidence.
> 3. The WB + acdream source references listed in the handoff.
>
> The goal: design how acdream's indoor visibility should render outside-through-
> portals **correctly clipped to the portal chain** (so a sealed cellar shows no
> terrain, a windowed room shows its own windows, deep rooms show only the sliver
> visible through the doorway chain) **AND** decouple "draw exterior geometry at
> all" from "is the portal mask non-empty" (the coupling that made the targeted
> fix regress).
>
> Brainstorm MUST resolve the 4 open design questions in the handoff before any
> code — especially Q1 (does WB even render a sealed cellar inside-out?) and Q2
> (what is WB's ACTUAL per-portal clipping mechanism — verify against source,
> don't assume recursion). Output a written design/plan; do not start coding
> until the design is agreed.
>
> Process rules still in force: no workarounds/band-aids; faithful WB/retail
> port; one visual gate only when a complete fix is ready; the broken indoor
> branch is behind `ACDREAM_A8_INDOOR_BRANCH=1` (default off = pre-A8 visual).
> The dirty tree has valuable uncommitted A8 work — decide whether to commit it
> (strip `ACDREAM_A8_DIAG_*` first) before starting.

View file

@ -0,0 +1,120 @@
# A8 cellar-flap — structured debugging root cause (2026-05-28 PM)
## Method
Systematic-debugging Phases 1-3, all evidence gathered **offline** via the
`tools/A8CellAudit` tool (extended with a `portals` mode) — no live launches
needed. Deterministic, instant, reproducible.
## Phase 1 — evidence
Scenario (from `launch-a8-probe-normal-20260528-194536.out.log`):
- Camera in cell `0xA9B40171`, `inside=True really=True`.
- `camBldgs=[0xA]`, `visN=7 [0x16F,0x170,0x171,0x172,0x173,0x174,0x175]`.
- Portal stencil mask = 12 verts (not the old over-punch case).
- Bisection (prior session): writer is **Step 4 content**; disabling Step-2
punch does **not** fix it.
Offline audit findings:
**Building grouping** (`A8CellAudit buildings 0xA9B40000`):
```
buildingOrdinal=10 registryId=0xA model=0x01002232 portalCells=[0xA9B4016F,0xA9B40170]
```
Building `0xA`'s LandBlockInfo seed = `{0x16F, 0x170}`. `BuildingLoader` then
BFS-expands through interior portals → all 7 cells (incl. the cellar). The
BFS matches WB's `PortalService` (same algorithm), so the grouping is not the
divergence.
**Exit-portal ownership** (`A8CellAudit portals ...`):
| Cell | exit portals (0xFFFF) | interior | role |
|------|----|----|------|
| `0x16F` | **1** | 1 | ground floor (window/door) |
| `0x170` | **1** | 1 | ground floor (window/door) |
| `0x171` (camera) | **0** | 3 | cellar |
| `0x172``0x175` | **0** | 12 | cellar rooms |
So the 12-vert mask = `0x16F` exit (6v) + `0x170` exit (6v). **The cellar
camera (zero exit portals) is marking the two ground-floor windows.**
**Topology**:
```
0x171.portal[0] -> 0x170 (stairwell/hatch, polyId 54)
0x170.portal[1] -> 0x171 (polyId 5)
0x170.portal[0] -> EXIT (window/door to outside, polyId 4)
```
Cellar connects directly up to ground floor `0x170`; `0x16F` is one further hop.
**Occluder geometry** (`A8CellAudit 0xA9B40170` / `0xA9B40171`):
- `0x170` floor poly `0x0002` (n.Z=+1) **emits** — the cellar's ceiling/occluder exists.
- `0x171` has a ceiling `0x0003` (n.Z=-1, emits) AND three `NoPos` polys
`0x0036/0x0037/0x0038` (surface `0x080000DF`) that do **not** emit —
`0x0038` is a ceiling-plane poly = the **stairwell hole** up to the ground floor.
## Phase 2 — pattern vs WB
WB `RenderInsideOut` marks the building's exit portals (flat — same as us) and
relies on **Step-3 cell depth** to occlude them: terrain only survives where the
punched/cleared far-depth isn't overwritten by rendered cell geometry.
Our code matches that structure. The difference that produces the visible flap:
WB's outside view through a portal is the world geometrically behind that
portal; from a cellar, the only un-occluded opening is the **stairwell hole**
(`0x0038`, not rendered). Through that hole, stencil=1 (ground-floor window
marked) and depth=far → **Step 4 draws the entire outdoor world (terrain +
buildings) through the hole**, not a window-sized sliver. The two ground-floor
windows are 12 BFS hops above the camera and should contribute essentially
nothing from the cellar, but their full silhouettes are marked.
"Disable Step-2 punch doesn't fix it" is explained: the leak pixels are the
stairwell hole, which has **cleared (far) depth** regardless of the punch
because no cell geometry covers it — terrain passes `DepthFunc.Less` either way.
## Phase 3 — single hypothesis (root cause)
**The inside-out exit-portal stencil mask is built by flat-marking the exit
portals of every visibility-BFS-reached cell. From the cellar, the BFS reaches
the ground-floor cells, whose windows get full-silhouette-marked. Where the
cellar's stairwell hole leaves those silhouettes un-occluded, Step 4 paints the
whole outdoor world through them. There is no constraint tying a deeper cell's
exit portal to the portal chain (here: the narrow stairwell) through which its
cell became visible.**
This is a flat-vs-constrained masking gap. Not a depth bug (occluders emit and
render), not the Step-2 punch, not the camera-side filter (the cellar camera is
geometrically on the interior side of a ground-floor window's plane, so the
per-portal filter passes it).
## Phase 4 — fix options
1. **Camera-cell-scoped mask (minimal, conservative).** Mark only the camera
cell's own exit portals. Cellar (0 exit portals) → empty mask → no leak;
windowed room → marks its own windows. **Risk:** loses daylight through an
*adjacent* cell's window seen across a doorway in multi-cell ground-floor
rooms (e.g. the inn) — a visible-but-minor regression, and the flat approach
was wrong there anyway.
2. **Vertical-portal-aware scoping (targeted).** Don't propagate exit-portal
marking across a floor/ceiling (vertical-normal) portal. The cellar→ground
stairwell is a horizontal-plane portal; suppressing inheritance across it
stops the cellar from marking ground-floor windows while preserving
same-level multi-cell rooms. Needs per-portal polygon-normal classification.
3. **WB recursive/constrained portal masking (faithful, largest).** Constrain
each deeper portal's stencil to the screen region of the portal chain leading
to it. Correct for all cases (cellar + multi-cell rooms) but a substantial
port of WB's recursive RenderInsideOut.
**Recommendation:** option 2 is the best correctness/effort trade — it fixes the
cellar without the inn regression risk of option 1, and is a principled scoping
rule (don't inherit a different vertical level's exterior openings) rather than a
band-aid. Option 3 remains the eventual faithful target if cross-level portal
visibility ever needs to be exact.
## Reproduction / verification assets
- `tools/A8CellAudit` `portals` mode (added this session) dumps any cell's
`CellPortals` offline. `A8CellAudit buildings <lb> <radius>` dumps
building→cell grouping. These make the whole investigation re-runnable in
seconds with zero launches.

File diff suppressed because it is too large Load diff

View file

@ -225,6 +225,12 @@ public sealed class CellVisibility
: System.Array.Empty<LoadedCell>();
}
/// <summary>
/// Looks up a currently loaded cell by full 32-bit cell id.
/// </summary>
public bool TryGetCell(uint cellId, out LoadedCell? cell)
=> _cellLookup.TryGetValue(cellId, out cell);
/// <summary>
/// Removes all cells belonging to <paramref name="lbId"/> (upper 16 bits of
/// the landblock key, e.g. 0xA9B4 for landblock 0xA9B40000). Called when a

View file

@ -171,6 +171,159 @@ public sealed class GameWindow : IDisposable
// around each RenderBuildingStencilMask call.
private AcDream.App.Rendering.IndoorCellStencilPipeline? _indoorStencilPipeline;
private void CollectVisiblePortalBuildings(
System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building> output,
int centerLbX,
int centerLbY,
int radius)
{
output.Clear();
foreach (var (landblockId, reg) in _buildingRegistries)
{
int lbX = (int)((landblockId >> 24) & 0xFFu);
int lbY = (int)((landblockId >> 16) & 0xFFu);
if (System.Math.Abs(lbX - centerLbX) > radius ||
System.Math.Abs(lbY - centerLbY) > radius)
{
continue;
}
foreach (var b in reg.All())
{
if (!b.HasPortalBounds)
continue;
// WB PortalRenderManager.GetVisibleBuildingPortals frustum-culls
// each building's portal AABB with ignoreNearPlane=true. That
// prevents a doorway/window clipped by the camera near plane from
// dropping out of the portal visibility list.
if (_envCellFrustum is not null &&
_envCellFrustum.TestBox(b.PortalBounds, ignoreNearPlane: true)
== AcDream.App.Rendering.Wb.FrustumTestResult.Outside)
{
continue;
}
output.Add(b);
}
}
}
private void RenderOutsideInAcdream(
System.Numerics.Matrix4x4 viewProj,
AcDream.App.Rendering.ICamera camera,
AcDream.App.Rendering.FrustumPlanes? frustum,
uint? playerLb,
System.Collections.Generic.HashSet<uint>? animatedIds,
System.Collections.Generic.IReadOnlyList<AcDream.App.Rendering.Wb.Building> visibleBuildings)
{
if (visibleBuildings.Count == 0)
return;
var gl = _gl!;
var visibleEnvCellIds = new System.Collections.Generic.HashSet<uint>();
foreach (var b in visibleBuildings)
{
foreach (var id in b.EnvCellIds)
visibleEnvCellIds.Add(id);
}
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled)
{
Console.WriteLine(
$"[outside-in] portals={visibleBuildings.Count} cells={visibleEnvCellIds.Count}");
}
// WB VisibilityManager.RenderOutsideIn, but fed by the same
// frustum-visible portal list prepared above instead of every loaded
// building. Terrain/scenery are already drawn by the caller; this pass
// opens portal silhouettes, repairs wall depth, then draws EnvCells
// through those silhouettes. WB's outside-in EnvCell render passes a
// null cell filter; the stencil/depth mask is the visibility gate.
gl.Enable(EnableCap.StencilTest);
gl.ClearStencil(0);
gl.Clear(ClearBufferMask.StencilBufferBit);
// Step 1: mark visible building portals where the exterior depth test
// says the portal surface is actually visible.
gl.Disable(EnableCap.CullFace);
gl.StencilFunc(StencilFunction.Always, 1, 0xFFu);
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
gl.StencilMask(0xFFu);
gl.ColorMask(false, false, false, false);
gl.DepthMask(false);
gl.Enable(EnableCap.DepthTest);
gl.DepthFunc(DepthFunction.Less);
foreach (var b in visibleBuildings)
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false);
// Step 2: punch portal depth to the far plane.
gl.StencilFunc(StencilFunction.Equal, 1, 0xFFu);
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
gl.StencilMask(0x00u);
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Always);
foreach (var b in visibleBuildings)
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true);
// Step 3: depth-only repair for exterior walls that overlap portal
// silhouettes, matching WB's staticObjectManager depth repair pass.
// In acdream, the dispatcher can target just building shells here;
// walking the full OutdoorScenery set would reprocess every tree and
// outdoor static object only to write depth under portal masks.
gl.Enable(EnableCap.CullFace);
gl.FrontFace(FrontFaceDirection.CW);
gl.DepthFunc(DepthFunction.Less);
_meshShader!.Use();
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibleEnvCellIds,
animatedEntityIds: null,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.BuildingShells);
_a8PerfLastOutsideShellStats = _wbDrawDispatcher.LastDrawStats;
// Step 4/5: render EnvCells through the repaired stencil mask.
//
// WB EnvCellRenderManager owns BOTH cell geometry and EnvCell static
// objects. A8 split that in acdream: EnvCellRenderer owns only the
// CellStruct mesh, while static objects remain dispatcher WorldEntity
// records with ParentCellId. Mirror WB's combined manager by drawing
// the dispatcher IndoorPass through the same portal stencil and the
// same WB-derived visible cell set used to prepare EnvCellRenderer.
gl.StencilFunc(StencilFunction.Equal, 1, 0xFFu);
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
gl.StencilMask(0x00u);
gl.ColorMask(true, true, true, false);
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Less);
gl.Disable(EnableCap.Blend);
_meshShader!.Use();
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, filter: null);
gl.Enable(EnableCap.Blend);
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
gl.DepthMask(false);
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, filter: null);
gl.DepthMask(true);
gl.Disable(EnableCap.Blend);
_meshShader!.Use();
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibleEnvCellIds,
animatedEntityIds: null,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass);
_a8PerfLastOutsideIndoorStats = _wbDrawDispatcher.LastDrawStats;
gl.Disable(EnableCap.StencilTest);
gl.StencilMask(0xFFu);
gl.ColorMask(true, true, true, true);
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Less);
gl.FrontFace(FrontFaceDirection.CW);
}
/// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose
/// MotionTable resolved to a real cycle. The render loop ticks each
@ -2281,23 +2434,27 @@ public sealed class GameWindow : IDisposable
// its value is the live WorldEntity we need to dispose.
RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false);
// Log every spawn that arrives so we can inventory what the server
// When requested, log every spawn that arrives so we can inventory what the server
// sends (including the ones we can't render yet). The Name field
// is the critical one — we can grep the log for "Nullified Statue
// of a Drudge" or similar to find a specific weenie by its
// in-game name.
string posStr = spawn.Position is { } sp
? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}"
: "no-pos";
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
int subPalCount = spawn.SubPalettes?.Count ?? 0;
Console.WriteLine(
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
$"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
bool dumpLiveSpawns = _options.DumpLiveSpawns;
if (dumpLiveSpawns)
{
string posStr = spawn.Position is { } sp
? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}"
: "no-pos";
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
int subPalCount = spawn.SubPalettes?.Count ?? 0;
Console.WriteLine(
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
$"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
}
_liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(
spawn.Name,
@ -2308,7 +2465,9 @@ public sealed class GameWindow : IDisposable
// Target the statue specifically for full diagnostic dump: Name match
// is cheap and gives us exactly one entity's worth of log regardless
// of arrival order.
bool isStatue = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
bool isStatue = dumpLiveSpawns
&& spawn.Name is not null
&& spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
if (isStatue)
{
Console.WriteLine($"live: [STATUE] objScale={spawn.ObjScale?.ToString("F3") ?? "null"}");
@ -2388,8 +2547,9 @@ public sealed class GameWindow : IDisposable
if (setup is null)
{
_liveDropReasonSetupDatMissing++;
Console.WriteLine($"live: DROP setup dat 0x{spawn.SetupTableId.Value:X8} missing " +
$"(guid=0x{spawn.Guid:X8})");
if (dumpLiveSpawns)
Console.WriteLine($"live: DROP setup dat 0x{spawn.SetupTableId.Value:X8} missing " +
$"(guid=0x{spawn.Guid:X8})");
return;
}
@ -2627,7 +2787,9 @@ public sealed class GameWindow : IDisposable
// Second pass: resolve each affected part's Surface chain and
// build the Surface-id-keyed override map the renderer consumes.
bool isStatueDiag = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
bool isStatueDiag = dumpLiveSpawns
&& spawn.Name is not null
&& spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
resolvedOverridesByPart = new Dictionary<int, Dictionary<uint, uint>>();
for (int pi = 0; pi < parts.Count; pi++)
{
@ -2720,8 +2882,9 @@ public sealed class GameWindow : IDisposable
if (meshRefs.Count == 0)
{
_liveDropReasonNoMeshRefs++;
Console.WriteLine($"live: DROP no mesh refs from setup 0x{spawn.SetupTableId.Value:X8} " +
$"(guid=0x{spawn.Guid:X8})");
if (dumpLiveSpawns)
Console.WriteLine($"live: DROP no mesh refs from setup 0x{spawn.SetupTableId.Value:X8} " +
$"(guid=0x{spawn.Guid:X8})");
return;
}
if (dumpClothing)
@ -3008,7 +3171,7 @@ public sealed class GameWindow : IDisposable
// Dump a summary periodically so we can see drop breakdowns without
// waiting for a graceful shutdown.
if (_liveSpawnReceived % 20 == 0)
if (dumpLiveSpawns && _liveSpawnReceived % 20 == 0)
{
Console.WriteLine(
$"live: animated={_animatedEntities.Count} " +
@ -5173,6 +5336,7 @@ public sealed class GameWindow : IDisposable
Rotation = e.Rotation,
MeshRefs = meshRefs,
IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag
BuildingShellAnchorCellId = e.BuildingShellAnchorCellId,
};
hydrated.Add(entity);
}
@ -6415,7 +6579,7 @@ public sealed class GameWindow : IDisposable
lbNoneCount++;
}
}
if (scTried > 0)
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && scTried > 0)
Console.WriteLine(
$"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} registered={scRegistered} " +
$"noBounds={scNoBounds} tooThin={scTooThin} (outdoorNone={lbNoneCount})");
@ -6439,7 +6603,7 @@ public sealed class GameWindow : IDisposable
sampleMissing.Add(entity.SourceGfxObjOrSetupId);
}
}
if (sceneryNoCache > 0)
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && sceneryNoCache > 0)
{
string samples = string.Join(",", sampleMissing.Select(s => $"0x{s:X8}"));
Console.WriteLine($" → {sceneryNoCache} scenery entities had no visual bounds cached. Samples: {samples}");
@ -6965,8 +7129,56 @@ public sealed class GameWindow : IDisposable
private double _perfAccum;
private int _perfFrameCount;
private long _a8PerfLastLogTick;
private long _a8PerfFrames;
private long _a8PerfInsideFrames;
private long _a8PerfOutsideInFrames;
private long _a8PerfTickAnimTicks;
private long _a8PerfCollectTicks;
private long _a8PerfEnvPrepareTicks;
private long _a8PerfTerrainTicks;
private long _a8PerfStaticTicks;
private long _a8PerfOutsideInTicks;
private long _a8PerfLiveTicks;
private long _a8PerfInsideOutTicks;
private long _a8PerfInsideLiveTicks;
private int _a8PerfLastPortalBuildings;
private int _a8PerfMaxPortalBuildings;
private int _a8PerfLastPortalCells;
private int _a8PerfMaxPortalCells;
private int _a8PerfLastVisibleLandblocks;
private int _a8PerfLastTotalLandblocks;
private readonly System.Collections.Generic.HashSet<uint> _a8PerfCellScratch = new();
private const int A8PerfGpuRingDepth = 4;
private const int A8PerfGpuPassCount = 6;
private const int A8PerfGpuTerrain = 0;
private const int A8PerfGpuStatic = 1;
private const int A8PerfGpuOutsideIn = 2;
private const int A8PerfGpuLive = 3;
private const int A8PerfGpuInsideOut = 4;
private const int A8PerfGpuInsideLive = 5;
private readonly uint[] _a8PerfGpuQueries = new uint[A8PerfGpuRingDepth * A8PerfGpuPassCount];
private bool _a8PerfGpuQueriesInitialized;
private int _a8PerfGpuFrameIndex;
private readonly bool[] _a8PerfGpuIssued = new bool[A8PerfGpuRingDepth * A8PerfGpuPassCount];
private long _a8PerfTerrainGpuNs;
private long _a8PerfStaticGpuNs;
private long _a8PerfOutsideInGpuNs;
private long _a8PerfLiveGpuNs;
private long _a8PerfInsideOutGpuNs;
private long _a8PerfInsideLiveGpuNs;
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastStaticStats;
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastLiveStats;
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastOutsideShellStats;
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastOutsideIndoorStats;
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastInsideStats;
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastInsideLiveStats;
private void OnRender(double deltaSeconds)
{
bool a8Perf = A8PerfEnabled();
int a8GpuSlot = A8PerfBeginGpuFrame(a8Perf);
// Phase G.1: set the clear color from the current sky's fog
// tint so the horizon band continues naturally past the
// rendered geometry. Fog blends to this color at max distance
@ -6990,6 +7202,13 @@ public sealed class GameWindow : IDisposable
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit);
// WB GameScene.cs:830-843 establishes CW as the frame-global
// front-face convention; per-batch CullMode changes only the culled
// side. A8 indoor/env-cell geometry and setup meshes share that
// convention, so keep the GL state aligned before any scene pass.
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.CW);
// Phase N.6 slice 1: one-shot surface-format histogram dump under
// ACDREAM_DUMP_SURFACES=1. Zero cost when off.
_textureCache?.TickSurfaceHistogramDumpIfEnabled();
@ -7008,8 +7227,10 @@ public sealed class GameWindow : IDisposable
// Phase 6.4: advance per-entity animation playback before drawing
// so the renderer always sees the up-to-date per-part transforms.
long a8PerfStart = A8PerfStart(a8Perf);
if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds);
A8PerfStop(a8Perf, ref _a8PerfTickAnimTicks, a8PerfStart);
// Phase G.1: weather state machine — deterministic per-day roll
// + transitions + lightning flash.
@ -7126,6 +7347,9 @@ public sealed class GameWindow : IDisposable
var camBuildings = new System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building>();
var otherBuildings = new System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building>();
var visiblePortalBuildings = new System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building>();
System.Collections.Generic.HashSet<uint>? envCellPrepareFilter = null;
int visiblePortalCellCount = 0;
if (cameraInsideBuilding)
{
@ -7135,12 +7359,6 @@ public sealed class GameWindow : IDisposable
foreach (var b in reg.GetBuildingsContainingCell(visibility.CameraCell.CellId))
camBuildings.Add(b);
}
var camCellId = visibility!.CameraCell!.CellId;
foreach (var rr in _buildingRegistries.Values)
foreach (var b in rr.All())
if (!b.EnvCellIds.Contains(camCellId))
otherBuildings.Add(b);
}
// SPIKE 2026-05-26: A8 transition investigation. Lights up the
@ -7249,11 +7467,67 @@ public sealed class GameWindow : IDisposable
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
}
int renderCenterLbX = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f);
int renderCenterLbY = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
// Phase A8: prepare EnvCellRenderer's per-frame visibility snapshot.
// Always called — cheap when no cells loaded, cheap when frustum culls all.
var envCellViewProj = camera.View * camera.Projection;
_envCellFrustum?.Update(envCellViewProj);
_envCellRenderer?.PrepareRenderBatches(envCellViewProj, camPos);
if (a8IndoorBranchEnabled)
{
a8PerfStart = A8PerfStart(a8Perf);
CollectVisiblePortalBuildings(
visiblePortalBuildings,
renderCenterLbX,
renderCenterLbY,
_nearRadius);
// WB VisibilityManager.PrepareVisibility builds the EnvCell
// set before EnvCellRenderManager.PrepareRenderBatches, then
// RenderOutsideIn calls Render(..., null) against that already-
// narrowed snapshot. Keep that two-stage shape: the stencil is
// the render gate, but the prepared workload remains limited
// to camera-building cells, runtime portal-visible cells, plus
// frustum-visible portal cells.
envCellPrepareFilter = new System.Collections.Generic.HashSet<uint>();
foreach (var b in camBuildings)
foreach (var id in b.EnvCellIds)
envCellPrepareFilter.Add(id);
if (visibility?.VisibleCellIds is not null)
foreach (var id in visibility.VisibleCellIds)
envCellPrepareFilter.Add(id);
foreach (var b in visiblePortalBuildings)
foreach (var id in b.EnvCellIds)
envCellPrepareFilter.Add(id);
visiblePortalCellCount = envCellPrepareFilter.Count;
if (a8Perf)
{
_a8PerfCellScratch.Clear();
foreach (var id in envCellPrepareFilter)
_a8PerfCellScratch.Add(id);
}
A8PerfStop(a8Perf, ref _a8PerfCollectTicks, a8PerfStart);
if (cameraInsideBuilding && visibility?.CameraCell is not null)
{
var camCellId = visibility.CameraCell.CellId;
foreach (var b in visiblePortalBuildings)
{
if (!b.EnvCellIds.Contains(camCellId))
otherBuildings.Add(b);
}
}
}
a8PerfStart = A8PerfStart(a8Perf);
_envCellRenderer?.PrepareRenderBatches(
envCellViewProj,
camPos,
envCellPrepareFilter,
centerLbX: renderCenterLbX,
centerLbY: renderCenterLbY,
renderRadius: _nearRadius);
A8PerfStop(a8Perf, ref _a8PerfEnvPrepareTicks, a8PerfStart);
// Phase G.1: sky renderer — draws the far-plane-infinity
// celestial meshes FIRST so the rest of the scene z-tests
@ -7315,8 +7589,12 @@ public sealed class GameWindow : IDisposable
// The proper fix is to NOT draw terrain here when indoor; Step 4
// is the single, stencil-gated terrain pass.
_terrainCpuStopwatch.Restart();
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuTerrain);
if (!cameraInsideBuilding)
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfTerrainTicks, a8PerfStart);
_terrainCpuStopwatch.Stop();
// Multiply by 100 then divide by 100 in the diag print to keep
// 0.01 µs precision in the long-typed sample buffer. Terrain Draw
@ -7357,10 +7635,14 @@ public sealed class GameWindow : IDisposable
// but aren't stencil-clipped.
if (cameraInsideBuilding)
{
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuInsideOut);
RenderInsideOutAcdream(envCellViewProj, camPos, visibility!.CameraCell!,
camBuildings, otherBuildings,
camera, frustum, playerLb, animatedIds,
visibility?.VisibleCellIds);
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfInsideOutTicks, a8PerfStart);
// Phase A8 fix (2026-05-28 visual-gate-#2 follow-up): LiveDynamic
// entities (player char, NPCs, dropped items, doors) were missing
@ -7372,19 +7654,63 @@ public sealed class GameWindow : IDisposable
// stencil + state restored to defaults at its cleanup block. Same
// shape as the outdoor branch's Draw(All) for the LiveDynamic
// subset only.
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuInsideLive);
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic);
_a8PerfLastInsideLiveStats = _wbDrawDispatcher.LastDrawStats;
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfInsideLiveTicks, a8PerfStart);
}
else
{
// N.5: WbDrawDispatcher is always non-null (modern path mandatory).
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds);
if (a8IndoorBranchEnabled)
{
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuStatic);
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: null,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfStaticTicks, a8PerfStart);
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuOutsideIn);
RenderOutsideInAcdream(envCellViewProj, camera, frustum, playerLb,
animatedIds, visiblePortalBuildings);
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfOutsideInTicks, a8PerfStart);
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuLive);
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic);
_a8PerfLastLiveStats = _wbDrawDispatcher.LastDrawStats;
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfLiveTicks, a8PerfStart);
}
else
{
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuStatic);
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds);
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfStaticTicks, a8PerfStart);
}
}
// Phase G.1 / E.3: draw all live particles after opaque
@ -7488,12 +7814,19 @@ public sealed class GameWindow : IDisposable
}
// Count visible vs total for the perf overlay.
foreach (var entry in _worldState.LandblockEntries)
foreach (var entry in _worldState.LandblockBounds)
{
totalLandblocks++;
if (AcDream.App.Rendering.FrustumCuller.IsAabbVisible(frustum, entry.AabbMin, entry.AabbMax))
visibleLandblocks++;
}
MaybeFlushA8Perf(
a8Perf,
cameraInsideBuilding,
visiblePortalBuildings.Count,
visiblePortalCellCount,
visibleLandblocks,
totalLandblocks);
// Phase I.2: refresh per-frame fields that DebugVM closures
// can't compute lazily (frustum-derived counters + nearest-
@ -7547,6 +7880,8 @@ public sealed class GameWindow : IDisposable
// GL state it touches (blend, scissor, VAO, shader, texture); any
// state not in its save-list (e.g. GL_FRAMEBUFFER_SRGB, unused
// today) would need manual protection.
A8PerfEndGpuFrame(a8Perf);
if (DevToolsEnabled && _imguiBootstrap is not null && _panelHost is not null)
{
// Phase I.3 — prefer the live command bus when a live session
@ -10691,8 +11026,32 @@ public sealed class GameWindow : IDisposable
EmitBuildingsProbe(visibilityCellId: cameraCell.CellId, camBuildings, otherBuildings);
// Steps 1+2: stencil bit 1 + far-depth punch at camera-buildings' portals.
if (camBuildings.Count > 0)
bool diagDisableStep4Terrain = _options.A8DiagDisableInsideStep4Terrain;
bool diagDisableStep4Outdoor = _options.A8DiagDisableInsideStep4Outdoor;
bool diagDisableStep3EnvCellOpaque = _options.A8DiagDisableInsideStep3EnvCellOpaque;
bool diagDisableStep3IndoorPass = _options.A8DiagDisableInsideStep3IndoorPass;
bool diagDisableStep2Punch = _options.A8DiagDisableInsideStep2Punch;
bool diagDisablePortalDepthClamp = _options.A8DiagDisablePortalDepthClamp;
var visiblePortalCells = new System.Collections.Generic.List<AcDream.App.Rendering.LoadedCell>();
if (visibleCellIds is not null)
{
foreach (uint cellId in visibleCellIds)
{
if (_cellVisibility.TryGetCell(cellId, out var cell) && cell is not null)
visiblePortalCells.Add(cell);
}
}
int insidePortalVertexCount = visiblePortalCells.Count > 0
? _indoorStencilPipeline!.UploadPortalMesh(visiblePortalCells, camPos)
: 0;
// Steps 1+2: stencil bit 1 + far-depth punch at portal-visible exits only.
// WB builds its outside view from the current portal traversal; using every
// exit on the camera building over-punches terrain through indoor openings
// when an unrelated window/door portal overlaps them in screen space.
if (insidePortalVertexCount > 0)
{
didInsideStencil = true;
gl.Enable(EnableCap.StencilTest);
@ -10711,26 +11070,30 @@ public sealed class GameWindow : IDisposable
gl.DepthFunc(DepthFunction.Always);
EmitDrawOrderProbe(step: 1, sub: ' ');
foreach (var b in camBuildings)
{
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false);
EmitStencilProbe(op: "mark");
}
_indoorStencilPipeline!.DrawUploadedPortalMesh(
viewProj,
writeFarDepth: false,
enableDepthClamp: !diagDisablePortalDepthClamp);
EmitStencilProbe(op: "mark-visible");
// Step 2: punch depth at portals.
// WB VisibilityManager.cs:99-104
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Always);
EmitDrawOrderProbe(step: 2, sub: ' ');
foreach (var b in camBuildings)
if (!diagDisableStep2Punch)
{
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true);
EmitStencilProbe(op: "punch");
// Step 2: punch depth at portals.
// WB VisibilityManager.cs:99-104
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Always);
EmitDrawOrderProbe(step: 2, sub: ' ');
_indoorStencilPipeline!.DrawUploadedPortalMesh(
viewProj,
writeFarDepth: true,
enableDepthClamp: !diagDisablePortalDepthClamp);
EmitStencilProbe(op: "punch-visible");
}
}
// Step 3: render camera-buildings' cells (stencil off, DepthFunc.Less).
// Step 3: render the indoor cells visible from the camera cell
// (stencil off, DepthFunc.Less).
// WB VisibilityManager.cs:107-127
gl.ColorMask(true, true, true, false);
gl.DepthMask(true);
@ -10744,12 +11107,27 @@ public sealed class GameWindow : IDisposable
{
foreach (var b in camBuildings)
foreach (var id in b.EnvCellIds) currentEnvCellIds.Add(id);
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, currentEnvCellIds);
// A8 cellar-flap provenance (2026-05-28): disabling Step 4
// terrain removes the green flap, proving terrain is the writer.
// The leak happens because indoor-to-indoor portal cells reached
// by the runtime visibility BFS can be outside the static
// BuildingInfo cell set. Render them in Step 3 so their depth
// blocks Step 4 terrain, while real exterior openings still show
// terrain through the portal mask.
if (visibleCellIds is not null)
foreach (var id in visibleCellIds)
currentEnvCellIds.Add(id);
gl.Disable(EnableCap.Blend);
if (!diagDisableStep3EnvCellOpaque)
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, currentEnvCellIds);
// Transparency pass.
gl.Enable(EnableCap.Blend);
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
gl.DepthMask(false);
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, currentEnvCellIds);
gl.DepthMask(true);
gl.Disable(EnableCap.Blend);
}
// FIX 2026-05-28 (post-third-visual-gate): render IndoorPass entities.
@ -10764,20 +11142,22 @@ public sealed class GameWindow : IDisposable
// Result with the missing call: user reports "house missing lots of
// walls" — the cottage's exterior wall slabs aren't drawn.
//
// Render IndoorPass between Step 3 and Step 4, with the
// currentEnvCellIds filter narrowing cell stabs but NOT the building
// shells (they have no ParentCellId and pass through). Depth-test
// with DepthFunc.Less so cottage-A's near walls occlude cottage-B's
// far walls. NO stencil — we want them rendered unconditionally
// inside the camera-building.
if (camBuildings.Count > 0)
// Render IndoorPass between Step 3 and Step 4. The currentEnvCellIds
// filter now narrows both cell stabs and building shells: shells have
// no ParentCellId, but carry BuildingShellAnchorCellId from
// LandBlockInfo.Buildings[]. Depth-test with DepthFunc.Less so the
// current cottage shell occludes any farther geometry. NO stencil: we
// want the active building shell rendered unconditionally inside the
// camera-building.
if (camBuildings.Count > 0 && !diagDisableStep3IndoorPass)
{
_meshShader!.Use();
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: currentEnvCellIds,
animatedEntityIds: animatedIds,
animatedEntityIds: null,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass);
_a8PerfLastInsideStats = _wbDrawDispatcher.LastDrawStats;
}
EmitEnvCellProbe(camBuildings.Count, otherBuildings.Count, currentEnvCellIds.Count);
@ -10794,31 +11174,52 @@ public sealed class GameWindow : IDisposable
gl.DepthMask(true);
gl.Enable(EnableCap.CullFace);
gl.DepthFunc(DepthFunction.Less);
EmitDrawOrderProbe(step: 4, sub: ' ');
// Terrain (WB line 143).
// acdream's retail/ACME terrain mesh is CCW from the visible top side
// (see terrain_modern.vert's LandblockMesh order comment), while WB's
// editor terrain uses the opposite vertex order under its global CW
// convention. Step 4 enables culling before terrain, so temporarily
// use terrain's own front-face convention or ground disappears through
// indoor portal silhouettes.
if (!diagDisableStep4Terrain)
{
gl.FrontFace(FrontFaceDirection.Ccw);
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
gl.FrontFace(FrontFaceDirection.CW);
}
else
{
gl.FrontFace(FrontFaceDirection.CW);
}
_meshShader!.Use();
// Scenery + static objects via dispatcher (WB lines 148-154).
if (!diagDisableStep4Outdoor)
{
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibleCellIds, // OK - outdoor cells outside the building
animatedEntityIds: null,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
}
}
EmitDrawOrderProbe(step: 4, sub: ' ');
// Terrain (WB line 143).
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
_meshShader!.Use();
// Scenery + static objects via dispatcher (WB lines 148-154).
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibleCellIds, // OK — outdoor cells outside the building
animatedEntityIds: animatedIds,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
// Step 5: per-other-building 3-bit stencil pipeline.
// WB VisibilityManager.cs:157-232
//
// FIX 2026-05-28 (post-first-visual-gate): Step 5 is GATED OFF BY DEFAULT.
// First visual gate showed perf collapse + texture flicker indoors because
// Step 5 iterates EVERY loaded other-building per frame (109 at Holtburg),
// each doing 5 GL draws (mark/end-query/punch/render-opaque/render-trans/reset)
// = ~545 extra draws/frame with no frustum culling. The driver hit TDR
// limits. Set ACDREAM_A8_STEP5=1 to re-enable for cross-building visibility
// testing once we add per-building frustum culling.
bool step5Enabled = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_A8_STEP5"), "1", StringComparison.Ordinal);
// GATED OFF BY DEFAULT. Current acdream does not yet have WB's
// portal-manager visibility/occlusion lifecycle; feeding this loop
// directly from all loaded building registries causes unrelated
// buildings' EnvCells to overwrite exterior walls through stale or
// over-broad portal masks. Keep the apparatus, but require an explicit
// opt-in until the portal list is proven equivalent to WB's
// _visibleBuildingPortals.
bool step5Enabled = string.Equals(
Environment.GetEnvironmentVariable("ACDREAM_A8_STEP5"), "1",
StringComparison.Ordinal);
if (step5Enabled && didInsideStencil && otherBuildings.Count > 0)
{
gl.Enable(EnableCap.StencilTest);
@ -10861,10 +11262,14 @@ public sealed class GameWindow : IDisposable
gl.DepthFunc(DepthFunction.Less);
gl.Enable(EnableCap.CullFace);
_meshShader!.Use();
gl.Disable(EnableCap.Blend);
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, b.EnvCellIds);
gl.Enable(EnableCap.Blend);
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
gl.DepthMask(false);
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, b.EnvCellIds);
gl.DepthMask(true);
gl.Disable(EnableCap.Blend);
// Step 5d: reset bit 2 (Ref=1, Mask=0x02) for next iteration. WB VisibilityManager.cs:222-228
EmitDrawOrderProbe(step: 5, sub: 'd');
@ -10896,6 +11301,13 @@ public sealed class GameWindow : IDisposable
gl.DepthFunc(DepthFunction.Less);
gl.Enable(EnableCap.CullFace);
}
// If no visible exit portal was uploaded, Step 4 is skipped but Step 3
// still leaves alpha writes disabled. Restore the outer-frame defaults.
gl.ColorMask(true, true, true, true);
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Less);
gl.Enable(EnableCap.CullFace);
}
// ── Phase A8 Task 9 (2026-05-28): probe trail for RenderInsideOutAcdream ──
@ -10915,7 +11327,8 @@ public sealed class GameWindow : IDisposable
// - [buildings] camBldgs=[0x...] non-empty when inside a cottage
// - [envcells] cells>=1 tris>=1 filterCnt>=1 for at least one indoor frame
// - [stencil] op=mark verts>0 fires per camera-building
// - [draworder] shows steps 1 → 2 → 3 → 4 → 5{a,b,c,d} per indoor frame
// - [draworder] shows steps 1 → 2 → 3 → 4 per indoor frame
// (and 5{a,b,c,d} only when ACDREAM_A8_STEP5=1)
private int _phaseA8DrawOrderFrame = 0;
@ -10942,6 +11355,7 @@ public sealed class GameWindow : IDisposable
gl.GetBoolean(Silk.NET.OpenGL.GLEnum.DepthWritemask, out var depthMask);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.CullFace, out int cullEnabled);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.CullFaceMode, out int cullMode);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.FrontFace, out int frontFace);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.BlendSrc, out int blendSrc);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.BlendDst, out int blendDst);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilFunc, out int sFunc);
@ -10971,6 +11385,7 @@ public sealed class GameWindow : IDisposable
$"[draworder] frame={_phaseA8DrawOrderFrame} step={step}{subStr} " +
$"stencil={(stOn != 0 ? "on" : "off")} depthFn=0x{depthFn:X} depthMask={depthMask} " +
$"cull={(cullEnabled != 0 ? "on" : "off")}({(cullMode == (int)Silk.NET.OpenGL.GLEnum.Front ? "front" : cullMode == (int)Silk.NET.OpenGL.GLEnum.Back ? "back" : "f+b")}) " +
$"front={(frontFace == (int)Silk.NET.OpenGL.GLEnum.CW ? "cw" : "ccw")} " +
$"blend=0x{blendSrc:X}/0x{blendDst:X} " +
$"sFunc=0x{sFunc:X}:{sRef}:0x{sValMask:X} " +
$"sOp=0x{sFail:X}/0x{sPdFail:X}/0x{sPdPass:X} sMask=0x{sWriteMask:X} " +
@ -11035,6 +11450,152 @@ public sealed class GameWindow : IDisposable
/// rolling 256-sample buffer of microseconds, median + p95 reported.
/// Sample buffer is NOT cleared on flush — it's a moving window so the
/// next 5s window already has 256 frames of recent history.</summary>
private static bool A8PerfEnabled()
=> string.Equals(Environment.GetEnvironmentVariable("ACDREAM_A8_PERF"), "1", StringComparison.Ordinal);
private static long A8PerfStart(bool enabled)
=> enabled ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
private static void A8PerfStop(bool enabled, ref long bucket, long startTick)
{
if (!enabled || startTick == 0) return;
bucket += System.Diagnostics.Stopwatch.GetTimestamp() - startTick;
}
private int A8PerfBeginGpuFrame(bool enabled)
{
if (!enabled || _gl is null) return -1;
if (!_a8PerfGpuQueriesInitialized)
{
for (int i = 0; i < _a8PerfGpuQueries.Length; i++)
_a8PerfGpuQueries[i] = _gl.GenQuery();
_a8PerfGpuQueriesInitialized = true;
}
int slot = _a8PerfGpuFrameIndex % A8PerfGpuRingDepth;
if (_a8PerfGpuFrameIndex >= A8PerfGpuRingDepth)
{
for (int pass = 0; pass < A8PerfGpuPassCount; pass++)
{
int queryIndex = slot * A8PerfGpuPassCount + pass;
if (!_a8PerfGpuIssued[queryIndex]) continue;
uint query = _a8PerfGpuQueries[queryIndex];
_gl.GetQueryObject(query, QueryObjectParameterName.ResultAvailable, out int available);
if (available == 0) continue;
_gl.GetQueryObject(query, QueryObjectParameterName.Result, out ulong elapsedNs);
A8PerfAccumulateGpu(pass, elapsedNs);
_a8PerfGpuIssued[queryIndex] = false;
}
}
return slot;
}
private void A8PerfEndGpuFrame(bool enabled)
{
if (enabled && _a8PerfGpuQueriesInitialized)
_a8PerfGpuFrameIndex++;
}
private void A8PerfBeginGpuQuery(bool enabled, int slot, int pass)
{
if (!enabled || slot < 0 || _gl is null) return;
int queryIndex = slot * A8PerfGpuPassCount + pass;
_a8PerfGpuIssued[queryIndex] = true;
uint query = _a8PerfGpuQueries[queryIndex];
_gl.BeginQuery(QueryTarget.TimeElapsed, query);
}
private void A8PerfEndGpuQuery(bool enabled, int slot)
{
if (!enabled || slot < 0 || _gl is null) return;
_gl.EndQuery(QueryTarget.TimeElapsed);
}
private void A8PerfAccumulateGpu(int pass, ulong elapsedNs)
{
long ns = elapsedNs > long.MaxValue ? long.MaxValue : (long)elapsedNs;
switch (pass)
{
case A8PerfGpuTerrain: _a8PerfTerrainGpuNs += ns; break;
case A8PerfGpuStatic: _a8PerfStaticGpuNs += ns; break;
case A8PerfGpuOutsideIn: _a8PerfOutsideInGpuNs += ns; break;
case A8PerfGpuLive: _a8PerfLiveGpuNs += ns; break;
case A8PerfGpuInsideOut: _a8PerfInsideOutGpuNs += ns; break;
case A8PerfGpuInsideLive: _a8PerfInsideLiveGpuNs += ns; break;
}
}
private static string A8DrawStats(AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats stats)
=> $"{stats.Draws}d/{stats.CullRuns}r/{stats.Instances}i/{stats.Triangles}t";
private void MaybeFlushA8Perf(
bool enabled,
bool cameraInsideBuilding,
int portalBuildings,
int portalCells,
int visibleLandblocks,
int totalLandblocks)
{
if (!enabled) return;
_a8PerfFrames++;
if (cameraInsideBuilding) _a8PerfInsideFrames++;
if (portalBuildings > 0) _a8PerfOutsideInFrames++;
_a8PerfLastPortalBuildings = portalBuildings;
_a8PerfMaxPortalBuildings = System.Math.Max(_a8PerfMaxPortalBuildings, portalBuildings);
_a8PerfLastPortalCells = portalCells;
_a8PerfMaxPortalCells = System.Math.Max(_a8PerfMaxPortalCells, portalCells);
_a8PerfLastVisibleLandblocks = visibleLandblocks;
_a8PerfLastTotalLandblocks = totalLandblocks;
long now = Environment.TickCount64;
if (now - _a8PerfLastLogTick <= 3000) return;
double Ms(long ticks) => _a8PerfFrames == 0
? 0.0
: ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency / _a8PerfFrames;
double GpuMs(long ns) => _a8PerfFrames == 0 ? 0.0 : ns / 1_000_000.0 / _a8PerfFrames;
Console.WriteLine(string.Create(System.Globalization.CultureInfo.InvariantCulture,
$"[A8-PERF] frames={_a8PerfFrames} inside={_a8PerfInsideFrames} outsideIn={_a8PerfOutsideInFrames} " +
$"portals={_a8PerfLastPortalBuildings}/{_a8PerfMaxPortalBuildings} cells={_a8PerfLastPortalCells}/{_a8PerfMaxPortalCells} " +
$"lb={_a8PerfLastVisibleLandblocks}/{_a8PerfLastTotalLandblocks} " +
$"avg_ms anim={Ms(_a8PerfTickAnimTicks):F3} collect={Ms(_a8PerfCollectTicks):F3} " +
$"envPrep={Ms(_a8PerfEnvPrepareTicks):F3} terrain={Ms(_a8PerfTerrainTicks):F3} " +
$"static={Ms(_a8PerfStaticTicks):F3} outsideIn={Ms(_a8PerfOutsideInTicks):F3} " +
$"live={Ms(_a8PerfLiveTicks):F3} insideOut={Ms(_a8PerfInsideOutTicks):F3} " +
$"insideLive={Ms(_a8PerfInsideLiveTicks):F3} " +
$"gpu_ms terrain={GpuMs(_a8PerfTerrainGpuNs):F3} static={GpuMs(_a8PerfStaticGpuNs):F3} " +
$"outsideIn={GpuMs(_a8PerfOutsideInGpuNs):F3} live={GpuMs(_a8PerfLiveGpuNs):F3} " +
$"insideOut={GpuMs(_a8PerfInsideOutGpuNs):F3} insideLive={GpuMs(_a8PerfInsideLiveGpuNs):F3} " +
$"draws static={A8DrawStats(_a8PerfLastStaticStats)} live={A8DrawStats(_a8PerfLastLiveStats)} " +
$"outsideShell={A8DrawStats(_a8PerfLastOutsideShellStats)} outsideIndoor={A8DrawStats(_a8PerfLastOutsideIndoorStats)}"));
_a8PerfFrames = 0;
_a8PerfInsideFrames = 0;
_a8PerfOutsideInFrames = 0;
_a8PerfTickAnimTicks = 0;
_a8PerfCollectTicks = 0;
_a8PerfEnvPrepareTicks = 0;
_a8PerfTerrainTicks = 0;
_a8PerfStaticTicks = 0;
_a8PerfOutsideInTicks = 0;
_a8PerfLiveTicks = 0;
_a8PerfInsideOutTicks = 0;
_a8PerfInsideLiveTicks = 0;
_a8PerfTerrainGpuNs = 0;
_a8PerfStaticGpuNs = 0;
_a8PerfOutsideInGpuNs = 0;
_a8PerfLiveGpuNs = 0;
_a8PerfInsideOutGpuNs = 0;
_a8PerfInsideLiveGpuNs = 0;
_a8PerfMaxPortalBuildings = portalBuildings;
_a8PerfMaxPortalCells = portalCells;
_a8PerfLastLogTick = now;
}
private void MaybeFlushTerrainDiag()
{
if (!string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal))

View file

@ -40,7 +40,9 @@ public static class PortalMeshBuilder
/// — they don't open to outdoors, so stencil-marking them would let
/// outdoor geometry bleed into adjacent rooms (incorrect).
/// </summary>
public static Vector3[] BuildTriangles(IReadOnlyCollection<LoadedCell> cells)
public static Vector3[] BuildTriangles(
IReadOnlyCollection<LoadedCell> cells,
Vector3? cameraWorldPosition = null)
{
// Pre-count to size the output exactly.
int triCount = 0;
@ -49,6 +51,7 @@ public static class PortalMeshBuilder
for (int p = 0; p < cell.Portals.Count; p++)
{
if (cell.Portals[p].OtherCellId != 0xFFFF) continue;
if (!ExitPortalPassesCameraSide(cell, p, cameraWorldPosition)) continue;
if (p >= cell.PortalPolygons.Count) continue;
var poly = cell.PortalPolygons[p];
if (poly.Length < 3) continue;
@ -66,6 +69,7 @@ public static class PortalMeshBuilder
for (int p = 0; p < cell.Portals.Count; p++)
{
if (cell.Portals[p].OtherCellId != 0xFFFF) continue;
if (!ExitPortalPassesCameraSide(cell, p, cameraWorldPosition)) continue;
if (p >= cell.PortalPolygons.Count) continue;
var poly = cell.PortalPolygons[p];
if (poly.Length < 3) continue;
@ -83,6 +87,29 @@ public static class PortalMeshBuilder
return output;
}
private static bool ExitPortalPassesCameraSide(
LoadedCell cell,
int portalIndex,
Vector3? cameraWorldPosition)
{
if (cameraWorldPosition is not Vector3 camera)
return true;
if (portalIndex >= cell.ClipPlanes.Count)
return true;
var plane = cell.ClipPlanes[portalIndex];
if (plane.Normal.LengthSquared() < 1e-8f)
return true;
var localCamera = Vector3.Transform(camera, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCamera) + plane.D;
return plane.InsideSide == 0
? dot >= -0.01f
: dot <= 0.01f;
}
}
/// <summary>
@ -120,9 +147,11 @@ public sealed unsafe class IndoorCellStencilPipeline : IDisposable
/// and uploads it to <see cref="_vbo"/>. Returns the vertex count
/// (0 means no exit portals — caller should skip stencil setup entirely).
/// </summary>
public int UploadPortalMesh(IReadOnlyCollection<LoadedCell> cells)
public int UploadPortalMesh(
IReadOnlyCollection<LoadedCell> cells,
Vector3? cameraWorldPosition = null)
{
var verts = PortalMeshBuilder.BuildTriangles(cells);
var verts = PortalMeshBuilder.BuildTriangles(cells, cameraWorldPosition);
_lastVertexCount = verts.Length;
if (_lastVertexCount == 0) return 0;
@ -142,6 +171,44 @@ public sealed unsafe class IndoorCellStencilPipeline : IDisposable
return _lastVertexCount;
}
/// <summary>
/// Draws the portal mesh most recently uploaded by <see cref="UploadPortalMesh"/>.
/// The caller owns stencil/depth/color/cull state, matching
/// <see cref="RenderBuildingStencilMask"/>.
/// </summary>
public void DrawUploadedPortalMesh(
Matrix4x4 viewProjection,
bool writeFarDepth,
bool enableDepthClamp = true)
{
if (_lastVertexCount == 0)
{
LastStencilVertexCount = 0;
LastStencilWasFarPunch = writeFarDepth;
LastStencilBuildingId = 0;
return;
}
if (enableDepthClamp)
_gl.Enable(EnableCap.DepthClamp);
_shader.Use();
var vp = viewProjection;
_gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp);
_gl.Uniform1(_uWriteFarDepthLoc, writeFarDepth ? 1 : 0);
_gl.BindVertexArray(_vao);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_lastVertexCount);
_gl.BindVertexArray(0);
if (enableDepthClamp)
_gl.Disable(EnableCap.DepthClamp);
LastStencilVertexCount = _lastVertexCount;
LastStencilWasFarPunch = writeFarDepth;
LastStencilBuildingId = 0;
}
/// <summary>
/// Steps 1+2 of WB's RenderInsideOut: mark stencil ref=1 wherever
/// portal polygons cover, then write gl_FragDepth=1.0 into those

View file

@ -1,6 +1,6 @@
#version 430 core
//
// Phase A8 portal stencil mark + far-depth punch.
// Phase A8 - portal stencil mark + far-depth punch.
//
// Position is in WORLD space (pipeline transforms cell-local portal
// polygon vertices through cell.WorldTransform on the CPU before
@ -10,10 +10,16 @@ layout(location = 0) in vec3 aPosition;
uniform mat4 uViewProjection;
// Note: no pos.w clamp — coplanar-camera degenerate is accepted per spec.
// If stencil artifacts appear when the camera straddles an exit portal plane,
// re-introduce the clamp from WB's PortalStencil.vert.
void main()
{
gl_Position = uViewProjection * vec4(aPosition, 1.0);
vec4 pos = uViewProjection * vec4(aPosition, 1.0);
// Match WorldBuilder's PortalStencil.vert: keep portal polygons stable
// when the chase camera straddles an exit portal plane. Without this,
// near-zero clip W can explode the screen-space portal mask and let the
// Step 4 terrain pass punch into indoor floor/wall pixels for a frame.
if (abs(pos.w) < 0.001)
pos.w = pos.w < 0.0 ? -0.001 : 0.001;
gl_Position = pos;
}

View file

@ -6,10 +6,9 @@ namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Phase A8 (2026-05-26): a logical building — one or more EnvCells linked
/// via the dat-level <c>LandBlockInfo.Buildings</c> entry. Building shells (cottage
/// walls, inn walls — <c>IsBuildingShell=true</c> entities) render unconditionally
/// when the camera is inside this building's cells. The exit portal polygons
/// are stencil-marked so outdoor visibility leaks through portal silhouettes
/// only.
/// walls, inn walls — <c>IsBuildingShell=true</c> entities) are scoped to this
/// building's cells via their dat-derived anchor. The exit portal polygons are
/// stencil-marked so outdoor visibility leaks through portal silhouettes only.
///
/// <para>Step 5 (cross-building visibility via 3-stencil-bit pipeline) uses
/// the occlusion-query state to skip rendering when the building's portals
@ -39,6 +38,18 @@ public sealed class Building
/// polygon vertices via <see cref="AcDream.App.Rendering.LoadedCell.WorldTransform"/>.</summary>
public required IReadOnlyList<Vector3[]> ExitPortalPolygons { get; init; }
/// <summary>True when <see cref="PortalBounds"/> contains at least one
/// exit-portal vertex. Mirrors WB's <c>BuildingPortalGPU.VertexCount &gt; 0</c>
/// filter before a building participates in outside-in / Step 5 stencil
/// visibility.</summary>
public bool HasPortalBounds { get; init; }
/// <summary>World-space AABB of all exit portal polygons. WB's
/// <c>PortalRenderManager.GetVisibleBuildingPortals</c> frustum-culls this
/// box with near-plane ignored before adding the building to the portal
/// visibility list.</summary>
public WbBoundingBox PortalBounds { get; init; }
// -------------------------------------------------------------------------
// Step 5 occlusion-query state (mutable, per-frame, RR9 scope).
// -------------------------------------------------------------------------

View file

@ -117,6 +117,19 @@ public static class BuildingLoader
}
}
bool hasPortalBounds = false;
var portalMin = new Vector3(float.MaxValue);
var portalMax = new Vector3(float.MinValue);
foreach (var poly in exitPortalPolys)
{
foreach (var v in poly)
{
hasPortalBounds = true;
portalMin = Vector3.Min(portalMin, v);
portalMax = Vector3.Max(portalMax, v);
}
}
// WB PortalService.cs:89: skip buildings with no interior cells.
if (envCellIds.Count == 0) continue;
@ -125,6 +138,10 @@ public static class BuildingLoader
BuildingId = nextId++,
EnvCellIds = envCellIds,
ExitPortalPolygons = exitPortalPolys,
HasPortalBounds = hasPortalBounds,
PortalBounds = hasPortalBounds
? new WbBoundingBox(portalMin, portalMax)
: default,
};
reg.Add(building);

View file

@ -23,7 +23,6 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using DatReaderWriter.Enums;
@ -70,6 +69,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
private int _modernInstanceCapacity = 1024;
private uint _modernBatchBuffer;
private int _modernBatchCapacity = 1024;
// mesh_modern.vert's SSBO InstanceData is only mat4 transform. The CPU
// InstanceData below also carries CellId/Flags for filtering, so upload a
// packed transform array instead of the 80-byte CPU struct.
private Matrix4x4[] _gpuInstanceTransforms = Array.Empty<Matrix4x4>();
// Reusable scratch arrays — avoid per-frame allocation.
// WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>()
@ -193,7 +196,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.GenBuffers(1, out _modernInstanceBuffer);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
// Per-batch data SSBO (binding=1)
_gl.GenBuffers(1, out _modernBatchBuffer);
@ -464,11 +467,30 @@ public sealed unsafe class EnvCellRenderer : IDisposable
/// Call once per frame, before <see cref="Render"/>.
/// Source: WB EnvCellRenderManager.cs:247-373 (verbatim).
/// </summary>
public void PrepareRenderBatches(Matrix4x4 viewProjection, Vector3 cameraPosition, HashSet<uint>? filter = null)
public void PrepareRenderBatches(
Matrix4x4 viewProjection,
Vector3 cameraPosition,
HashSet<uint>? filter = null,
int? centerLbX = null,
int? centerLbY = null,
int? renderRadius = null)
{
// WB EnvCellRenderManager.cs:249-250:
if (!_initialized || cameraPosition.Z > 4000) return;
if (filter is { Count: 0 })
{
lock (_renderLock)
{
_poolIndex = 0;
_activeSnapshot = new EnvCellVisibilitySnapshot();
_activeSnapshotGlobalGroups = new Dictionary<ulong, List<InstanceData>>();
_activeSnapshotGlobalGfxObjIds = new List<ulong>();
NeedsPrepare = false;
}
return;
}
// WB EnvCellRenderManager.cs:251-253:
lock (_renderLock) { _poolIndex = 0; }
@ -479,8 +501,19 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// Filter loaded landblocks by GpuReady + Instances non-empty.
var landblocks = new List<EnvCellLandblock>();
foreach (var lb in _landblocks.Values)
{
if (centerLbX.HasValue && centerLbY.HasValue && renderRadius.HasValue)
{
if (Math.Abs(lb.GridX - centerLbX.Value) > renderRadius.Value ||
Math.Abs(lb.GridY - centerLbY.Value) > renderRadius.Value)
{
continue;
}
}
if (lb.GpuReady && lb.Instances.Count > 0)
landblocks.Add(lb);
}
if (landblocks.Count == 0) return;
// WB EnvCellRenderManager.cs:265-267:
@ -815,32 +848,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BindVertexArray(0);
_currentVao = 0;
// Phase A8 (2026-05-28 visual-gate-#4 follow-up): NO cull-restore
// at exit. The Landblock→None override can leave cull DISABLED
// if the last batch was Landblock — and that's intentional: the
// subsequent `dispatcher.Draw(IndoorPass)` call in
// RenderInsideOutAcdream's Step 3 wants cull-off too, because
// AC's cottage-shell GfxObj parts (the wooden floor planks +
// wall slabs that the player walks on / through) have winding
// that gets back-face-culled by the dispatcher's default
// FrontFace=CCW. Letting cull stay off through IndoorPass
// renders both shell and cell mesh double-sided, so floors are
// visible from above (and inverted-front-facing wall slabs are
// visible from inside the room). Step 4's
// `gl.Enable(EnableCap.CullFace)` (line ~10768) + the cleanup
// block's enable (line ~10870) re-establish cull-back before
// LiveDynamic chars / NPCs / doors render — so those still
// look solid (no see-through head). The static `_currentVao`
// is reset because the next Render call's batch loop needs to
// re-issue BindVertexArray regardless; `_currentCullMode` is
// intentionally left at None so the cache matches actual GL
// state until the next Render call's per-batch SetCullMode
// either confirms or re-sets it.
//
// The retail-faithful long-term move is matching WB's
// glFrontFace(CW) globally (GameScene.cs:843) so cull-back
// selects the correct side for AC's polygon winding without
// double-sided rendering — deferred until a wider audit.
// No cull restore at exit, matching WB's manager pattern: the
// last SetCullMode call reflects actual GL state, and the next
// Render call invalidates `_currentCullMode` before issuing its
// own per-batch state. The Landblock->None override below can
// intentionally leave cull disabled for the following IndoorPass,
// preserving the shipped Gate #5 baseline while deeper evidence is
// gathered.
// Update frame stats for probe emission at the call site.
_lastFrameStats.CellsRendered = filter?.Count ?? snapshot.BatchedByCell.Count;
@ -932,7 +946,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_modernInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
}
// WB BaseObjectRenderManager.cs:761-762: grow scratch arrays.
@ -977,12 +991,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(uniqueInstanceCount * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
var instancesSpan = CollectionsMarshal.AsSpan(allInstances);
fixed (InstanceData* ptr = instancesSpan)
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
if (_gpuInstanceTransforms.Length < uniqueInstanceCount)
Array.Resize(ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount));
for (int i = 0; i < uniqueInstanceCount; i++)
_gpuInstanceTransforms[i] = allInstances[i].Transform;
fixed (Matrix4x4* ptr = _gpuInstanceTransforms)
{
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(uniqueInstanceCount * sizeof(InstanceData)), ptr);
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), ptr);
}
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
@ -1014,23 +1031,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
foreach (var group in batchesByCullMode)
{
var cullMode = (CullMode)(group.Key % 4);
// Phase A8 fix (2026-05-28 visual-gate-#3 evidence): override
// CullMode.Landblock to None for cell-mesh batches. WB sets
// glFrontFace(CW) globally (GameScene.cs:843) so its CullMode
// mapping (Landblock→Back) culls the correct side; we set
// glFrontFace(CCW) in WbDrawDispatcher (line 1056) so the
// mapping would cull the OPPOSITE side, hiding cell floors.
// Cell-mesh polys with CullMode.Landblock represent the floor +
// walls + ceiling of a single room — they face different
// directions but share one CullMode value, so a single cull
// setting can't be correct for all of them. The retail-faithful
// approach is double-sided rendering for cell polys (cull off),
// matching what the cull-disable A/B diagnostic empirically
// confirmed (floor visible with cull off in visual-gate-#3).
// CullMode.Landblock is only ever assigned in this codebase by
// PrepareCellStructMeshData (cell polys) — terrain has its own
// renderer that doesn't go through this code path — so this
// override is scoped exactly right.
// Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock
// uniformly, but the room surfaces need to be visible from inside
// under acdream's current global winding state. Render cell polys
// double-sided while the architectural cause is isolated.
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
if (_currentCullMode != cullMode)
{

View file

@ -1,4 +1,5 @@
using AcDream.Core.Meshing;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering.Wb;
@ -17,4 +18,5 @@ internal readonly record struct GroupKey(
int IndexCount,
ulong BindlessTextureHandle,
uint TextureLayer,
TranslucencyKind Translucency);
TranslucencyKind Translucency,
CullMode CullMode = CullMode.CounterClockwise);

View file

@ -25,9 +25,12 @@ namespace AcDream.App.Rendering.Wb;
/// </para>
///
/// <para>
/// Idempotency: a duplicate load for the same landblock is a no-op on
/// ref-counting (the snapshot is already present). Defensive guard against
/// streaming-controller bugs.
/// Idempotency: repeated notifications for the same landblock only register
/// newly-seen ids. This matters for two-tier streaming: a far-tier terrain
/// load first snapshots an empty entity set, then a later Far-to-Near promotion
/// supplies the actual stabs/buildings. Treating the second notification as a
/// blanket no-op leaves the world-state entity list populated while the WB
/// mesh cache never pins the promoted GfxObj ids.
/// </para>
///
/// <para>
@ -53,18 +56,15 @@ public sealed class LandblockSpawnAdapter
}
/// <summary>
/// Called when a landblock finishes streaming in.
/// Registers a ref-count increment with WB for each unique atlas-tier
/// GfxObj id in the landblock. Duplicate loads for the same landblock id
/// are silently ignored.
/// Called when a landblock finishes streaming in or receives promoted
/// atlas-tier entities. Registers a ref-count increment with WB for each
/// unique atlas-tier GfxObj id that has not already been registered for
/// this landblock.
/// </summary>
public void OnLandblockLoaded(LoadedLandblock landblock)
{
System.ArgumentNullException.ThrowIfNull(landblock);
// Idempotency: already-loaded landblock is a no-op.
if (_idsByLandblock.ContainsKey(landblock.LandblockId)) return;
var unique = new HashSet<ulong>();
foreach (var entity in landblock.Entities)
{
@ -76,8 +76,18 @@ public sealed class LandblockSpawnAdapter
unique.Add((ulong)meshRef.GfxObjId);
}
_idsByLandblock[landblock.LandblockId] = unique;
foreach (var id in unique) _adapter.IncrementRefCount(id);
if (!_idsByLandblock.TryGetValue(landblock.LandblockId, out var registered))
{
_idsByLandblock[landblock.LandblockId] = unique;
foreach (var id in unique) _adapter.IncrementRefCount(id);
return;
}
foreach (var id in unique)
{
if (registered.Add(id))
_adapter.IncrementRefCount(id);
}
}
/// <summary>

View file

@ -6,6 +6,7 @@ using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Wb;
@ -76,18 +77,22 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// <summary>Cell mesh + cell statics (<see cref="WorldEntity.ParentCellId"/>
/// non-null) PLUS building shell stabs (<see cref="WorldEntity.IsBuildingShell"/>
/// true, regardless of ParentCellId). These render unconditionally
/// when the camera is inside their building — building shells ARE
/// the indoor walls. Live-dynamic (<c>ServerGuid != 0</c>) is
/// excluded; it flows through <see cref="LiveDynamic"/>.</summary>
/// true) whose <see cref="WorldEntity.BuildingShellAnchorCellId"/>
/// belongs to the active building cell set. Live-dynamic
/// (<c>ServerGuid != 0</c>) is excluded; it flows through
/// <see cref="LiveDynamic"/>.</summary>
IndoorPass,
/// <summary>Outdoor scenery stabs (<c>ParentCellId == null</c>,
/// <c>!IsBuildingShell</c>) plus procedurally-generated scenery.
/// Drawn stencil-gated to portal silhouettes when the camera is
/// inside. Live-dynamic excluded.</summary>
/// <summary>Outdoor/top-level stabs (<c>ParentCellId == null</c>),
/// including building shells. Drawn stencil-gated to portal
/// silhouettes when the camera is inside. Live-dynamic excluded.</summary>
OutdoorScenery,
/// <summary>Top-level building shell stabs only, optionally scoped by
/// <see cref="WorldEntity.BuildingShellAnchorCellId"/>. Used for
/// portal depth repair without walking the full outdoor scenery set.</summary>
BuildingShells,
/// <summary>Server-spawned dynamic entities (<c>ServerGuid != 0</c>):
/// player, NPCs, monsters, dropped items, animated and idle doors.
/// Drawn last with stencil disabled so they're depth-tested against
@ -103,6 +108,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly BindlessSupport _bindless;
public readonly record struct DrawStats(
EntitySet Set,
int EntitiesWalked,
int MeshRefs,
int Instances,
int Draws,
int CullRuns,
int OpaqueDraws,
int TransparentDraws,
long Triangles);
public DrawStats LastDrawStats { get; private set; }
// Tier 1 cache (#53): per-entity classification results for static
// entities (those NOT in GameWindow._animatedEntities). Wired here in
// Task 7 for plumbing only — Tasks 9-10 wire the per-entity
@ -132,6 +150,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private float[] _instanceData = new float[256 * 16]; // mat4 floats per instance
private BatchData[] _batchData = new BatchData[256];
private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[256];
private CullMode[] _drawCullModes = new CullMode[256];
private int _opaqueDrawCount;
private int _transparentDrawCount;
@ -283,6 +302,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
public struct WalkResult
{
public int EntitiesWalked;
public int BuildingShellAnchorPass;
public int BuildingShellAnchorReject;
public List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> ToDraw;
}
@ -375,8 +396,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Phase A8: EntitySet partition for indoor/outdoor split passes.
if (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
bool shellScoped = IsShellScopedSet(set)
&& entity.IsBuildingShell
&& visibleCellIds is not null;
if (!EntityPassesVisibleCellGate(entity, visibleCellIds, set))
{
if (shellScoped) result.BuildingShellAnchorReject++;
continue;
}
if (shellScoped) result.BuildingShellAnchorPass++;
result.EntitiesWalked++;
for (int i = 0; i < entity.MeshRefs.Count; i++)
scratch.Add((entity, i, entry.LandblockId));
@ -397,11 +425,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
bool isCellEntity = indoorProbeState is not null
&& RenderingDiagnostics.IsEnvCellId(cellProbeId);
bool cellInVis = !(entity.ParentCellId.HasValue
&& visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
bool shellScoped = IsShellScopedSet(set)
&& entity.IsBuildingShell
&& visibleCellIds is not null;
bool cellInVis = EntityPassesVisibleCellGate(entity, visibleCellIds, set);
if (!cellInVis)
{
if (shellScoped) result.BuildingShellAnchorReject++;
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
&& indoorProbeState!.ShouldEmit(cellProbeId))
{
@ -412,6 +442,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
continue;
}
if (shellScoped) result.BuildingShellAnchorPass++;
// Per-entity AABB frustum cull (perf #3). Animated entities bypass —
// they're tracked at landblock level + need per-frame work regardless.
@ -545,6 +576,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
probeState,
set);
if (set == EntitySet.IndoorPass && RenderingDiagnostics.ProbeVisibilityEnabled)
{
Console.WriteLine(
$"[indoor-shells] anchorPass={walkResult.BuildingShellAnchorPass} " +
$"anchorReject={walkResult.BuildingShellAnchorReject} walked={walkResult.EntitiesWalked}");
}
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
// a given entity are contiguous. We accumulate ALL of an entity's
@ -855,6 +893,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Nothing visible — skip the GL pass entirely.
if (anyVao == 0)
{
LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0);
_cpuStopwatch.Stop();
if (diag) MaybeFlushDiag();
return;
@ -865,6 +904,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count;
if (totalInstances == 0)
{
LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0);
_cpuStopwatch.Stop();
if (diag) MaybeFlushDiag();
return;
@ -905,11 +945,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_translucentDraws.Add(grp);
}
// Front-to-back sort for opaque pass: nearer groups draw first so the
// depth test rejects fragments hidden behind them, reducing fragment
// shader cost from overdraw on dense scenes (Holtburg courtyard,
// Foundry interior).
_opaqueDraws.Sort(static (a, b) => a.SortDistance.CompareTo(b.SortDistance));
// Front-to-back sort within each cull mode. DrawIndirectRange must
// split MDI calls whenever CullMode changes because GL state is not
// part of an indirect command. Sorting by distance alone can turn a
// stable 1k-draw live scene into hundreds of tiny MDI runs after a
// landblock transition, which shows up as a GPU-command bottleneck
// without a triangle-count spike.
_opaqueDraws.Sort(CompareOpaqueSubmissionOrder);
_translucentDraws.Sort(CompareTransparentSubmissionOrder);
// ── Phase 4: build IndirectGroupInput list (opaque sorted, then translucent),
// fill via BuildIndirectArrays ──────────────────────────────────
@ -918,6 +961,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_batchData = new BatchData[totalDraws + 64];
if (_indirectCommands.Length < totalDraws)
_indirectCommands = new DrawElementsIndirectCommand[totalDraws + 64];
if (_drawCullModes.Length < totalDraws)
_drawCullModes = new CullMode[totalDraws + 64];
var groupInputs = new List<IndirectGroupInput>(totalDraws);
foreach (var g in _opaqueDraws) groupInputs.Add(ToInput(g));
@ -926,7 +971,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Cast _batchData (private BatchData) to public-mirror BatchDataPublic for BuildIndirectArrays.
// Layout is asserted at test time (BatchDataPublic_LayoutMatchesPrivateBatchData test).
var batchPublic = new BatchDataPublic[totalDraws];
var layout = BuildIndirectArrays(groupInputs, _indirectCommands, batchPublic);
var layout = BuildIndirectArrays(groupInputs, _indirectCommands, batchPublic, _drawCullModes);
long totalTriangles = 0;
foreach (var input in groupInputs)
totalTriangles += (long)(input.IndexCount / 3) * input.InstanceCount;
int cullRuns =
CountCullRuns(_drawCullModes, 0, layout.OpaqueCount) +
CountCullRuns(_drawCullModes, layout.OpaqueCount, layout.TransparentCount);
// Copy back into _batchData
for (int i = 0; i < totalDraws; i++)
@ -941,6 +992,16 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_opaqueDrawCount = layout.OpaqueCount;
_transparentDrawCount = layout.TransparentCount;
_transparentByteOffset = layout.TransparentByteOffset;
LastDrawStats = new DrawStats(
set,
walkResult.EntitiesWalked,
_walkScratch.Count,
totalInstances,
totalDraws,
cullRuns,
_opaqueDrawCount,
_transparentDrawCount,
totalTriangles);
// ── Phase 5: upload three buffers ───────────────────────────────────
fixed (float* ip = _instanceData)
@ -1007,12 +1068,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_shader.SetInt("uDrawIDOffset", 0);
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque[gpuQuerySlot]);
_gl.MultiDrawElementsIndirect(
PrimitiveType.Triangles,
DrawElementsType.UnsignedShort,
(void*)0,
(uint)_opaqueDrawCount,
(uint)DrawCommandStride);
DrawIndirectRange(0, _opaqueDrawCount);
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
if (AlphaToCoverage) _gl.Disable(EnableCap.SampleAlphaToCoverage);
}
@ -1030,38 +1086,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// flickers to whatever opaque batch sorted first that frame. See
// uDrawIDOffset comment in mesh_modern.vert.
_shader.SetInt("uDrawIDOffset", _opaqueDrawCount);
// Phase Post-A.5 (ISSUE #52, 2026-05-10): re-establish Phase 9.2's
// back-face cull setup. The legacy StaticMeshRenderer had this
// (commit 6f1971a, 2026-04-11) until the N.5 retirement amendment
// (commit dcae2b6, 2026-05-08) deleted that renderer; the new
// WbDrawDispatcher never inherited the cull-face state.
//
// Closed-shell translucent meshes — lifestone crystal, glow gems,
// any convex blended mesh — NEED back-face culling in the
// translucent pass. Without it, back faces composite OVER front
// faces in arbitrary iteration order, because DepthMask(false)
// means nothing records depth within the translucent set. The
// result is the user-visible "one face missing, see into the
// hollow interior" + frame-to-frame color flicker as rotation
// shifts the triangle order.
//
// Our fan triangulation emits pos-side polygons as (0, i, i+1) —
// CCW in standard OpenGL conventions — so GL_BACK + CCW-front is
// the correct state. Matches WorldBuilder's per-batch CullMode
// handling. Neg-side polygons (rare on translucent AC content)
// use reversed winding and get culled here, matching the opaque
// pass and the original Phase 9.2 fix's known limitation.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
// Closed-shell translucent meshes still need culling, but the
// cull side must come from each dat batch just like the opaque
// section. BuildIndirectArrays preserves CullMode in _drawCullModes.
_gl.FrontFace(FrontFaceDirection.CW);
_shader.SetInt("uRenderPass", 1);
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryTransparent[gpuQuerySlot]);
_gl.MultiDrawElementsIndirect(
PrimitiveType.Triangles,
DrawElementsType.UnsignedShort,
(void*)_transparentByteOffset,
(uint)_transparentDrawCount,
(uint)DrawCommandStride);
DrawIndirectRange(_opaqueDrawCount, _transparentDrawCount);
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
@ -1132,7 +1163,91 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
FirstInstance: g.FirstInstance,
TextureHandle: g.BindlessTextureHandle,
TextureLayer: g.TextureLayer,
Translucency: g.Translucency);
Translucency: g.Translucency,
CullMode: g.CullMode);
private static int CompareOpaqueSubmissionOrder(InstanceGroup a, InstanceGroup b)
{
int cull = a.CullMode.CompareTo(b.CullMode);
return cull != 0 ? cull : a.SortDistance.CompareTo(b.SortDistance);
}
private static int CompareTransparentSubmissionOrder(InstanceGroup a, InstanceGroup b)
{
int cull = a.CullMode.CompareTo(b.CullMode);
return cull != 0 ? cull : b.SortDistance.CompareTo(a.SortDistance);
}
private static int CountCullRuns(CullMode[] modes, int startCommand, int commandCount)
{
if (commandCount <= 0) return 0;
int end = startCommand + commandCount;
int runs = 1;
var previous = modes[startCommand];
for (int i = startCommand + 1; i < end; i++)
{
var current = modes[i];
if (current == previous) continue;
runs++;
previous = current;
}
return runs;
}
private unsafe void DrawIndirectRange(int startCommand, int commandCount)
{
int end = startCommand + commandCount;
int command = startCommand;
while (command < end)
{
var cullMode = _drawCullModes[command];
ApplyCullMode(cullMode);
int runCount = 1;
while (command + runCount < end && _drawCullModes[command + runCount] == cullMode)
runCount++;
// Each glMultiDrawElementsIndirect call restarts gl_DrawID at 0.
// Because this method splits one logical opaque/transparent pass
// into CullMode runs, the shader must receive the absolute command
// index for this run or it will read BatchData[0] again and bind
// the wrong texture for later runs.
_shader.SetInt("uDrawIDOffset", command);
_gl.MultiDrawElementsIndirect(
PrimitiveType.Triangles,
DrawElementsType.UnsignedShort,
(void*)(command * DrawCommandStride),
(uint)runCount,
(uint)DrawCommandStride);
command += runCount;
}
}
private void ApplyCullMode(CullMode mode)
{
// WB BaseObjectRenderManager.cs:850-866 applies CullMode per MDI group.
// WB GameScene.cs:843 sets FrontFace(CW) globally; SetCullMode then
// only chooses front/back culling. Keep the same convention here so
// splitting MDI commands by CullMode cannot resurrect stale CCW state.
_gl.FrontFace(FrontFaceDirection.CW);
switch (mode)
{
case CullMode.None:
_gl.Disable(EnableCap.CullFace);
break;
case CullMode.Clockwise:
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Front);
break;
case CullMode.CounterClockwise:
case CullMode.Landblock:
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
break;
}
}
private unsafe void UploadSsbo(uint ssbo, uint binding, void* data, int byteCount)
{
@ -1293,6 +1408,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
BindlessTextureHandle = key.BindlessTextureHandle,
TextureLayer = key.TextureLayer,
Translucency = key.Translucency,
CullMode = key.CullMode,
};
_groups[key] = grp;
}
@ -1335,7 +1451,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
var key = new GroupKey(
batch.IBO, batch.FirstIndex, (int)batch.BaseVertex,
batch.IndexCount, texHandle, texLayer, translucency);
batch.IndexCount, texHandle, texLayer, translucency, batch.CullMode);
if (!_groups.TryGetValue(key, out var grp))
{
@ -1348,6 +1464,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
BindlessTextureHandle = texHandle,
TextureLayer = texLayer,
Translucency = translucency,
CullMode = batch.CullMode,
};
_groups[key] = grp;
}
@ -1403,7 +1520,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
bool isIndoor = entity.ParentCellId.HasValue || entity.IsBuildingShell;
if (set == EntitySet.IndoorPass) return isIndoor;
if (set == EntitySet.OutdoorScenery) return !isIndoor;
if (set == EntitySet.OutdoorScenery) return !entity.ParentCellId.HasValue;
if (set == EntitySet.BuildingShells) return entity.IsBuildingShell;
throw new InvalidOperationException($"Unhandled EntitySet value: {set}");
}
@ -1425,10 +1543,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
bool cellInVis = !(entity.ParentCellId.HasValue
&& visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
if (!cellInVis) continue;
if (!EntityPassesVisibleCellGate(entity, visibleCellIds, set)) continue;
output.Add(entity.Id);
}
@ -1441,8 +1556,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// cells (instead of the visibility-derived set).
///
/// <para>Indoor entities (ParentCellId set) gated by membership in
/// <paramref name="cellIds"/>. Building shells (IsBuildingShell) pass
/// unconditionally when <paramref name="set"/> == IndoorPass. Outdoor
/// <paramref name="cellIds"/>. Building shells are gated by
/// BuildingShellAnchorCellId membership in the same cell set. Outdoor
/// scenery is excluded by the EntitySet partition (no cell-list gate
/// needed — EntityMatchesSet handles it).</para>
/// </summary>
@ -1458,11 +1573,40 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && !cellIds.Contains(entity.ParentCellId.Value))
continue;
if (IsShellScopedSet(set) && entity.IsBuildingShell)
{
if (entity.BuildingShellAnchorCellId is not uint anchorCellId ||
!cellIds.Contains(anchorCellId))
continue;
}
result.Add(entity.Id);
}
return result;
}
private static bool EntityPassesVisibleCellGate(
WorldEntity entity,
HashSet<uint>? visibleCellIds,
EntitySet set)
{
if (visibleCellIds is null)
return true;
if (entity.ParentCellId.HasValue)
return visibleCellIds.Contains(entity.ParentCellId.Value);
if (IsShellScopedSet(set) && entity.IsBuildingShell)
{
return entity.BuildingShellAnchorCellId is uint anchorCellId
&& visibleCellIds.Contains(anchorCellId);
}
return true;
}
private static bool IsShellScopedSet(EntitySet set) =>
set == EntitySet.IndoorPass || set == EntitySet.BuildingShells;
public void Dispose()
{
if (_disposed) return;
@ -1503,7 +1647,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
int FirstInstance,
ulong TextureHandle,
uint TextureLayer,
TranslucencyKind Translucency);
TranslucencyKind Translucency,
CullMode CullMode = CullMode.CounterClockwise);
/// <summary>
/// Public mirror of the per-group <see cref="BatchData"/> uploaded to the SSBO.
@ -1535,7 +1680,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
public static IndirectLayoutResult BuildIndirectArrays(
IReadOnlyList<IndirectGroupInput> groups,
DrawElementsIndirectCommand[] indirectScratch,
BatchDataPublic[] batchScratch)
BatchDataPublic[] batchScratch,
CullMode[]? cullScratch = null)
{
int opaqueCount = 0;
int transparentCount = 0;
@ -1570,12 +1716,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{
indirectScratch[oi] = dec;
batchScratch[oi] = bd;
if (cullScratch is not null) cullScratch[oi] = g.CullMode;
oi++;
}
else
{
indirectScratch[ti] = dec;
batchScratch[ti] = bd;
if (cullScratch is not null) cullScratch[ti] = g.CullMode;
ti++;
}
}
@ -1639,6 +1787,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
public ulong BindlessTextureHandle; // 64-bit (was uint TextureHandle in N.4)
public uint TextureLayer; // 0 for per-instance composites; non-zero when WB atlas is adopted in N.6+
public TranslucencyKind Translucency;
public CullMode CullMode;
public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes)
public int InstanceCount;
public float SortDistance; // squared distance from camera to first instance, for opaque sort

View file

@ -38,6 +38,13 @@ public sealed record RuntimeOptions(
int HidePartIndex,
bool RetailCloseDegrades,
bool DumpSceneryZ,
bool DumpLiveSpawns,
bool A8DiagDisableInsideStep4Terrain,
bool A8DiagDisableInsideStep4Outdoor,
bool A8DiagDisableInsideStep3EnvCellOpaque,
bool A8DiagDisableInsideStep3IndoorPass,
bool A8DiagDisableInsideStep2Punch,
bool A8DiagDisablePortalDepthClamp,
int? LegacyStreamRadius)
{
/// <summary>
@ -77,6 +84,19 @@ public sealed record RuntimeOptions(
// only for before/after diagnostic comparisons.
RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal),
DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")),
DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")),
A8DiagDisableInsideStep4Terrain:
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_TERRAIN")),
A8DiagDisableInsideStep4Outdoor:
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_OUTDOOR")),
A8DiagDisableInsideStep3EnvCellOpaque:
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_ENVCELL_OPAQUE")),
A8DiagDisableInsideStep3IndoorPass:
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_INDOORPASS")),
A8DiagDisableInsideStep2Punch:
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP2_PUNCH")),
A8DiagDisablePortalDepthClamp:
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_PORTAL_DEPTH_CLAMP")),
// Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
// top of the quality preset's radii. Null when unset or invalid.
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")));

View file

@ -137,21 +137,55 @@ public sealed class GpuWorldState
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
=> EnumerateLandblockEntries(includeAnimatedIndex: true);
/// <summary>
/// Per-landblock render entries without the animated lookup dictionary.
/// Static render passes use this to avoid rebuilding an index they cannot
/// consume.
/// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntriesWithoutAnimatedIndex
=> EnumerateLandblockEntries(includeAnimatedIndex: false);
/// <summary>
/// Lightweight bounds-only enumeration for overlays and diagnostics.
/// Does not walk entity lists.
/// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds
{
get
{
foreach (var kvp in _loaded)
{
// Build AnimatedById on the fly — cheap (~132 entities/LB max).
var byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
yield return (kvp.Key, aabb.Min, aabb.Max);
else
yield return (kvp.Key, Vector3.Zero, Vector3.Zero);
}
}
}
private IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> EnumerateLandblockEntries(
bool includeAnimatedIndex)
{
foreach (var kvp in _loaded)
{
Dictionary<uint, WorldEntity>? byId = null;
if (includeAnimatedIndex)
{
byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
foreach (var e in kvp.Value.Entities)
byId[e.Id] = e;
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
else
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
}
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
else
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
}
}

View file

@ -37,15 +37,21 @@ public abstract record LandblockStreamResult(uint LandblockId)
) : LandblockStreamResult(LandblockId);
/// <summary>
/// A previously-Far-resident landblock was promoted to Near. Terrain
/// mesh is already on the GPU; the result carries the entity layer
/// (stabs, buildings, scenery) to merge into the existing GpuWorldState
/// entry.
/// A previously-Far-resident landblock was promoted to Near. The result
/// carries the full near landblock plus mesh data so the render thread can
/// run the same near-tier side effects as a fresh LoadNear: cell visibility,
/// building registries, EnvCell finalization, lighting, and static collision.
/// GpuWorldState still merges only the entity layer so live entities already
/// attached to the landblock are preserved.
/// </summary>
public sealed record Promoted(
uint LandblockId,
IReadOnlyList<WorldEntity> Entities
) : LandblockStreamResult(LandblockId);
LoadedLandblock Landblock,
LandblockMeshData MeshData
) : LandblockStreamResult(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);

View file

@ -159,6 +159,9 @@ public sealed class LandblockStreamer : IDisposable
private void WorkerLoop()
{
var highPriority = new Queue<LandblockStreamJob>();
var lowPriority = new Queue<LandblockStreamJob>();
try
{
// Safe to block: this is a dedicated worker thread with no
@ -169,14 +172,24 @@ public sealed class LandblockStreamer : IDisposable
// simple thread-start shape.
while (!_cancel.Token.IsCancellationRequested)
{
if (!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
if (highPriority.Count == 0 &&
lowPriority.Count == 0 &&
!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
{
break;
}
while (_inbox.Reader.TryRead(out var job))
{
if (_cancel.Token.IsCancellationRequested) return;
HandleJob(job);
}
EnqueuePrioritized(job, highPriority, lowPriority);
if (highPriority.Count == 0 && lowPriority.Count == 0)
continue;
if (_cancel.Token.IsCancellationRequested) return;
var next = highPriority.Count > 0
? highPriority.Dequeue()
: lowPriority.Dequeue();
HandleJob(next);
}
}
catch (OperationCanceledException) { /* graceful shutdown */ }
@ -192,6 +205,55 @@ public sealed class LandblockStreamer : IDisposable
}
}
private static void EnqueuePrioritized(
LandblockStreamJob job,
Queue<LandblockStreamJob> highPriority,
Queue<LandblockStreamJob> lowPriority)
{
if (job is LandblockStreamJob.Load
{
Kind: LandblockStreamJobKind.LoadNear or LandblockStreamJobKind.PromoteToNear
} high)
{
// Near-tier jobs are visible-content critical. They supersede an
// older queued LoadFar for the same landblock: LoadNear obviously
// loads everything, and PromoteToNear now carries mesh data so the
// render thread can run the full near-tier apply side effects. If a
// LoadFar is already being processed, the single worker naturally
// finishes it before the promotion is dequeued.
RemoveLowPriorityJobsForLandblock(
lowPriority,
high.LandblockId,
removeLoadFar: true,
removeUnload: true);
highPriority.Enqueue(job);
return;
}
lowPriority.Enqueue(job);
}
private static void RemoveLowPriorityJobsForLandblock(
Queue<LandblockStreamJob> queue,
uint landblockId,
bool removeLoadFar,
bool removeUnload)
{
int count = queue.Count;
for (int i = 0; i < count; i++)
{
var job = queue.Dequeue();
bool remove = job.LandblockId == landblockId && job switch
{
LandblockStreamJob.Load { Kind: LandblockStreamJobKind.LoadFar } => removeLoadFar,
LandblockStreamJob.Unload => removeUnload,
_ => false
};
if (!remove)
queue.Enqueue(job);
}
}
private void HandleJob(LandblockStreamJob job)
{
switch (job)
@ -212,6 +274,19 @@ public sealed class LandblockStreamer : IDisposable
load.LandblockId, "LandblockLoader.Load returned null"));
break;
}
if (load.Kind == LandblockStreamJobKind.PromoteToNear)
{
var promotedMesh = _buildMeshOrNull(load.LandblockId, lb);
if (promotedMesh is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "buildMeshOrNull returned null"));
break;
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
load.LandblockId, lb, promotedMesh));
break;
}
var mesh = _buildMeshOrNull(load.LandblockId, lb);
if (mesh is null)
{

View file

@ -103,16 +103,16 @@ public sealed class StreamingController
{
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
var bootstrap = _region.ComputeFirstTickDiff();
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.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
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);
}
@ -129,6 +129,7 @@ public sealed class StreamingController
_state.AddLandblock(loaded.Landblock);
break;
case LandblockStreamResult.Promoted promoted:
_applyTerrain(promoted.Landblock, promoted.MeshData);
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
break;
case LandblockStreamResult.Unloaded unloaded:

View file

@ -1,5 +1,6 @@
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.World;
@ -83,6 +84,7 @@ public static class LandblockLoader
Rotation = building.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(),
IsBuildingShell = true, // Phase A8: tag at source array boundary
BuildingShellAnchorCellId = FirstBuildingAnchorCellId(building, landblockId),
};
buildingEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction
result.Add(buildingEntity);
@ -96,4 +98,20 @@ public static class LandblockLoader
var type = id & TypeMask;
return type == GfxObjMask || type == SetupMask;
}
private static uint? FirstBuildingAnchorCellId(BuildingInfo building, uint landblockId)
{
if (landblockId == 0)
return null;
uint lbPrefix = landblockId & 0xFFFF0000u;
foreach (var portal in building.Portals)
{
if (portal.OtherCellId == 0xFFFF)
continue;
return lbPrefix | (uint)portal.OtherCellId;
}
return null;
}
}

View file

@ -56,13 +56,24 @@ public sealed class WorldEntity
///
/// <para>
/// Read at draw time by <c>WbDrawDispatcher</c>'s <c>IndoorPass</c>
/// partition so building shells render unconditionally when the camera
/// is inside their building (they ARE the indoor walls), not stencil-gated
/// as outdoor scenery would be.
/// partition so building shells can render when the camera is inside their
/// own building (they ARE the indoor walls), not stencil-gated as outdoor
/// scenery would be.
/// </para>
/// </summary>
public bool IsBuildingShell { get; init; }
/// <summary>
/// Dat-derived EnvCell anchor for a building shell. Building shells are
/// top-level landblock stabs, so they do not have a real ParentCellId, but
/// the LandBlockInfo.Buildings[] portal list names cells owned by the same
/// building. The indoor renderer uses this anchor only for draw scoping:
/// a shell renders in IndoorPass when its anchor belongs to the camera
/// building's EnvCell set. Collision still treats the shell as an outdoor
/// stab unless ParentCellId is explicitly set.
/// </summary>
public uint? BuildingShellAnchorCellId { get; init; }
/// <summary>
/// Uniform scale applied to this entity's mesh by the scenery pipeline.
/// For scenery objects this is spawn.Scale (typically 0.81.3). For stabs

View file

@ -61,6 +61,87 @@ public class IndoorCellStencilPipelineTests
Assert.Equal(new Vector3(1, 1, 0), verts[2]);
}
[Fact]
public void BuildTriangles_OnlyIncludesProvidedVisibleCells()
{
// The render path now feeds BuildTriangles from the portal traversal's
// visible cells, not every cell in the building. A hidden room's exit
// portal must not punch outdoor terrain into the current view.
var visibleInnerCell = new LoadedCell
{
WorldTransform = Matrix4x4.Identity,
Portals = new() { new CellPortalInfo(0x0102, 100, 0) },
ClipPlanes = new() { default },
PortalPolygons = new()
{
new[]
{
new Vector3(0, 0, 0),
new Vector3(1, 0, 0),
new Vector3(1, 1, 0),
},
},
};
var hiddenExitCell = new LoadedCell
{
WorldTransform = Matrix4x4.Identity,
Portals = new() { new CellPortalInfo(0xFFFF, 101, 0) },
ClipPlanes = new() { default },
PortalPolygons = new()
{
new[]
{
new Vector3(10, 0, 0),
new Vector3(11, 0, 0),
new Vector3(11, 1, 0),
},
},
};
var visibleOnly = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { visibleInnerCell });
var allBuildingCells = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { visibleInnerCell, hiddenExitCell });
Assert.Empty(visibleOnly);
Assert.Equal(3, allBuildingCells.Length);
Assert.Equal(new Vector3(10, 0, 0), allBuildingCells[0]);
}
[Fact]
public void BuildTriangles_CameraSideFilterSkipsExitPortalsBehindCamera()
{
var cell = new LoadedCell
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Portals = new() { new CellPortalInfo(0xFFFF, 100, 0) },
ClipPlanes = new()
{
new PortalClipPlane
{
Normal = Vector3.UnitX,
D = 0f,
InsideSide = 0,
},
},
PortalPolygons = new()
{
new[]
{
new Vector3(0, 0, 0),
new Vector3(0, 1, 0),
new Vector3(0, 0, 1),
},
},
};
var visible = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { cell }, new Vector3(1, 0, 0));
var rejected = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { cell }, new Vector3(-1, 0, 0));
Assert.Equal(3, visible.Length);
Assert.Empty(rejected);
}
[Fact]
public void BuildTriangles_TriangulatesAsFan()
{

View file

@ -127,4 +127,43 @@ public class BuildingLoaderTests
Assert.Equal(b.BuildingId, cell150.BuildingId);
Assert.Equal(b.BuildingId, cell151.BuildingId);
}
[Fact]
public void Build_ComputesExitPortalBounds()
{
var cell150 = new AcDream.App.Rendering.LoadedCell
{
CellId = 0xA9B40150u,
Portals = new List<AcDream.App.Rendering.CellPortalInfo>
{
new(0xFFFF, 0, 0),
},
PortalPolygons = new List<Vector3[]>
{
new[]
{
new Vector3(-1, 2, 3),
new Vector3(4, 5, 6),
new Vector3(7, -8, 9),
},
},
WorldTransform = Matrix4x4.CreateTranslation(10, 20, 30),
InverseWorldTransform = Matrix4x4.Identity,
LocalBoundsMin = new Vector3(-5, -5, -5),
LocalBoundsMax = new Vector3(5, 5, 5),
ClipPlanes = new List<AcDream.App.Rendering.PortalClipPlane>(),
};
var info = MakeInfo((0x02000123u, new[] { 0x0150u }));
var reg = BuildingLoader.Build(info, 0xA9B40000u,
new Dictionary<uint, AcDream.App.Rendering.LoadedCell>
{
{ 0xA9B40150u, cell150 },
});
var b = System.Linq.Enumerable.First(reg.All());
Assert.True(b.HasPortalBounds);
Assert.Equal(new Vector3(9, 12, 33), b.PortalBounds.Min);
Assert.Equal(new Vector3(17, 25, 39), b.PortalBounds.Max);
}
}

View file

@ -4,6 +4,8 @@
// GL context and are visual-verified at the render frame (Task 10).
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Wb;
using Xunit;
@ -114,6 +116,22 @@ public class EnvCellRendererTests
// (Render() requires a GL context — visual-verified in Task 10.)
[Fact]
public void GpuInstanceUpload_UsesMeshModernMat4Stride()
{
// mesh_modern.vert declares SSBO InstanceData as exactly one mat4,
// so the GPU array stride is 64 bytes. EnvCellRenderer's CPU
// InstanceData also carries CellId/Flags for culling/filtering and
// is 80 bytes; uploading that struct corrupts every instance after 0.
Assert.Equal(64, Marshal.SizeOf<Matrix4x4>());
Assert.Equal(80, Marshal.SizeOf<InstanceData>());
var field = typeof(EnvCellRenderer).GetField("_gpuInstanceTransforms",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(field);
Assert.Equal(typeof(Matrix4x4[]), field!.FieldType);
}
// -----------------------------------------------------------------------
// Pool-aliasing regression tests (2026-05-28 audit findings).
//

View file

@ -37,6 +37,12 @@ public sealed class RuntimeOptionsTests
// Default-on: RetailCloseDegrades is true unless explicitly disabled.
Assert.True(opts.RetailCloseDegrades);
Assert.False(opts.DumpSceneryZ);
Assert.False(opts.A8DiagDisableInsideStep4Terrain);
Assert.False(opts.A8DiagDisableInsideStep4Outdoor);
Assert.False(opts.A8DiagDisableInsideStep3EnvCellOpaque);
Assert.False(opts.A8DiagDisableInsideStep3IndoorPass);
Assert.False(opts.A8DiagDisableInsideStep2Punch);
Assert.False(opts.A8DiagDisablePortalDepthClamp);
Assert.Null(opts.LegacyStreamRadius);
Assert.False(opts.HasLiveCredentials);
}
@ -184,12 +190,24 @@ public sealed class RuntimeOptionsTests
["ACDREAM_NO_AUDIO"] = "1",
["ACDREAM_ENABLE_SKY_PES"] = "1",
["ACDREAM_DUMP_SCENERY_Z"] = "1",
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_TERRAIN"] = "1",
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_OUTDOOR"] = "1",
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_ENVCELL_OPAQUE"] = "1",
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_INDOORPASS"] = "1",
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP2_PUNCH"] = "1",
["ACDREAM_A8_DIAG_DISABLE_PORTAL_DEPTH_CLAMP"] = "1",
}));
Assert.True(allOn.DevTools);
Assert.True(allOn.DumpMoveTruth);
Assert.True(allOn.NoAudio);
Assert.True(allOn.EnableSkyPesDebug);
Assert.True(allOn.DumpSceneryZ);
Assert.True(allOn.A8DiagDisableInsideStep4Terrain);
Assert.True(allOn.A8DiagDisableInsideStep4Outdoor);
Assert.True(allOn.A8DiagDisableInsideStep3EnvCellOpaque);
Assert.True(allOn.A8DiagDisableInsideStep3IndoorPass);
Assert.True(allOn.A8DiagDisableInsideStep2Punch);
Assert.True(allOn.A8DiagDisablePortalDepthClamp);
// Any non-"1" value leaves them off, matching the
// string.Equals(env, "1", StringComparison.Ordinal) check.
@ -200,12 +218,24 @@ public sealed class RuntimeOptionsTests
["ACDREAM_NO_AUDIO"] = "2",
["ACDREAM_ENABLE_SKY_PES"] = "on",
["ACDREAM_DUMP_SCENERY_Z"] = " 1",
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_TERRAIN"] = "true",
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_OUTDOOR"] = "yes",
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_ENVCELL_OPAQUE"] = "2",
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_INDOORPASS"] = " 1",
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP2_PUNCH"] = "true",
["ACDREAM_A8_DIAG_DISABLE_PORTAL_DEPTH_CLAMP"] = "yes",
}));
Assert.False(anyOther.DevTools);
Assert.False(anyOther.DumpMoveTruth);
Assert.False(anyOther.NoAudio);
Assert.False(anyOther.EnableSkyPesDebug);
Assert.False(anyOther.DumpSceneryZ);
Assert.False(anyOther.A8DiagDisableInsideStep4Terrain);
Assert.False(anyOther.A8DiagDisableInsideStep4Outdoor);
Assert.False(anyOther.A8DiagDisableInsideStep3EnvCellOpaque);
Assert.False(anyOther.A8DiagDisableInsideStep3IndoorPass);
Assert.False(anyOther.A8DiagDisableInsideStep2Punch);
Assert.False(anyOther.A8DiagDisablePortalDepthClamp);
}
[Fact]

View file

@ -101,8 +101,7 @@ public sealed class LandblockSpawnAdapterTests
public void OnLandblockLoaded_SameLandblockTwice_DedupesAtTheLandblockLevel()
{
// If a landblock load fires twice (e.g. a streaming-controller bug),
// we should not double-register. Second load is treated as a no-op
// for ref-counting purposes.
// we should not double-register ids that were already seen.
var captured = new CapturingAdapterMock();
var adapter = new LandblockSpawnAdapter(captured);
@ -118,6 +117,49 @@ public sealed class LandblockSpawnAdapterTests
Assert.Single(captured.IncrementCalls);
}
[Fact]
public void OnLandblockLoaded_FarEmptyThenNearPromotion_RegistersPromotedIds()
{
var captured = new CapturingAdapterMock();
var adapter = new LandblockSpawnAdapter(captured);
var far = MakeLandblock(
landblockId: 0x12340000u,
entities: System.Array.Empty<WorldEntity>());
var promoted = MakeLandblock(landblockId: 0x12340000u, entities: new[]
{
MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }),
});
adapter.OnLandblockLoaded(far);
adapter.OnLandblockLoaded(promoted);
Assert.Equal(2, captured.IncrementCalls.Count);
Assert.Contains(0x01000010ul, captured.IncrementCalls);
Assert.Contains(0x01000020ul, captured.IncrementCalls);
}
[Fact]
public void OnLandblockLoaded_PromotionWithPartiallyRegisteredIds_RegistersOnlyNewIds()
{
var captured = new CapturingAdapterMock();
var adapter = new LandblockSpawnAdapter(captured);
var initial = MakeLandblock(landblockId: 0x12340000u, entities: new[]
{
MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u }),
});
var promoted = MakeLandblock(landblockId: 0x12340000u, entities: new[]
{
MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }),
});
adapter.OnLandblockLoaded(initial);
adapter.OnLandblockLoaded(promoted);
Assert.Equal(new[] { 0x01000010ul, 0x01000020ul }, captured.IncrementCalls);
}
// ── Test helpers ──────────────────────────────────────────────────────
private sealed class CapturingAdapterMock : IWbMeshAdapter

View file

@ -2,7 +2,8 @@
// the pure-data companion to the new Draw(cellIds:) production overload.
//
// Semantics: indoor entities (ParentCellId.HasValue) are gated by explicit
// membership in cellIds. Building shells (IsBuildingShell) always pass.
// membership in cellIds. Building shells (IsBuildingShell) pass only when their
// BuildingShellAnchorCellId belongs to the same cell set.
// Outdoor scenery (no ParentCellId, not a shell) is excluded by EntitySet.IndoorPass.
using System.Collections.Generic;
@ -36,12 +37,13 @@ public class WbDrawDispatcherCellIdsOverloadTests
Rotation = Quaternion.Identity,
};
private static WorldEntity BuildingShell(uint id) => new()
private static WorldEntity BuildingShell(uint id, uint? anchorCellId) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x02000001u,
ParentCellId = null,
IsBuildingShell = true,
BuildingShellAnchorCellId = anchorCellId,
MeshRefs = new List<MeshRef> { new(0x01000001u, Matrix4x4.Identity) },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
@ -55,7 +57,8 @@ public class WbDrawDispatcherCellIdsOverloadTests
CellEnt(0x40000001u, 0xA9B40150u), // in listed cells
CellEnt(0x40000002u, 0xA9B40151u), // in listed cells
CellEnt(0x40000003u, 0xA9B40999u), // OUT — not in list
BuildingShell(0xC0000001u), // always included (IsBuildingShell)
BuildingShell(0xC0000001u, 0xA9B40150u), // in listed building cells
BuildingShell(0xC0000003u, 0xA9B40999u), // OUT — another building shell
OutdoorScenery(0xC0000002u), // OUT — not a shell, not in cell list
};
var cellIds = new HashSet<uint> { 0xA9B40150u, 0xA9B40151u };
@ -68,23 +71,22 @@ public class WbDrawDispatcherCellIdsOverloadTests
Assert.Contains(0x40000002u, result);
Assert.Contains(0xC0000001u, result);
Assert.DoesNotContain(0x40000003u, result);
Assert.DoesNotContain(0xC0000003u, result);
Assert.DoesNotContain(0xC0000002u, result);
}
[Fact]
public void WalkEntitiesByCellIds_EmptyCellList_StillIncludesBuildingShells()
public void WalkEntitiesByCellIds_EmptyCellList_ExcludesBuildingShells()
{
var entities = new List<WorldEntity>
{
CellEnt(0x40000001u, 0xA9B40150u),
BuildingShell(0xC0000001u),
BuildingShell(0xC0000001u, 0xA9B40150u),
};
var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds(
entities, new HashSet<uint>(), set: WbDrawDispatcher.EntitySet.IndoorPass);
// Cell entity dropped (no cells in list); building shell still passes.
Assert.Single(result);
Assert.Contains(0xC0000001u, result);
Assert.Empty(result);
}
}

View file

@ -5,10 +5,12 @@
//
// EntitySet.IndoorPass — ParentCellId.HasValue OR IsBuildingShell,
// and NOT live-dynamic (ServerGuid == 0).
// Building shells render unconditionally indoors;
// Building shells are gated by their dat anchor;
// live-dynamic flows through LiveDynamic instead.
// EntitySet.OutdoorScenery — ParentCellId == null AND !IsBuildingShell
// AND not live-dynamic.
// EntitySet.OutdoorScenery — ParentCellId == null AND not live-dynamic.
// Includes building shells for exterior/depth repair passes.
// EntitySet.BuildingShells — IsBuildingShell only, gated by dat anchor when
// visibleCellIds are supplied.
// EntitySet.LiveDynamic — ServerGuid != 0 (player, NPCs, dropped items,
// idle doors after animation). Drawn last with
// stencil disabled.
@ -46,12 +48,13 @@ public class WbDrawDispatcherEntitySetTests
Rotation = Quaternion.Identity,
};
private static WorldEntity BuildingShell(uint id) => new()
private static WorldEntity BuildingShell(uint id, uint? anchorCellId = null) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x02000001u,
ParentCellId = null,
IsBuildingShell = true,
BuildingShellAnchorCellId = anchorCellId,
MeshRefs = new List<MeshRef> { new() { GfxObjId = 0x01000001u } },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
@ -90,11 +93,11 @@ public class WbDrawDispatcherEntitySetTests
}
[Fact]
public void IndoorPass_IncludesBuildingShells_EvenWithNullParentCellId()
public void IndoorPass_IncludesBuildingShells_WhenAnchorCellIsVisible()
{
var entities = new List<WorldEntity>
{
BuildingShell(0xC0000001), // cottage wall
BuildingShell(0xC0000001, 0xA9B40143u), // cottage wall
OutdoorScenery(0xC0000002), // tree
CellEnt(0x40000001, 0xA9B40143),
};
@ -109,6 +112,47 @@ public class WbDrawDispatcherEntitySetTests
Assert.DoesNotContain(0xC0000002u, result); // tree excluded
}
[Fact]
public void IndoorPass_WithNullCellFilter_UsesEntitySetOnly()
{
var entities = new List<WorldEntity>
{
BuildingShell(0xC0000001, 0xA9B40143u),
CellEnt(0x40000001, 0xA9B40143),
CellEnt(0x40000002, 0xA9B40199),
OutdoorScenery(0xC0000002),
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
};
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Equal(3, result.Count);
Assert.Contains(0xC0000001u, result);
Assert.Contains(0x40000001u, result);
Assert.Contains(0x40000002u, result);
Assert.DoesNotContain(0xC0000002u, result);
Assert.DoesNotContain(0x10000001u, result);
}
[Fact]
public void IndoorPass_ExcludesBuildingShells_WhenAnchorCellIsNotVisible()
{
var entities = new List<WorldEntity>
{
BuildingShell(0xC0000001, 0xA9B40150u),
CellEnt(0x40000001, 0xA9B40143),
};
var visible = new HashSet<uint> { 0xA9B40143u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Single(result);
Assert.Contains(0x40000001u, result);
Assert.DoesNotContain(0xC0000001u, result);
}
[Fact]
public void IndoorPass_ExcludesLiveDynamic()
{
@ -128,11 +172,11 @@ public class WbDrawDispatcherEntitySetTests
}
[Fact]
public void OutdoorScenery_ExcludesBuildingShells()
public void OutdoorScenery_IncludesBuildingShells()
{
var entities = new List<WorldEntity>
{
BuildingShell(0xC0000001), // cottage wall — excluded
BuildingShell(0xC0000001), // cottage wall — included
OutdoorScenery(0xC0000002), // tree — included
CellEnt(0x40000001, 0xA9B40143), // cell — excluded
};
@ -140,9 +184,9 @@ public class WbDrawDispatcherEntitySetTests
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.OutdoorScenery);
Assert.Single(result);
Assert.Equal(2, result.Count);
Assert.Contains(0xC0000002u, result);
Assert.DoesNotContain(0xC0000001u, result);
Assert.Contains(0xC0000001u, result);
Assert.DoesNotContain(0x40000001u, result);
}
@ -163,6 +207,30 @@ public class WbDrawDispatcherEntitySetTests
Assert.DoesNotContain(0x10000001u, result);
}
[Fact]
public void BuildingShells_IncludesOnlyAnchoredShells()
{
var entities = new List<WorldEntity>
{
BuildingShell(0xC0000001, 0xA9B40143u),
BuildingShell(0xC0000002, 0xA9B40999u),
OutdoorScenery(0xC0000003),
CellEnt(0x40000001, 0xA9B40143),
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
};
var visible = new HashSet<uint> { 0xA9B40143u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.BuildingShells);
Assert.Single(result);
Assert.Contains(0xC0000001u, result);
Assert.DoesNotContain(0xC0000002u, result);
Assert.DoesNotContain(0xC0000003u, result);
Assert.DoesNotContain(0x40000001u, result);
Assert.DoesNotContain(0x10000001u, result);
}
[Fact]
public void LiveDynamic_IncludesOnlyServerSpawned()
{

View file

@ -1,6 +1,7 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing;
using DatReaderWriter.Enums;
using Xunit;
namespace AcDream.Core.Tests.Rendering.Wb;
@ -26,9 +27,10 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
var indirect = new DrawElementsIndirectCommand[16];
var batch = new WbDrawDispatcher.BatchDataPublic[16];
var cull = new CullMode[16];
// Act
var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch);
var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch, cull);
// Assert layout
Assert.Equal(2, result.OpaqueCount);
@ -58,6 +60,36 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
Assert.Equal(0xAAul, batch[0].TextureHandle);
Assert.Equal(0xCCul, batch[1].TextureHandle);
Assert.Equal(0xBBul, batch[2].TextureHandle);
Assert.Equal(CullMode.CounterClockwise, cull[0]);
Assert.Equal(CullMode.CounterClockwise, cull[1]);
Assert.Equal(CullMode.CounterClockwise, cull[2]);
}
[Fact]
public void CullModes_FollowOpaqueTransparentLayout()
{
var groups = new List<WbDrawDispatcher.IndirectGroupInput>
{
new(IndexCount: 10, FirstIndex: 0, BaseVertex: 0, InstanceCount: 1, FirstInstance: 0,
TextureHandle: 0x1, TextureLayer: 0, Translucency: TranslucencyKind.Opaque,
CullMode: CullMode.Clockwise),
new(IndexCount: 20, FirstIndex: 10, BaseVertex: 0, InstanceCount: 1, FirstInstance: 1,
TextureHandle: 0x2, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend,
CullMode: CullMode.None),
new(IndexCount: 30, FirstIndex: 30, BaseVertex: 0, InstanceCount: 1, FirstInstance: 2,
TextureHandle: 0x3, TextureLayer: 0, Translucency: TranslucencyKind.ClipMap,
CullMode: CullMode.Landblock),
};
var indirect = new DrawElementsIndirectCommand[4];
var batch = new WbDrawDispatcher.BatchDataPublic[4];
var cull = new CullMode[4];
var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch, cull);
Assert.Equal(2, result.OpaqueCount);
Assert.Equal(CullMode.Clockwise, cull[0]);
Assert.Equal(CullMode.Landblock, cull[1]);
Assert.Equal(CullMode.None, cull[2]);
}
[Fact]

View file

@ -74,6 +74,36 @@ public class GpuWorldStateTwoTierTests
Assert.Equal(2, state.Entities.Count);
}
[Fact]
public void LandblockEntriesWithoutAnimatedIndex_LeavesAnimatedLookupNull()
{
var state = new GpuWorldState();
state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu,
MakeStubEntity(1),
MakeStubEntity(2)));
var staticEntry = Assert.Single(state.LandblockEntriesWithoutAnimatedIndex);
Assert.Null(staticEntry.AnimatedById);
var liveEntry = Assert.Single(state.LandblockEntries);
Assert.NotNull(liveEntry.AnimatedById);
Assert.True(liveEntry.AnimatedById!.ContainsKey(1));
Assert.True(liveEntry.AnimatedById.ContainsKey(2));
}
[Fact]
public void LandblockBounds_EnumeratesWithoutEntityPayload()
{
var state = new GpuWorldState();
state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu,
MakeStubEntity(1),
MakeStubEntity(2)));
var bounds = Assert.Single(state.LandblockBounds);
Assert.Equal(0xAAAAFFFFu, bounds.LandblockId);
}
/// <summary>
/// Phase Post-A.5 #53 (Task 12): the optional <c>onLandblockUnloaded</c>
/// callback fires once when <see cref="GpuWorldState.RemoveEntitiesFromLandblock"/>

View file

@ -45,6 +45,97 @@ public class LandblockStreamerTests
Assert.Same(stubLandblock, loaded.Landblock);
}
[Fact]
public async Task LoadNear_OvertakesQueuedFarLoads()
{
var callOrder = new System.Collections.Generic.List<(uint Id, LandblockStreamJobKind Kind)>();
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
System.Array.Empty<uint>());
using var streamer = new LandblockStreamer(
loadLandblock: (id, kind) =>
{
callOrder.Add((id, kind));
return new LoadedLandblock(id, new LandBlock(), System.Array.Empty<WorldEntity>());
},
buildMeshOrNull: (_, _) => stubMesh);
streamer.EnqueueLoad(0xAAAAFFFFu, LandblockStreamJobKind.LoadFar);
streamer.EnqueueLoad(0xBBBBFFFFu, LandblockStreamJobKind.LoadFar);
streamer.EnqueueLoad(0xCCCCFFFFu, LandblockStreamJobKind.LoadFar);
streamer.EnqueueLoad(0xDDDDFFFFu, LandblockStreamJobKind.LoadNear);
streamer.Start();
var result = await DrainFirstAsync(streamer);
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(result);
Assert.Equal(0xDDDDFFFFu, loaded.LandblockId);
Assert.Equal((0xDDDDFFFFu, LandblockStreamJobKind.LoadNear), callOrder[0]);
}
[Fact]
public async Task PromoteToNear_ProducesPromotedWithMeshData()
{
int meshBuildCalls = 0;
var entity = new WorldEntity
{
Id = 7,
SourceGfxObjOrSetupId = 0,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>()
};
using var streamer = new LandblockStreamer(
loadLandblock: (id, kind) => new LoadedLandblock(id, new LandBlock(), new[] { entity }),
buildMeshOrNull: (_, _) =>
{
meshBuildCalls++;
return new AcDream.Core.Terrain.LandblockMeshData(
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
System.Array.Empty<uint>());
});
streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear);
streamer.Start();
var result = await DrainFirstAsync(streamer);
var promoted = Assert.IsType<LandblockStreamResult.Promoted>(result);
Assert.Equal(0xA9B4FFFFu, promoted.LandblockId);
Assert.Same(entity, promoted.Entities[0]);
Assert.NotNull(promoted.MeshData);
Assert.Equal(1, meshBuildCalls);
}
[Fact]
public async Task PromoteToNear_OvertakesAndSupersedesQueuedFarLoadForSameLandblock()
{
var callOrder = new System.Collections.Generic.List<(uint Id, LandblockStreamJobKind Kind)>();
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
System.Array.Empty<uint>());
using var streamer = new LandblockStreamer(
loadLandblock: (id, kind) =>
{
callOrder.Add((id, kind));
return new LoadedLandblock(id, new LandBlock(), System.Array.Empty<WorldEntity>());
},
buildMeshOrNull: (_, _) => stubMesh);
streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.LoadFar);
streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear);
streamer.Start();
var result = await DrainFirstAsync(streamer);
var promoted = Assert.IsType<LandblockStreamResult.Promoted>(result);
Assert.Equal(0xA9B4FFFFu, promoted.LandblockId);
Assert.Equal((0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear), callOrder[0]);
}
[Fact]
public async Task Load_WhenLoaderReturnsNull_ReportsFailed()
{
@ -183,4 +274,16 @@ public class LandblockStreamerTests
Assert.NotNull(loaderThreadId);
Assert.NotEqual(testThreadId, loaderThreadId.Value);
}
private static async Task<LandblockStreamResult> DrainFirstAsync(LandblockStreamer streamer)
{
for (int i = 0; i < SpinMaxIterations; i++)
{
var drained = streamer.DrainCompletions(maxBatchSize: LandblockStreamer.DefaultDrainBatchSize);
if (drained.Count > 0) return drained[0];
await Task.Delay(SpinStepMs);
}
throw new Xunit.Sdk.XunitException("Timed out waiting for streamer completion.");
}
}

View file

@ -99,16 +99,25 @@ public class StreamingControllerTwoTierTests
state.AddLandblock(lb);
Assert.Empty(state.Entities);
// Streamer pushes a Promoted result carrying the entity layer.
var promoted = new LandblockStreamResult.Promoted(
// Streamer pushes a Promoted result carrying the full near landblock.
var promotedLb = new LoadedLandblock(
lbId,
new[] { new WorldEntity {
Heightmap: null!,
Entities: new[] { new WorldEntity {
Id = 7, SourceGfxObjOrSetupId = 0,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>() } });
var promotedMesh = new LandblockMeshData(
System.Array.Empty<TerrainVertex>(),
System.Array.Empty<uint>());
var promoted = new LandblockStreamResult.Promoted(
lbId,
promotedLb,
promotedMesh);
var queue = new Queue<LandblockStreamResult>();
queue.Enqueue(promoted);
int applyPromotedCount = 0;
var ctrl = new StreamingController(
enqueueLoad: (id, kind) => loads.Add((id, kind)),
@ -119,7 +128,12 @@ public class StreamingControllerTwoTierTests
while (batch.Count < max && queue.Count > 0) batch.Add(queue.Dequeue());
return batch;
},
applyTerrain: (_, _) => { },
applyTerrain: (appliedLb, appliedMesh) =>
{
applyPromotedCount++;
Assert.Same(promotedLb, appliedLb);
Assert.Same(promotedMesh, appliedMesh);
},
state: state,
nearRadius: 2,
farRadius: 2);
@ -128,6 +142,7 @@ public class StreamingControllerTwoTierTests
// Promoted routes to AddEntitiesToExistingLandblock — the entity is now
// merged into the existing LB record.
Assert.Equal(1, applyPromotedCount);
Assert.Equal(1, state.Entities.Count);
Assert.Equal(7u, state.Entities[0].Id);
}

View file

@ -178,14 +178,24 @@ public class LandblockLoaderTests
Origin = new Vector3(10f, 20f, 30f),
Orientation = Quaternion.Identity,
},
Portals =
{
new BuildingPortal
{
OtherCellId = 0x013F,
OtherPortalId = 0,
Flags = 0,
},
},
},
},
};
var entities = LandblockLoader.BuildEntitiesFromInfo(info);
var entities = LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B40000u);
Assert.Single(entities);
Assert.True(entities[0].IsBuildingShell);
Assert.Equal(0xA9B4013Fu, entities[0].BuildingShellAnchorCellId);
}
[Fact]

View file

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,311 @@
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using SysEnv = System.Environment;
string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
Console.Error.WriteLine($"DAT directory not found: {datDir}");
return 2;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
if (args.Length > 0 && string.Equals(args[0], "buildings", StringComparison.OrdinalIgnoreCase))
{
uint landblockId = args.Length > 1 ? ParseHex(args[1]) : 0xA9B40000u;
int radius = args.Length > 2 ? int.Parse(args[2], System.Globalization.CultureInfo.InvariantCulture) : 0;
DumpBuildings(dats, landblockId, radius);
}
else if (args.Length > 0 && string.Equals(args[0], "portals", StringComparison.OrdinalIgnoreCase))
{
var ids = args.Length == 1
? new uint[] { 0xA9B40171u }
: args.Skip(1).Select(ParseHex).ToArray();
foreach (var envCellId in ids)
DumpCellPortals(dats, envCellId);
}
else
{
var ids = args.Length == 0
? [0xA9B4013Fu]
: args.Select(ParseHex).ToArray();
foreach (var envCellId in ids)
{
DumpCell(dats, envCellId);
}
}
return 0;
static uint ParseHex(string text)
{
text = text.Trim();
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return Convert.ToUInt32(text[2..], 16);
return Convert.ToUInt32(text, 16);
}
static void DumpCell(DatCollection dats, uint envCellId)
{
Console.WriteLine($"=== EnvCell 0x{envCellId:X8} ===");
var envCell = dats.Get<EnvCell>(envCellId);
if (envCell is null)
{
Console.WriteLine("missing EnvCell");
return;
}
var envId = 0x0D000000u | envCell.EnvironmentId;
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(envId);
if (environment is null)
{
Console.WriteLine($"missing Environment 0x{envId:X8}");
return;
}
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct))
{
Console.WriteLine($"missing CellStruct {envCell.CellStructure}");
return;
}
Console.WriteLine(
$"environment=0x{envId:X8} cellStruct={envCell.CellStructure} " +
$"surfaces={envCell.Surfaces.Count} verts={cellStruct.VertexArray.Vertices.Count} polys={cellStruct.Polygons.Count}");
int posSides = 0;
int negSides = 0;
int skipped = 0;
int likelyFloor = 0;
int likelyCeiling = 0;
foreach (var (polyId, poly) in cellStruct.Polygons.OrderBy(p => p.Key))
{
if (poly.VertexIds.Count < 3)
continue;
bool emitPos = !poly.Stippling.HasFlag(StipplingType.NoPos) && poly.PosSurface >= 0 && poly.PosSurface < envCell.Surfaces.Count;
bool emitNeg =
(poly.Stippling.HasFlag(StipplingType.Negative) ||
poly.Stippling.HasFlag(StipplingType.Both) ||
(!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise))
&& poly.NegSurface >= 0
&& poly.NegSurface < envCell.Surfaces.Count;
if (emitPos) posSides++;
if (emitNeg) negSides++;
if (!emitPos && !emitNeg) skipped++;
var normal = ComputeNormal(cellStruct, poly);
var uvRange = UvRange(cellStruct, poly);
string planeHint = Math.Abs(normal.Z) > 0.9f
? normal.Z > 0 ? "floor/up" : "ceiling/down"
: Math.Abs(normal.Z) > 0.15f ? "slope" : "wall";
if (normal.Z > 0.9f) likelyFloor++;
if (normal.Z < -0.9f) likelyCeiling++;
var posSurf = SurfaceText(envCell, poly.PosSurface);
var negSurf = SurfaceText(envCell, poly.NegSurface);
Console.WriteLine(
$"poly=0x{polyId:X4} pts={poly.VertexIds.Count} n=({normal.X:F3},{normal.Y:F3},{normal.Z:F3}) {planeHint,-12} " +
$"stip={poly.Stippling} sides={poly.SidesType} pos={poly.PosSurface}->{posSurf} neg={poly.NegSurface}->{negSurf} " +
$"emitPos={emitPos} emitNeg={emitNeg} posUv={poly.PosUVIndices?.Count ?? 0} negUv={poly.NegUVIndices?.Count ?? 0} " +
$"uv=({uvRange.Min.X:F3},{uvRange.Min.Y:F3})..({uvRange.Max.X:F3},{uvRange.Max.Y:F3})");
}
Console.WriteLine(
$"summary: posSides={posSides} negSides={negSides} skipped={skipped} " +
$"likelyFloor={likelyFloor} likelyCeiling={likelyCeiling}");
Console.WriteLine();
}
static void DumpCellPortals(DatCollection dats, uint envCellId)
{
Console.WriteLine($"=== EnvCell 0x{envCellId:X8} portals ===");
var envCell = dats.Get<EnvCell>(envCellId);
if (envCell is null)
{
Console.WriteLine("missing EnvCell");
return;
}
uint lbPrefix = envCellId & 0xFFFF0000u;
int exitCount = 0;
int interiorCount = 0;
for (int i = 0; i < envCell.CellPortals.Count; i++)
{
var portal = envCell.CellPortals[i];
bool isExit = portal.OtherCellId == 0xFFFF;
if (isExit) exitCount++; else interiorCount++;
string dest = isExit
? "EXIT(outdoor)"
: $"0x{(lbPrefix | (uint)portal.OtherCellId):X8}";
Console.WriteLine(
$" portal[{i}] other=0x{portal.OtherCellId:X4} -> {dest} " +
$"flags={portal.Flags} polyId={portal.PolygonId}");
}
Console.WriteLine(
$"summary: cell=0x{envCellId:X8} portals={envCell.CellPortals.Count} " +
$"exits(0xFFFF)={exitCount} interior={interiorCount} " +
$"numCellPortals={envCell.CellPortals.Count} seenOutside={(envCell.Flags.HasFlag(EnvCellFlags.SeenOutside) ? "Y" : "n")}");
Console.WriteLine();
}
static void DumpBuildings(DatCollection dats, uint centerLandblockId, int radius)
{
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
int totalRegistryBuildings = 0;
int totalShellEntities = 0;
for (int dy = -radius; dy <= radius; dy++)
for (int dx = -radius; dx <= radius; dx++)
{
int x = centerX + dx;
int y = centerY + dy;
if (x < 0 || x > 255 || y < 0 || y > 255)
continue;
uint landblockId = ((uint)x << 24) | ((uint)y << 16);
var info = dats.Get<LandBlockInfo>(landblockId | 0xFFFEu);
if (info is null)
continue;
var (registryBuildings, shellEntities) = DumpLandblockBuildings(info, landblockId);
totalRegistryBuildings += registryBuildings;
totalShellEntities += shellEntities;
}
Console.WriteLine(
$"radius-summary center=0x{(centerLandblockId & 0xFFFF0000u):X8} radius={radius} " +
$"registryBuildings={totalRegistryBuildings} shellEntities={totalShellEntities}");
}
static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlockInfo info, uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
uint stabIdBase = 0xC0000000u
| (((landblockId >> 24) & 0xFFu) << 16)
| (((landblockId >> 16) & 0xFFu) << 8);
uint nextEntityId = stabIdBase + 1u;
int supportedObjects = 0;
foreach (var obj in info.Objects)
{
if (!IsSupported(obj.Id))
continue;
supportedObjects++;
nextEntityId++;
}
Console.WriteLine(
$"=== Landblock 0x{lbPrefix:X8} info=0x{(lbPrefix | 0xFFFEu):X8} " +
$"objects={info.Objects.Count} supportedObjects={supportedObjects} buildings={info.Buildings.Count} ===");
int registryBuildingId = 1;
int shellEntities = 0;
foreach (var (building, zeroBased) in info.Buildings.Select((b, i) => (b, i)))
{
if (!IsSupported(building.ModelId))
continue;
uint shellEntityId = nextEntityId++;
shellEntities++;
var portalCells = building.Portals
.Where(p => p.OtherCellId != 0xFFFF)
.Select(p => lbPrefix | (uint)p.OtherCellId)
.Distinct()
.OrderBy(id => id)
.ToArray();
string registryText = portalCells.Length == 0
? "none"
: $"0x{registryBuildingId++:X}";
string portalText = portalCells.Length == 0
? "[]"
: "[" + string.Join(",", portalCells.Select(id => $"0x{id:X8}")) + "]";
Console.WriteLine(
$"buildingOrdinal={zeroBased + 1} registryId={registryText} shellEntity=0x{shellEntityId:X8} " +
$"model=0x{building.ModelId:X8} pos=({building.Frame.Origin.X:F2},{building.Frame.Origin.Y:F2},{building.Frame.Origin.Z:F2}) " +
$"portalCells={portalText}");
}
Console.WriteLine();
return (registryBuildingId - 1, shellEntities);
}
static bool IsSupported(uint id)
{
uint type = id & 0xFF000000u;
return type == 0x01000000u || type == 0x02000000u;
}
static string SurfaceText(EnvCell envCell, short index)
{
if (index < 0) return "none";
if (index >= envCell.Surfaces.Count) return "out-of-range";
return $"0x{(0x08000000u | envCell.Surfaces[index]):X8}";
}
static Vector3 ComputeNormal(DatReaderWriter.Types.CellStruct cellStruct, DatReaderWriter.Types.Polygon poly)
{
if (poly.VertexIds.Count < 3) return Vector3.Zero;
if (!TryGetOrigin(cellStruct, (ushort)poly.VertexIds[0], out var a) ||
!TryGetOrigin(cellStruct, (ushort)poly.VertexIds[1], out var b) ||
!TryGetOrigin(cellStruct, (ushort)poly.VertexIds[2], out var c))
{
return Vector3.Zero;
}
var n = Vector3.Cross(b - a, c - a);
return n.LengthSquared() > 0f ? Vector3.Normalize(n) : Vector3.Zero;
}
static bool TryGetOrigin(DatReaderWriter.Types.CellStruct cellStruct, ushort id, out Vector3 origin)
{
if (cellStruct.VertexArray.Vertices.TryGetValue(id, out var vertex))
{
origin = vertex.Origin;
return true;
}
origin = Vector3.Zero;
return false;
}
static (Vector2 Min, Vector2 Max) UvRange(DatReaderWriter.Types.CellStruct cellStruct, DatReaderWriter.Types.Polygon poly)
{
var min = new Vector2(float.MaxValue, float.MaxValue);
var max = new Vector2(float.MinValue, float.MinValue);
bool any = false;
for (int i = 0; i < poly.VertexIds.Count; i++)
{
if (!cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[i], out var vertex))
continue;
ushort uvIdx = 0;
if (poly.PosUVIndices is not null && i < poly.PosUVIndices.Count)
uvIdx = poly.PosUVIndices[i];
if (uvIdx >= vertex.UVs.Count)
uvIdx = 0;
if (vertex.UVs.Count == 0)
continue;
var uv = new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V);
min = Vector2.Min(min, uv);
max = Vector2.Max(max, uv);
any = true;
}
return any ? (min, max) : (Vector2.Zero, Vector2.Zero);
}