From 5dc4140c1173f1d16a8d1759b361f08866b1cb93 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 29 May 2026 10:14:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20A8=20=E2=80=94=20indoor?= =?UTF-8?q?=20visibility=20+=20streaming=20fixes=20batch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/ISSUES.md | 15 +- ...sue-78-visibility-culling-investigation.md | 206 ++++ .../research/2026-05-26-a8-entity-taxonomy.md | 137 +++ .../2026-05-28-a8-cellar-flap-handoff.md | 168 +++ ...26-05-28-a8-cellar-flap-option2-handoff.md | 176 +++ .../2026-05-28-a8-cellar-flap-root-cause.md | 120 ++ .../plans/2026-05-26-phase-a8-replan.md | 1081 +++++++++++++++++ src/AcDream.App/Rendering/CellVisibility.cs | 6 + src/AcDream.App/Rendering/GameWindow.cs | 725 +++++++++-- .../Rendering/IndoorCellStencilPipeline.cs | 73 +- .../Rendering/Shaders/portal_stencil.vert | 16 +- src/AcDream.App/Rendering/Wb/Building.cs | 19 +- .../Rendering/Wb/BuildingLoader.cs | 17 + .../Rendering/Wb/EnvCellRenderer.cs | 106 +- src/AcDream.App/Rendering/Wb/GroupKey.cs | 4 +- .../Rendering/Wb/LandblockSpawnAdapter.cs | 34 +- .../Rendering/Wb/WbDrawDispatcher.cs | 281 ++++- src/AcDream.App/RuntimeOptions.cs | 20 + src/AcDream.App/Streaming/GpuWorldState.cs | 48 +- .../Streaming/LandblockStreamJob.cs | 18 +- .../Streaming/LandblockStreamer.cs | 85 +- .../Streaming/StreamingController.cs | 7 +- src/AcDream.Core/World/LandblockLoader.cs | 18 + src/AcDream.Core/World/WorldEntity.cs | 17 +- .../IndoorCellStencilPipelineTests.cs | 81 ++ .../Rendering/Wb/BuildingLoaderTests.cs | 39 + .../Rendering/Wb/EnvCellRendererTests.cs | 18 + .../AcDream.App.Tests/RuntimeOptionsTests.cs | 30 + .../Wb/LandblockSpawnAdapterTests.cs | 46 +- .../WbDrawDispatcherCellIdsOverloadTests.cs | 18 +- .../Wb/WbDrawDispatcherEntitySetTests.cs | 88 +- .../WbDrawDispatcherIndirectBuilderTests.cs | 34 +- .../Streaming/GpuWorldStateTwoTierTests.cs | 30 + .../Streaming/LandblockStreamerTests.cs | 103 ++ .../StreamingControllerTwoTierTests.cs | 23 +- .../World/LandblockLoaderTests.cs | 12 +- tools/A8CellAudit/A8CellAudit.csproj | 12 + tools/A8CellAudit/Program.cs | 311 +++++ 38 files changed, 3965 insertions(+), 277 deletions(-) create mode 100644 docs/research/2026-05-25-issue-78-visibility-culling-investigation.md create mode 100644 docs/research/2026-05-26-a8-entity-taxonomy.md create mode 100644 docs/research/2026-05-28-a8-cellar-flap-handoff.md create mode 100644 docs/research/2026-05-28-a8-cellar-flap-option2-handoff.md create mode 100644 docs/research/2026-05-28-a8-cellar-flap-root-cause.md create mode 100644 docs/superpowers/plans/2026-05-26-phase-a8-replan.md create mode 100644 tools/A8CellAudit/A8CellAudit.csproj create mode 100644 tools/A8CellAudit/Program.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 26b8c88..8122839 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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. --- diff --git a/docs/research/2026-05-25-issue-78-visibility-culling-investigation.md b/docs/research/2026-05-25-issue-78-visibility-culling-investigation.md new file mode 100644 index 0000000..d6d5e23 --- /dev/null +++ b/docs/research/2026-05-25-issue-78-visibility-culling-investigation.md @@ -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. diff --git a/docs/research/2026-05-26-a8-entity-taxonomy.md b/docs/research/2026-05-26-a8-entity-taxonomy.md new file mode 100644 index 0000000..5efd313 --- /dev/null +++ b/docs/research/2026-05-26-a8-entity-taxonomy.md @@ -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` 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` diff --git a/docs/research/2026-05-28-a8-cellar-flap-handoff.md b/docs/research/2026-05-28-a8-cellar-flap-handoff.md new file mode 100644 index 0000000..85ccfdc --- /dev/null +++ b/docs/research/2026-05-28-a8-cellar-flap-handoff.md @@ -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. diff --git a/docs/research/2026-05-28-a8-cellar-flap-option2-handoff.md b/docs/research/2026-05-28-a8-cellar-flap-option2-handoff.md new file mode 100644 index 0000000..cd6b9d8 --- /dev/null +++ b/docs/research/2026-05-28-a8-cellar-flap-option2-handoff.md @@ -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 ` dumps a cell's + CellPortals (exit vs interior); `-- buildings ` 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. diff --git a/docs/research/2026-05-28-a8-cellar-flap-root-cause.md b/docs/research/2026-05-28-a8-cellar-flap-root-cause.md new file mode 100644 index 0000000..d22fa48 --- /dev/null +++ b/docs/research/2026-05-28-a8-cellar-flap-root-cause.md @@ -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** | 1–2 | 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 1–2 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 ` dumps + building→cell grouping. These make the whole investigation re-runnable in + seconds with zero launches. diff --git a/docs/superpowers/plans/2026-05-26-phase-a8-replan.md b/docs/superpowers/plans/2026-05-26-phase-a8-replan.md new file mode 100644 index 0000000..ece69db --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-phase-a8-replan.md @@ -0,0 +1,1081 @@ +# Phase A8 RE-PLAN — Indoor-cell visibility culling (taxonomy-aware integration) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close issue #78 (outdoor stabs/terrain visible through indoor walls) and the cellar terrain artifact by porting WorldBuilder's stencil-based `RenderInsideOut` pipeline with an entity taxonomy that correctly distinguishes **building shell stabs** (cottage walls — must render unconditionally indoors) from **outdoor scenery stabs** (trees, lampposts — stencil-gated to portal silhouettes). + +**Architecture:** Tag `WorldEntity.IsBuildingShell` at the `LandblockLoader` data boundary (sourced from `LandBlockInfo.Buildings` vs `LandBlockInfo.Objects` — the dat already carries the distinction). Refactor `WbDrawDispatcher.EntitySet` from binary `IndoorOnly`/`OutdoorOnly` to a three-way `IndoorPass` (cell mesh + cell statics + building shells) / `OutdoorScenery` (trees + procedural) / `LiveDynamic` (server-spawned). Re-wire the render frame with WB's MarkAndPunch-FIRST order so indoor cell depth correctly survives. Stencil-mark only the **camera's own cell's** exit portals (skip WB Step 5 / 3-stencil-bit for first ship; the cross-cell-portal visibility loss is acceptable). + +**Tech Stack:** C# .NET 10, Silk.NET (OpenGL 4.3 + GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters), xUnit. + +**Predecessor context (REQUIRED reading before starting):** +- [docs/research/2026-05-26-a8-revert-handoff.md](../../research/2026-05-26-a8-revert-handoff.md) — full story of the 3-round visual verification failure + reverts +- [docs/research/2026-05-26-a8-entity-taxonomy.md](../../research/2026-05-26-a8-entity-taxonomy.md) — approved fix-shape (3 cross-references converge: retail, WB, and acdream's own GameWindow.cs:5175 comment) +- [docs/superpowers/plans/2026-05-25-phase-a8-indoor-cell-visibility-culling.md](2026-05-25-phase-a8-indoor-cell-visibility-culling.md) — original plan; **Tasks 1-6 already shipped (dormant in-tree); do NOT re-execute Task 7 as written** + +**Infrastructure preserved (consume as-is):** +- `src/AcDream.App/Rendering/CellVisibility.cs` — `LoadedCell.PortalPolygons: List` (populated by `BuildLoadedCell`) +- `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` — `PortalMeshBuilder.BuildTriangles` (pure) + `IndoorCellStencilPipeline` (GL); `UploadPortalMesh` / `MarkAndPunch` / `EnableOutdoorPass` / `DisableStencil` already implement WB's Steps 1+2+4 +- `src/AcDream.App/Rendering/Shaders/portal_stencil.vert/.frag` — minimal MVP + `gl_FragDepth=1.0` writer +- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — `EntitySet` enum (will be reshaped) + `WalkEntitiesForTest` test helper +- `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` — `ProbeVisibilityEnabled` flag (optional, available for diagnostics) + +--- + +## File Structure + +| File | What changes | Why | +|---|---|---| +| `src/AcDream.Core/World/WorldEntity.cs` | Add `IsBuildingShell: bool` (init-only, default false) | Carry the dat-level distinction through to render time | +| `src/AcDream.Core/World/LandblockLoader.cs` | Set `IsBuildingShell = true` in the `info.Buildings` loop; leave `info.Objects` loop unchanged | Tag at the only point both classes are still distinguishable | +| `src/AcDream.App/Rendering/GameWindow.cs` | One-line propagation in the stab hydration copy (line 5129-5136) | Preserve flag through dat→runtime hydration | +| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | Rename `EntitySet.IndoorOnly`→`IndoorPass`, `OutdoorOnly`→`OutdoorScenery`; add `LiveDynamic`. Extend `WalkEntitiesInto` + `WalkEntitiesForTest` partition logic | Three-way partition reflecting the actual taxonomy | +| `src/AcDream.App/Rendering/GameWindow.cs` | Re-wire render frame inside-camera branch with WB-order: MarkAndPunch → IndoorPass → stencil-gated terrain re-draw → OutdoorScenery → LiveDynamic | Integration that respects the entity-taxonomy lesson | +| `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs` | Rebuild against new enum values; add IsBuildingShell + LiveDynamic coverage | Lock partition correctness before integration | +| `tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs` | Add tests confirming `IsBuildingShell` set correctly per source array | Lock the data-layer guarantee | +| `docs/ISSUES.md` | Move #78 to Recently closed; document deferred Step 5 / cellar-from-outside artifact | Ship-docs | +| `CLAUDE.md` | Update A8 paragraph from "REVERTED" → "SHIPPED" with ship summary | Roadmap discipline | + +--- + +## Task R1: `WorldEntity.IsBuildingShell` flag + LandblockLoader tagging + +**Files:** +- Modify: `src/AcDream.Core/World/WorldEntity.cs` +- Modify: `src/AcDream.Core/World/LandblockLoader.cs:58-87` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs:5129-5137` +- Test: `tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs` + +- [ ] **R1-S1: Write failing tests for LandblockLoader IsBuildingShell tagging** + +Append these tests to `tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs`. (Read the file first to find the existing test class + how to construct a `LandBlockInfo` mock with `Objects` and `Buildings` entries — there's already a setup pattern there; reuse it.) + +```csharp +[Fact] +public void BuildEntitiesFromInfo_TagsBuildingsWithIsBuildingShellTrue() +{ + var info = new DatReaderWriter.DBObjs.LandBlockInfo + { + Objects = new System.Collections.Generic.List(), + Buildings = new System.Collections.Generic.List + { + new DatReaderWriter.Types.BuildingInfo + { + ModelId = 0x02000123u, // Setup id + Frame = new DatReaderWriter.Types.Frame + { + Origin = new System.Numerics.Vector3(10f, 20f, 30f), + Orientation = System.Numerics.Quaternion.Identity, + }, + }, + }, + }; + + var entities = AcDream.Core.World.LandblockLoader.BuildEntitiesFromInfo(info); + + Assert.Single(entities); + Assert.True(entities[0].IsBuildingShell); +} + +[Fact] +public void BuildEntitiesFromInfo_TagsObjectsWithIsBuildingShellFalse() +{ + var info = new DatReaderWriter.DBObjs.LandBlockInfo + { + Objects = new System.Collections.Generic.List + { + new DatReaderWriter.Types.Stab + { + Id = 0x01000123u, // GfxObj id + Frame = new DatReaderWriter.Types.Frame + { + Origin = new System.Numerics.Vector3(10f, 20f, 30f), + Orientation = System.Numerics.Quaternion.Identity, + }, + }, + }, + Buildings = new System.Collections.Generic.List(), + }; + + var entities = AcDream.Core.World.LandblockLoader.BuildEntitiesFromInfo(info); + + Assert.Single(entities); + Assert.False(entities[0].IsBuildingShell); +} +``` + +**Note:** if the existing tests in this file use a different `Stab`/`BuildingInfo` type, mirror that pattern. The field names above match what `LandblockLoader.cs` already reads at lines 58 and 74. + +- [ ] **R1-S2: Run tests to verify they fail with "IsBuildingShell does not exist"** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~LandblockLoaderTests.BuildEntitiesFromInfo_TagsBuildings" --nologo +``` + +Expected: BUILD FAILURE with "'WorldEntity' does not contain a definition for 'IsBuildingShell'". + +- [ ] **R1-S3: Add `IsBuildingShell` to `WorldEntity`** + +Edit `src/AcDream.Core/World/WorldEntity.cs`. Add the new property just below `ParentCellId` (around current line 46): + +```csharp +/// +/// True when this entity originates from LandBlockInfo.Buildings[] +/// (the dat array that carries building shells: cottage walls, smithy walls, +/// inn walls — every solid building enclosure). False for entities from +/// LandBlockInfo.Objects[] (rocks, fences, lampposts, tree clusters — +/// outdoor scenery placeholders). The two arrays are conflated through +/// hydration today but the dat itself carries the distinction; retail +/// (CLandBlock::init_buildings) and WorldBuilder +/// (SceneryInstance.IsBuilding) both preserve it. +/// +/// +/// Read at draw time by 's +/// IndoorPass 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. +/// +/// +public bool IsBuildingShell { get; init; } +``` + +- [ ] **R1-S4: Tag Buildings loop in LandblockLoader** + +Edit `src/AcDream.Core/World/LandblockLoader.cs:78-85`. Add the `IsBuildingShell = true` initializer. The Objects loop at lines 62-69 stays unchanged (default `false`). + +Change this block: + +```csharp + var buildingEntity = new WorldEntity + { + Id = nextId++, + SourceGfxObjOrSetupId = building.ModelId, + Position = building.Frame.Origin, + Rotation = building.Frame.Orientation, + MeshRefs = Array.Empty(), + }; +``` + +To: + +```csharp + var buildingEntity = new WorldEntity + { + Id = nextId++, + SourceGfxObjOrSetupId = building.ModelId, + Position = building.Frame.Origin, + Rotation = building.Frame.Orientation, + MeshRefs = Array.Empty(), + IsBuildingShell = true, // Phase A8: tag at source array boundary + }; +``` + +- [ ] **R1-S5: Propagate flag through GameWindow hydration** + +Edit `src/AcDream.App/Rendering/GameWindow.cs:5129-5137`. The hydration loop copies fields from `e` into a fresh `WorldEntity`; `IsBuildingShell` is dropped today. Add propagation: + +Change this block: + +```csharp + var entity = new AcDream.Core.World.WorldEntity + { + Id = e.Id, + SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId, + Position = e.Position + worldOffset, + Rotation = e.Rotation, + MeshRefs = meshRefs, + }; +``` + +To: + +```csharp + var entity = new AcDream.Core.World.WorldEntity + { + Id = e.Id, + SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId, + Position = e.Position + worldOffset, + Rotation = e.Rotation, + MeshRefs = meshRefs, + IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag + }; +``` + +- [ ] **R1-S6: Run R1 tests to verify they pass** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~LandblockLoaderTests.BuildEntitiesFromInfo_TagsBuildings" --nologo +``` + +Expected: 2 tests pass. + +- [ ] **R1-S7: Full build + test to verify no regression** + +```bash +dotnet build -c Debug --nologo +dotnet test --nologo +``` + +Expected: build green (0 warnings, 0 errors). Test failures should be within the documented pre-existing ~14-23 flaky window (PhysicsResolveCapture / PhysicsDiagnostics static-leak issues) — no NEW failures attributable to this change. + +- [ ] **R1-S8: Commit** + +```bash +git add src/AcDream.Core/World/WorldEntity.cs src/AcDream.Core/World/LandblockLoader.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs +git commit -m "$(cat <<'EOF' +feat(world): Phase A8 R1 — tag WorldEntity.IsBuildingShell at LandblockLoader + +Adds a bool flag at the WorldEntity data layer set by LandblockLoader from +the source dat array: LandBlockInfo.Buildings → true (cottage walls, inn +walls, smithy walls); LandBlockInfo.Objects → false (trees, lampposts, +rocks, hitching posts). + +Retail anchor: CLandBlock::init_buildings reads a separate BuildInfo** +array from objects (acclient.h:31893 num_buildings / buildings field; +acclient_2013_pseudo_c.txt:313854 init_buildings entry). WorldBuilder +preserves the same distinction via SceneryInstance.IsBuilding +(StaticObjectRenderManager.cs:334). Today acdream's loader reads both +arrays into the same WorldEntity pool with no tag, destroying the +distinction (the comment at GameWindow.cs:5175 already acknowledges this +gap for scenery suppression). This commit closes the gap. + +Render-time consumption arrives in R2 (EntitySet partition refactor). +Two new LandblockLoader tests lock the tagging behavior. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task R2: Reshape `WbDrawDispatcher.EntitySet` to taxonomy-aware partition + +**Files:** +- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — enum + 3 partition-logic sites (`WalkEntitiesInto` line 360-362 + line 375-377; `WalkEntitiesForTest` line 1358-1359) +- Modify: `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs` — rebuild against new semantics + add coverage + +- [ ] **R2-S1: Write failing tests for the new partition semantics** + +Replace the contents of `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs` with: + +```csharp +// Phase A8 — verify the WbDrawDispatcher EntitySet partition (taxonomy-aware). +// +// The pure-data WalkEntitiesForTest helper iterates a flat entity list and +// returns the IDs that survive the EntitySet filter + visibleCellIds gate. +// +// EntitySet.IndoorPass — ParentCellId.HasValue OR IsBuildingShell, +// and NOT live-dynamic (ServerGuid == 0). +// Building shells render unconditionally indoors; +// live-dynamic flows through LiveDynamic instead. +// EntitySet.OutdoorScenery — ParentCellId == null AND !IsBuildingShell +// AND not live-dynamic. +// EntitySet.LiveDynamic — ServerGuid != 0 (player, NPCs, dropped items, +// idle doors after animation). Drawn last with +// stencil disabled. +// EntitySet.All — pre-A8 behavior (visibleCellIds gates indoor; +// outdoor entities pass through). + +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Rendering.Wb; + +public class WbDrawDispatcherEntitySetTests +{ + private static WorldEntity CellEnt(uint id, uint cellId) => new() + { + Id = id, + SourceGfxObjOrSetupId = 0x01000001u, + ParentCellId = cellId, + MeshRefs = new List { new() { GfxObjId = 0x01000001u } }, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + }; + + private static WorldEntity OutdoorScenery(uint id) => new() + { + Id = id, + SourceGfxObjOrSetupId = 0x01000001u, + ParentCellId = null, + IsBuildingShell = false, + MeshRefs = new List { new() { GfxObjId = 0x01000001u } }, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + }; + + private static WorldEntity BuildingShell(uint id) => new() + { + Id = id, + SourceGfxObjOrSetupId = 0x02000001u, + ParentCellId = null, + IsBuildingShell = true, + MeshRefs = new List { new() { GfxObjId = 0x01000001u } }, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + }; + + private static WorldEntity LiveDynamic(uint id, uint serverGuid) => new() + { + Id = id, + SourceGfxObjOrSetupId = 0x02000001u, + ServerGuid = serverGuid, + ParentCellId = null, + IsBuildingShell = false, + MeshRefs = new List { new() { GfxObjId = 0x01000001u } }, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + }; + + [Fact] + public void IndoorPass_IncludesCellEntities() + { + var entities = new List + { + CellEnt(0x10000001, 0xA9B40143), + OutdoorScenery(0x10000002), + CellEnt(0x10000003, 0xA9B40144), + }; + + var visible = new HashSet { 0xA9B40143u, 0xA9B40144u }; + var result = WbDrawDispatcher.WalkEntitiesForTest( + entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass); + + Assert.Equal(2, result.Count); + Assert.Contains(0x10000001u, result); + Assert.Contains(0x10000003u, result); + Assert.DoesNotContain(0x10000002u, result); + } + + [Fact] + public void IndoorPass_IncludesBuildingShells_EvenWithNullParentCellId() + { + var entities = new List + { + BuildingShell(0xC0000001), // cottage wall + OutdoorScenery(0xC0000002), // tree + CellEnt(0x40000001, 0xA9B40143), + }; + + var visible = new HashSet { 0xA9B40143u }; + var result = WbDrawDispatcher.WalkEntitiesForTest( + entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass); + + Assert.Equal(2, result.Count); + Assert.Contains(0xC0000001u, result); // building shell included + Assert.Contains(0x40000001u, result); // cell entity included + Assert.DoesNotContain(0xC0000002u, result); // tree excluded + } + + [Fact] + public void IndoorPass_ExcludesLiveDynamic() + { + var entities = new List + { + CellEnt(0x40000001, 0xA9B40143), + LiveDynamic(0x10000001, serverGuid: 0x50000123u), + }; + + var visible = new HashSet { 0xA9B40143u }; + var result = WbDrawDispatcher.WalkEntitiesForTest( + entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass); + + Assert.Single(result); + Assert.Contains(0x40000001u, result); + Assert.DoesNotContain(0x10000001u, result); // live-dynamic excluded + } + + [Fact] + public void OutdoorScenery_ExcludesBuildingShells() + { + var entities = new List + { + BuildingShell(0xC0000001), // cottage wall — excluded + OutdoorScenery(0xC0000002), // tree — included + CellEnt(0x40000001, 0xA9B40143), // cell — excluded + }; + + var result = WbDrawDispatcher.WalkEntitiesForTest( + entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.OutdoorScenery); + + Assert.Single(result); + Assert.Contains(0xC0000002u, result); + Assert.DoesNotContain(0xC0000001u, result); + Assert.DoesNotContain(0x40000001u, result); + } + + [Fact] + public void OutdoorScenery_ExcludesLiveDynamic() + { + var entities = new List + { + OutdoorScenery(0xC0000001), + LiveDynamic(0x10000001, serverGuid: 0x50000123u), + }; + + var result = WbDrawDispatcher.WalkEntitiesForTest( + entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.OutdoorScenery); + + Assert.Single(result); + Assert.Contains(0xC0000001u, result); + Assert.DoesNotContain(0x10000001u, result); + } + + [Fact] + public void LiveDynamic_IncludesOnlyServerSpawned() + { + var entities = new List + { + OutdoorScenery(0xC0000001), + BuildingShell(0xC0000002), + CellEnt(0x40000001, 0xA9B40143), + LiveDynamic(0x10000001, serverGuid: 0x50000123u), + LiveDynamic(0x10000002, serverGuid: 0x50000456u), + }; + + var result = WbDrawDispatcher.WalkEntitiesForTest( + entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.LiveDynamic); + + Assert.Equal(2, result.Count); + Assert.Contains(0x10000001u, result); + Assert.Contains(0x10000002u, result); + Assert.DoesNotContain(0xC0000001u, result); + Assert.DoesNotContain(0xC0000002u, result); + Assert.DoesNotContain(0x40000001u, result); + } + + [Fact] + public void All_MatchesPreA8Behavior() + { + var entities = new List + { + CellEnt(0x40000001, 0xA9B40143), + OutdoorScenery(0xC0000001), + BuildingShell(0xC0000002), + LiveDynamic(0x10000001, serverGuid: 0x50000123u), + CellEnt(0x40000002, 0xA9B40999), // not in visibleCellIds + }; + + var visible = new HashSet { 0xA9B40143u }; + var result = WbDrawDispatcher.WalkEntitiesForTest( + entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.All); + + // Pre-A8: visibleCellIds gates indoor entities only; outdoor entities + // (regardless of building/scenery/live-dynamic) pass through. + Assert.Equal(4, result.Count); + Assert.Contains(0x40000001u, result); + Assert.Contains(0xC0000001u, result); + Assert.Contains(0xC0000002u, result); + Assert.Contains(0x10000001u, result); + Assert.DoesNotContain(0x40000002u, result); + } +} +``` + +- [ ] **R2-S2: Run tests to verify they fail with "IndoorPass not found"** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WbDrawDispatcherEntitySetTests" --nologo +``` + +Expected: BUILD FAILURE with "'EntitySet' does not contain a definition for 'IndoorPass'" / "'LiveDynamic'". + +- [ ] **R2-S3: Rename + extend the EntitySet enum** + +Edit `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:69-81`. Replace the existing enum block with: + +```csharp + /// + /// Phase A8 — which subset of entities to walk in a single Draw call. + /// Used to split the indoor-cell visibility pipeline into three passes + /// when the camera is inside an EnvCell. + /// + /// Taxonomy reference: docs/research/2026-05-26-a8-entity-taxonomy.md. + /// + public enum EntitySet + { + /// Pre-A8 behavior: every entity walked, gated only by + /// the existing ParentCellId ∈ visibleCellIds filter. + /// Used when the camera is OUTSIDE any EnvCell. + All, + + /// Cell mesh + cell statics ( + /// non-null) PLUS building shell stabs ( + /// true, regardless of ParentCellId). These render unconditionally + /// when the camera is inside their building — building shells ARE + /// the indoor walls. Live-dynamic (ServerGuid != 0) is + /// excluded; it flows through . + IndoorPass, + + /// Outdoor scenery stabs (ParentCellId == null, + /// !IsBuildingShell) plus procedurally-generated scenery. + /// Drawn stencil-gated to portal silhouettes when the camera is + /// inside. Live-dynamic excluded. + OutdoorScenery, + + /// Server-spawned dynamic entities (ServerGuid != 0): + /// player, NPCs, monsters, dropped items, animated and idle doors. + /// Drawn last with stencil disabled so they're depth-tested against + /// everything else but not stencil-clipped. + LiveDynamic, + } +``` + +- [ ] **R2-S4: Extend the partition logic at the three call sites** + +Edit `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`. Find the partition predicate at lines 360-362 (inside the `landblockVisible == false` branch's animated-entity loop): + +```csharp + // Phase A8: EntitySet partition for indoor/outdoor split passes. + if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue) continue; + if (set == EntitySet.OutdoorOnly && entity.ParentCellId.HasValue) continue; +``` + +Replace with: + +```csharp + // Phase A8: EntitySet partition (taxonomy-aware). + if (!EntityMatchesSet(entity, set)) continue; +``` + +Do the same at lines 375-377 (the main entity-walk loop). Replace: + +```csharp + // Phase A8: EntitySet partition for indoor/outdoor split passes. + if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue) continue; + if (set == EntitySet.OutdoorOnly && entity.ParentCellId.HasValue) continue; +``` + +With: + +```csharp + // Phase A8: EntitySet partition (taxonomy-aware). + if (!EntityMatchesSet(entity, set)) continue; +``` + +And at lines 1358-1359 in `WalkEntitiesForTest`, replace: + +```csharp + if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue) continue; + if (set == EntitySet.OutdoorOnly && entity.ParentCellId.HasValue) continue; +``` + +With: + +```csharp + if (!EntityMatchesSet(entity, set)) continue; +``` + +Then add the shared predicate as a private static method on `WbDrawDispatcher` (place it just above `WalkEntitiesForTest` near line 1344, so all three call sites can reach it): + +```csharp + /// + /// Phase A8 — entity-taxonomy-aware membership test for the three-way + /// EntitySet partition. See for the doctrine. + /// + private static bool EntityMatchesSet(AcDream.Core.World.WorldEntity entity, EntitySet set) + { + if (set == EntitySet.All) return true; + + bool isLiveDynamic = entity.ServerGuid != 0; + if (set == EntitySet.LiveDynamic) return isLiveDynamic; + if (isLiveDynamic) return false; // IndoorPass/OutdoorScenery exclude live-dynamic + + bool isIndoor = entity.ParentCellId.HasValue || entity.IsBuildingShell; + if (set == EntitySet.IndoorPass) return isIndoor; + if (set == EntitySet.OutdoorScenery) return !isIndoor; + + return true; // unreachable; defensive default = include + } +``` + +- [ ] **R2-S5: Run R2 tests to verify they pass** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WbDrawDispatcherEntitySetTests" --nologo +``` + +Expected: 7 tests pass. + +- [ ] **R2-S6: Run full Core.Tests project to verify no regression** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo +``` + +Expected: pass-rate matches the documented pre-existing 14-23 flaky window. No NEW failures attributable to this change. If a test fails referencing `IndoorOnly` / `OutdoorOnly` by name (other than the EntitySetTests we just rewrote), update it inline — those are the renamed-enum references. + +- [ ] **R2-S7: Full build to verify App project compiles** + +```bash +dotnet build -c Debug --nologo +``` + +Expected: build green. + +- [ ] **R2-S8: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 R2 — WbDrawDispatcher.EntitySet taxonomy partition + +Reshapes the dormant EntitySet enum from binary IndoorOnly/OutdoorOnly to +a three-way taxonomy-aware partition: + + IndoorPass — cell mesh + cell statics + building shells + (ParentCellId.HasValue OR IsBuildingShell), live-dynamic + excluded + OutdoorScenery — outdoor scenery only (ParentCellId == null AND + !IsBuildingShell), live-dynamic excluded + LiveDynamic — ServerGuid != 0 (player, NPCs, dropped items) + +Centralizes the membership predicate in EntityMatchesSet to keep the three +call sites (two in WalkEntitiesInto, one in WalkEntitiesForTest) DRY. + +R1's IsBuildingShell flag is now consumed at render time. Integration into +the render frame ships in R3. + +Tests rebuilt from scratch — 7 cases cover the new partition truth table. +Existing dispatcher tests (Tier 1 cache, etc.) continue to pass under the +default EntitySet.All. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task R3: Wire stencil pipeline into the render frame (WB order) + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — render frame inside-camera branch (around lines 7079-7170, depending on where the dispatcher call sits after R2) +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — `IndoorCellStencilPipeline` field + ctor wiring (find the ctor that owns rendering pipelines; today it sits near the `WbDrawDispatcher` instantiation) +- No new tests (GL integration is visual-verification only; the partition logic + stencil math are already covered by existing unit tests from Tasks 1-6) + +- [ ] **R3-S1: Locate the dispatcher field + add IndoorCellStencilPipeline field** + +Grep for the existing `WbDrawDispatcher` field declaration to find the GameWindow ctor's pipeline-init block: + +```bash +grep -n "_wbDrawDispatcher" src/AcDream.App/Rendering/GameWindow.cs | head -5 +``` + +Find the line declaring `private readonly WbDrawDispatcher? _wbDrawDispatcher;` (or similar). Add a sibling field just below it: + +```csharp +private readonly IndoorCellStencilPipeline? _indoorStencilPipeline; +``` + +And in the ctor where `_wbDrawDispatcher` is constructed, instantiate the pipeline. The shader path follows the existing pattern for other shaders in the same ctor (search for `portal_stencil` in source — the dormant infrastructure already references it): + +```csharp +_indoorStencilPipeline = new IndoorCellStencilPipeline( + _gl, + System.IO.Path.Combine(shaderDir, "portal_stencil.vert"), + System.IO.Path.Combine(shaderDir, "portal_stencil.frag")); +``` + +Add a Dispose call in the `Dispose()` method alongside the other pipeline disposes: + +```csharp +_indoorStencilPipeline?.Dispose(); +``` + +- [ ] **R3-S2: Re-wire the render frame inside-camera branch** + +Find the existing render flow in `GameWindow.cs` around the `cameraInsideCell` references (currently lines 7001, 7079, 7118, 7159). The current structure is: + +```csharp +// Step 4: portal visibility — compute BEFORE the UBO upload so +// the indoor flag drives the sun's intensity to zero for dungeons. +var visibility = _cellVisibility.ComputeVisibility(camPos); +bool cameraInsideCell = visibility?.CameraCell is not null; +// ... [unchanged: lighting, audio, fog setup] ... + +// Sky (skipped when inside) +if (!cameraInsideCell) +{ + _skyRenderer?.RenderSky(...); + _particleRenderer?.Draw(..., SkyPreScene); +} + +// Terrain +_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); + +// Conditional depth clear (depth only, keep terrain color) +if (cameraInsideCell) + _gl!.Clear(ClearBufferMask.DepthBufferBit); + +// Animated-id set (unchanged) +HashSet? animatedIds = ...; + +// Single dispatcher call (all entities) +_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: visibility?.VisibleCellIds, + animatedEntityIds: animatedIds); + +// Particles + weather (skipped when inside) +``` + +Replace the entity-draw section (from the dispatcher call down through `animatedIds` build) with this branching version. The terrain draw stays where it is (above the depth clear). After the depth clear: + +```csharp + // L-fix1 (2026-04-28): animated-entity id set (unchanged from + // pre-A8). Required by both the cameraInsideCell branch (to + // route them to LiveDynamic pass) and the outdoor path (where + // it preserves visibility across landblock frustum culling). + HashSet? animatedIds = null; + if (_animatedEntities.Count > 0) + { + animatedIds = new HashSet(_animatedEntities.Count); + foreach (var k in _animatedEntities.Keys) + animatedIds.Add(k); + } + + if (cameraInsideCell && _indoorStencilPipeline is not null + && visibility?.CameraCell is not null) + { + // Phase A8: WB RenderInsideOut order. + // + // 1. Terrain has already drawn (color + depth). + // 2. Depth-clear-if-inside has already cleared depth to 1.0 + // (above this branch). Punch at portal silhouettes is a + // no-op against that 1.0 baseline — left in for symmetry + // with WB's reference and to handle the unusual case + // where depth-clear is later dropped. + // 3. MarkAndPunch — stencil bit 1 at camera-cell exit portals. + // Step 5 (cross-cell-portal visibility via 3-stencil-bit + // pipeline) is DEFERRED — we mark ONLY the camera's own + // cell's portals, not the BFS-extended VisibleCellIds. + // Trade-off: cross-cell visibility loss (rare visually); + // correctness in the common case (no see-through-wall to + // far-side portal openings). + var cameraCells = new[] { visibility.CameraCell }; + _indoorStencilPipeline.UploadPortalMesh(cameraCells); + + var viewProjection = camera.View * camera.Projection; + _indoorStencilPipeline.MarkAndPunch(viewProjection); + + // 4. IndoorPass — cell mesh + cell statics + building shells + // (R1's IsBuildingShell flag drives the partition). + // Stencil OFF (MarkAndPunch's cleanup restored that). + // Depth test normal; building shells write the wall depth + // that protects the indoor from outdoor visibility. + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: visibility.VisibleCellIds, + animatedEntityIds: animatedIds, + set: WbDrawDispatcher.EntitySet.IndoorPass); + + // 5. Stencil-gated outdoor: enable stencil read-only. + _indoorStencilPipeline.EnableOutdoorPass(); + + // 5a. Re-draw terrain — at portal-silhouette pixels only, + // terrain Z (with the f48c74a -0.01 nudge) wins over the + // punched 1.0 depth. Color writes through window. + _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); + + // 5b. Outdoor scenery — same stencil gating. + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: visibility.VisibleCellIds, + animatedEntityIds: animatedIds, + set: WbDrawDispatcher.EntitySet.OutdoorScenery); + + // 6. Stencil OFF — live dynamic entities draw freely with + // depth test only. + _indoorStencilPipeline.DisableStencil(); + + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: visibility.VisibleCellIds, + animatedEntityIds: animatedIds, + set: WbDrawDispatcher.EntitySet.LiveDynamic); + } + else + { + // Outdoor path — unchanged from pre-A8: single dispatcher + // call walks every entity with default partition. + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: visibility?.VisibleCellIds, + animatedEntityIds: animatedIds); + } +``` + +- [ ] **R3-S3: Build to verify the integration compiles** + +```bash +dotnet build -c Debug --nologo 2>&1 | tail -10 +``` + +Expected: green. If the `camera.View * camera.Projection` expression is wrong for the actual `camera` type in scope, substitute with the correct accessor (read 5 lines above the integration point — the rest of the render frame already uses `camera` for view/projection access; mirror that style). + +- [ ] **R3-S4: Run full test suite — must stay within the documented flaky window** + +```bash +dotnet test --nologo +``` + +Expected: failures within the documented 14-23 flaky window only. No new failures attributable to GameWindow.cs changes (GL integration is not unit-tested, but build alone catches compile errors and pre-existing tests catch any unintended logic changes). + +- [ ] **R3-S5: Commit (no visual verification yet — that's R4)** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 R3 — wire stencil pipeline into render frame (WB order) + +Replaces the pre-A8 single dispatcher call with the WB RenderInsideOut +order when cameraInsideCell: + + 1. Terrain draws normally (color + depth) + 2. depth-clear-if-inside (depth = 1.0 globally) + 3. MarkAndPunch — stencil bit 1 at camera's-own-cell exit portals + 4. IndoorPass — cell mesh + cell statics + building shells, stencil OFF + 5. EnableOutdoorPass + re-draw terrain + OutdoorScenery, stencil-gated + 6. DisableStencil + LiveDynamic, depth-test only + +Outdoor (cameraInsideCell == false) path unchanged: single Draw(set: All). + +Step 5 (WB's 3-stencil-bit cross-cell-portal pipeline) is DEFERRED — we +mark only the camera's own cell's exit portals via [visibility.CameraCell], +not the BFS-extended VisibleCellIds. Trade-off documented in +docs/research/2026-05-26-a8-entity-taxonomy.md §"open questions". + +Visual verification at cottage interior / cottage cellar / inn interior / +dungeon is R4. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task R4: Visual verification matrix + +**Files:** none modified in this task; only logs collected and a verification report appended to the plan. + +This task ships nothing on its own — it's the visual-gate before R5 (ship docs). The verification scenarios are chosen because each surfaced different bugs in the original A8 attempt and they exercise different parts of the entity taxonomy. + +- [ ] **R4-S1: Build for verification** + +```bash +dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo +``` + +Expected: green. + +- [ ] **R4-S2: Pre-launch — close any running client; launch** + +PowerShell: + +```powershell +$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue +if ($proc) { + $proc.CloseMainWindow() | Out-Null + if (-not $proc.WaitForExit(5000)) { $proc | Stop-Process -Force } +} +Start-Sleep -Seconds 3 + +$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" + +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | + Tee-Object -FilePath "launch-a8-verify.log" +``` + +(If running via Claude, use `run_in_background: true` so the tool returns immediately.) + +- [ ] **R4-S3: Scenario A — Holtburg cottage interior (ground floor)** + +Walk `+Acdream` into one of the Holtburg cottages (any of the cottages near the network portal). Stand in the middle of the room. + +**Acceptance:** +- All walls SOLID — no see-through to outdoor terrain, no see-through to neighboring cottages. +- Outdoor terrain visible ONLY through windows / open doors. +- Player character body visible (no head-backwards, no missing limbs, no flickering on enter). +- No "transparent rectangles around buildings" regression (#100 stays closed). + +If the wall opposite a window shows outdoor terrain bleed-through, that's the cross-cell-portal issue (deferred WB Step 5); document but **don't** treat as a R4 blocker if it's faint and rare. + +If walls are TOTALLY MISSING (Round 3 regression) — building shells are not being included in IndoorPass. STOP, investigate `EntityMatchesSet` and the hydration propagation in R1-S5; do not proceed. + +- [ ] **R4-S4: Scenario B — Holtburg cottage cellar** + +Walk to a cottage that has a cellar (per #98 saga, the cottage near Holtburg Town's small green; descend the stairs). + +**Acceptance:** +- Cellar walls + floor + ceiling SOLID. +- Cellar stairs SOLID — no grass/terrain overlay through the stair geometry from INSIDE (the in-to-out half of the cellar artifact). +- **Known limitation (NOT an R4 blocker):** grass/terrain may still be visible through the stair geometry when looking from OUTSIDE the cellar (out-to-in half). That's the deep-cell terrain Z-fight artifact noted in the predecessor handoff — NOT A8 scope; file separately in R5. + +- [ ] **R4-S5: Scenario C — Holtburg Inn (multi-room indoor)** + +Walk into the Holtburg inn (the larger building near the town network portal). Move through its rooms. + +**Acceptance:** +- All inn walls SOLID. +- Adjacent rooms not visible through walls (no "I can see the door of the next room" regression from Round 2). +- The inn's interior uses cell mesh more than cottages — confirms the `ParentCellId.HasValue` path in IndoorPass works. +- Furniture (cell statics) visible and properly positioned. + +- [ ] **R4-S6: Scenario D — A dungeon (portal-entry indoor world)** + +Pick any reachable dungeon. The closest from Holtburg is Holtburg Sewer (if mapped on this server) — otherwise the network portal can teleport to any dungeon zone. + +**Acceptance:** +- Corridor walls SOLID. +- Adjacent corridors not visible through walls. +- Lighting reads as indoor (sun zeroed, indoor ambient applied). +- No outdoor stab/terrain leak. + +Dungeons exercise cell mesh + cell statics ONLY (no building shells; dungeons aren't building-baked). If dungeons regress while cottages work, the bug is in the IndoorPass partition predicate's interaction with cell-mesh entities — not the IsBuildingShell flag. + +- [ ] **R4-S7: Graceful close + collect log** + +```powershell +$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue +if ($proc) { + $proc.CloseMainWindow() | Out-Null + if (-not $proc.WaitForExit(5000)) { $proc | Stop-Process -Force } +} +``` + +Append verification notes to the plan or to a follow-up handoff doc: +- Scenario A result: PASS / FAIL with notes +- Scenario B result: PASS / FAIL with notes +- Scenario C result: PASS / FAIL with notes +- Scenario D result: PASS / FAIL with notes + +- [ ] **R4-S8: Gate decision** + +- If all four scenarios PASS or have only documented known limitations → proceed to R5. +- If ANY scenario fails an acceptance criterion → STOP, do not commit ship docs. Open a new investigation (`/investigate` skill) to triage the failure. The taxonomy fix is correct in principle; failures here are integration-detail bugs (GL state, ordering, missed entity class) that need narrow fixes rather than a re-revert. + +--- + +## Task R5: Ship docs (close #78, update CLAUDE.md, file deferrals) + +**Files:** +- Modify: `docs/ISSUES.md` — move #78 to Recently closed; file new ISSUES for the known limitations +- Modify: `CLAUDE.md` — update the A8 paragraph from "REVERTED" → "SHIPPED" + +- [ ] **R5-S1: Update ISSUES.md** + +Read `docs/ISSUES.md` and find the #78 entry (currently OPEN). Move it to "Recently closed" with a commit ref: + +```markdown +**#78 — Outdoor stabs/buildings visible through the rendered floor** — CLOSED by R3 (commit ). Phase A8 re-plan ported WB's RenderInsideOut stencil pipeline with a corrected entity taxonomy (WorldEntity.IsBuildingShell flag distinguishing building shells from outdoor scenery stabs). Visual-verified at cottage interior, cottage cellar, Holtburg inn, dungeon. +``` + +In OPEN issues, file the two known limitations as new issues (assign next sequential numbers — read the doc to find the highest current ID and add to it): + +```markdown +## #102 — Far-side portal visibility through walls (WB Step 5 deferral) + +**Status:** OPEN (low priority; first ship of A8 deferred this). + +**Description:** When standing inside a multi-room building, looking at a wall between rooms, portals on the FAR side of the room (e.g. a doorway opening to outdoors on the other side of the wall) may have their silhouette stencil-marked by Phase A8. This lets outdoor terrain leak through the wall at that silhouette. The first-ship approximation in A8 R3 stencil-marks ONLY the camera's own cell's exit portals (not BFS-extended VisibleCellIds), which AVOIDS the leak in most cases but loses cross-cell-portal visibility. + +**Acceptance:** Inside Holtburg Inn looking at the wall between two rooms, no visible terrain or scenery shows through. WB Step 5's 3-stencil-bit cross-building pipeline is the reference fix. + +**Files:** +- `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` — currently single-bit stencil; would extend to bits 1+2. +- `src/AcDream.App/Rendering/GameWindow.cs` — render frame would gain a per-far-portal pass. + +## #103 — Outdoor-to-indoor cellar terrain Z-fight (out-to-in artifact) + +**Status:** OPEN (low priority; pre-existing). + +**Description:** Looking from OUTSIDE a cottage cellar at the stair geometry from above, grass/terrain may overlap the stair triangles. Pre-existing; not addressed by A8 (no stencil work runs when the camera is outside). #100's 1cm terrain Z nudge is insufficient because cellar geometry sits multiple meters below terrain Z — depth-precision artifacts persist at oblique angles. + +**Acceptance:** From outside a cottage, looking at the cellar entrance, stair geometry reads as solid stone (no grass overlay) regardless of camera angle. + +**Files:** likely a deeper terrain-occlusion mechanism (per-cell terrain mask, or proper outdoor portal culling) — beyond the scope of A8. +``` + +- [ ] **R5-S2: Update CLAUDE.md A8 paragraph** + +Read CLAUDE.md and find the current A8 paragraph. It currently begins "Phase A8 — REVERTED 2026-05-26..." (or similar). Replace with a SHIPPED summary along the lines of: + +```markdown +**Phase A8 — Indoor-cell visibility culling — SHIPPED 2026-05-26.** Closes +issue #78. Five commits across the re-plan: +- R1: `WorldEntity.IsBuildingShell` flag set at `LandblockLoader` from + `LandBlockInfo.Buildings` vs `LandBlockInfo.Objects` — the dat-level + distinction acdream's loader was destroying. +- R2: `WbDrawDispatcher.EntitySet` reshape to taxonomy-aware partition + (`IndoorPass` / `OutdoorScenery` / `LiveDynamic`). +- R3: Render frame re-wired with WB's RenderInsideOut order — MarkAndPunch + before indoor draw; stencil-gated outdoor re-draw; live dynamic last + with stencil disabled. Camera's-own-cell-portals-only approximation + (WB Step 5 deferred as #102). +- Tasks 1-6 infrastructure (`PortalPolygons`, `IndoorCellStencilPipeline`, + `portal_stencil` shaders, dormant `EntitySet` enum) shipped 2026-05-25 + and consumed as-is. + +Visual-verified at Holtburg cottage interior, cottage cellar (in-to-out +half), Holtburg Inn (multi-room), and a dungeon. Two deferrals filed as +#102 (cross-cell-portal far-side visibility) and #103 (cellar terrain +Z-fight from outside). + +Full re-plan: [docs/superpowers/plans/2026-05-26-phase-a8-replan.md](docs/superpowers/plans/2026-05-26-phase-a8-replan.md). +Taxonomy reference: [docs/research/2026-05-26-a8-entity-taxonomy.md](docs/research/2026-05-26-a8-entity-taxonomy.md). +Revert handoff (now historical): [docs/research/2026-05-26-a8-revert-handoff.md](docs/research/2026-05-26-a8-revert-handoff.md). +``` + +Find any other A8 references in CLAUDE.md (e.g. the "Currently working toward" line if it says A8) and update them to reflect ship. + +- [ ] **R5-S3: Commit ship docs** + +```bash +git add docs/ISSUES.md CLAUDE.md +git commit -m "$(cat <<'EOF' +ship(render): Phase A8 — indoor-cell visibility culling SHIPPED + +Closes #78 (outdoor stabs/terrain visible through indoor walls). Files +#102 (cross-cell-portal far-side visibility, WB Step 5 deferral) and +#103 (cellar terrain Z-fight from outside; pre-existing, not A8 scope). + +Visual-verified at Holtburg cottage interior, cottage cellar, Holtburg +Inn, and dungeon. + +Architecture: entity taxonomy partition (WorldEntity.IsBuildingShell +tagged at LandblockLoader) drives WbDrawDispatcher.EntitySet into a +three-way IndoorPass / OutdoorScenery / LiveDynamic split. Render frame +follows WB's RenderInsideOut order with the camera's-own-cell-portals-only +approximation. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-review (run after writing this plan, before execution) + +- **R1 covers:** the IsBuildingShell flag, loader tagging, hydration propagation, two LandblockLoader tests. ✓ +- **R2 covers:** enum reshape, partition predicate, three call sites updated, seven tests covering the truth table. ✓ +- **R3 covers:** pipeline field + ctor wiring, render frame branching, both inside + outside paths. ✓ +- **R4 covers:** four visual scenarios chosen to surface different bug classes (cottage = building shell + cell mesh; cellar = building shell + sloped; inn = multi-room cell mesh; dungeon = cell mesh only). ✓ +- **R5 covers:** ISSUES.md move + two deferral filings, CLAUDE.md ship summary, commit. ✓ + +**Cross-task type consistency check:** `IsBuildingShell` (bool, init-only on WorldEntity) is used consistently in R1 (declaration + set), R2 (EntityMatchesSet predicate), and R3 (no direct reference; goes through the predicate). `EntitySet.IndoorPass` / `OutdoorScenery` / `LiveDynamic` names match across R2 (definition) and R3 (consumption). `_indoorStencilPipeline` is the field name introduced in R3-S1 and referenced through R3-S2. No drift detected. + +**Placeholder scan:** no "TBD" / "implement later" / "similar to" / generic-validation strings. Every code step has actual code. Every command step has an actual command. + +**Spec coverage:** original handoff's pickup prompt Phase 2 expected R1-R6 tasks. This plan ships R1 (entity distinguisher) + R2 (EntitySet partition) + R3 (render frame re-wire) + R4 (visual verification matrix) + R5 (ship docs). R6 (deferred Step 5 etc.) is folded into R5 as filed issues #102 + #103 — no separate task needed since "decide to defer" was already approved in the entity-taxonomy fix-shape. diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs index af53139..6f1b803 100644 --- a/src/AcDream.App/Rendering/CellVisibility.cs +++ b/src/AcDream.App/Rendering/CellVisibility.cs @@ -225,6 +225,12 @@ public sealed class CellVisibility : System.Array.Empty(); } + /// + /// Looks up a currently loaded cell by full 32-bit cell id. + /// + public bool TryGetCell(uint cellId, out LoadedCell? cell) + => _cellLookup.TryGetValue(cellId, out cell); + /// /// Removes all cells belonging to (upper 16 bits of /// the landblock key, e.g. 0xA9B4 for landblock 0xA9B40000). Called when a diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7773441..473b290 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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 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? animatedIds, + System.Collections.Generic.IReadOnlyList visibleBuildings) + { + if (visibleBuildings.Count == 0) + return; + + var gl = _gl!; + var visibleEnvCellIds = new System.Collections.Generic.HashSet(); + 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); + } + /// /// 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>(); 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 _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(); var otherBuildings = new System.Collections.Generic.List(); + var visiblePortalBuildings = new System.Collections.Generic.List(); + System.Collections.Generic.HashSet? 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(); + 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(); + 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. + 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)) diff --git a/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs index 71d65d9..f13dda5 100644 --- a/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs +++ b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs @@ -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). /// - public static Vector3[] BuildTriangles(IReadOnlyCollection cells) + public static Vector3[] BuildTriangles( + IReadOnlyCollection 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; + } + } /// @@ -120,9 +147,11 @@ public sealed unsafe class IndoorCellStencilPipeline : IDisposable /// and uploads it to . Returns the vertex count /// (0 means no exit portals — caller should skip stencil setup entirely). /// - public int UploadPortalMesh(IReadOnlyCollection cells) + public int UploadPortalMesh( + IReadOnlyCollection 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; } + /// + /// Draws the portal mesh most recently uploaded by . + /// The caller owns stencil/depth/color/cull state, matching + /// . + /// + 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; + } + /// /// Steps 1+2 of WB's RenderInsideOut: mark stencil ref=1 wherever /// portal polygons cover, then write gl_FragDepth=1.0 into those diff --git a/src/AcDream.App/Rendering/Shaders/portal_stencil.vert b/src/AcDream.App/Rendering/Shaders/portal_stencil.vert index a10eded..54f71cb 100644 --- a/src/AcDream.App/Rendering/Shaders/portal_stencil.vert +++ b/src/AcDream.App/Rendering/Shaders/portal_stencil.vert @@ -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; } diff --git a/src/AcDream.App/Rendering/Wb/Building.cs b/src/AcDream.App/Rendering/Wb/Building.cs index cb8b242..aaff1b9 100644 --- a/src/AcDream.App/Rendering/Wb/Building.cs +++ b/src/AcDream.App/Rendering/Wb/Building.cs @@ -6,10 +6,9 @@ namespace AcDream.App.Rendering.Wb; /// /// Phase A8 (2026-05-26): a logical building — one or more EnvCells linked /// via the dat-level LandBlockInfo.Buildings entry. Building shells (cottage -/// walls, inn walls — IsBuildingShell=true 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 — IsBuildingShell=true 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. /// /// 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 . public required IReadOnlyList ExitPortalPolygons { get; init; } + /// True when contains at least one + /// exit-portal vertex. Mirrors WB's BuildingPortalGPU.VertexCount > 0 + /// filter before a building participates in outside-in / Step 5 stencil + /// visibility. + public bool HasPortalBounds { get; init; } + + /// World-space AABB of all exit portal polygons. WB's + /// PortalRenderManager.GetVisibleBuildingPortals frustum-culls this + /// box with near-plane ignored before adding the building to the portal + /// visibility list. + public WbBoundingBox PortalBounds { get; init; } + // ------------------------------------------------------------------------- // Step 5 occlusion-query state (mutable, per-frame, RR9 scope). // ------------------------------------------------------------------------- diff --git a/src/AcDream.App/Rendering/Wb/BuildingLoader.cs b/src/AcDream.App/Rendering/Wb/BuildingLoader.cs index cca29f3..b713b0b 100644 --- a/src/AcDream.App/Rendering/Wb/BuildingLoader.cs +++ b/src/AcDream.App/Rendering/Wb/BuildingLoader.cs @@ -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); diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 3bf2071..e902b0f 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -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(); // 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 . /// Source: WB EnvCellRenderManager.cs:247-373 (verbatim). /// - public void PrepareRenderBatches(Matrix4x4 viewProjection, Vector3 cameraPosition, HashSet? filter = null) + public void PrepareRenderBatches( + Matrix4x4 viewProjection, + Vector3 cameraPosition, + HashSet? 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>(); + _activeSnapshotGlobalGfxObjIds = new List(); + 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(); 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) { diff --git a/src/AcDream.App/Rendering/Wb/GroupKey.cs b/src/AcDream.App/Rendering/Wb/GroupKey.cs index 696363c..110a335 100644 --- a/src/AcDream.App/Rendering/Wb/GroupKey.cs +++ b/src/AcDream.App/Rendering/Wb/GroupKey.cs @@ -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); diff --git a/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs index ec16b7c..43ddb68 100644 --- a/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs @@ -25,9 +25,12 @@ namespace AcDream.App.Rendering.Wb; /// /// /// -/// 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. /// /// /// @@ -53,18 +56,15 @@ public sealed class LandblockSpawnAdapter } /// - /// 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. /// 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(); 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); + } } /// diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 10a14f4..2e4acb6 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -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 /// Cell mesh + cell statics ( /// non-null) PLUS building shell stabs ( - /// true, regardless of ParentCellId). These render unconditionally - /// when the camera is inside their building — building shells ARE - /// the indoor walls. Live-dynamic (ServerGuid != 0) is - /// excluded; it flows through . + /// true) whose + /// belongs to the active building cell set. Live-dynamic + /// (ServerGuid != 0) is excluded; it flows through + /// . IndoorPass, - /// Outdoor scenery stabs (ParentCellId == null, - /// !IsBuildingShell) plus procedurally-generated scenery. - /// Drawn stencil-gated to portal silhouettes when the camera is - /// inside. Live-dynamic excluded. + /// Outdoor/top-level stabs (ParentCellId == null), + /// including building shells. Drawn stencil-gated to portal + /// silhouettes when the camera is inside. Live-dynamic excluded. OutdoorScenery, + /// Top-level building shell stabs only, optionally scoped by + /// . Used for + /// portal depth repair without walking the full outdoor scenery set. + BuildingShells, + /// Server-spawned dynamic entities (ServerGuid != 0): /// 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(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). /// /// Indoor entities (ParentCellId set) gated by membership in - /// . Building shells (IsBuildingShell) pass - /// unconditionally when == IndoorPass. Outdoor + /// . 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). /// @@ -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? 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); /// /// Public mirror of the per-group uploaded to the SSBO. @@ -1535,7 +1680,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable public static IndirectLayoutResult BuildIndirectArrays( IReadOnlyList 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 diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 7450640..01e2274 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -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) { /// @@ -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"))); diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 484b44b..f5fa230 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -137,21 +137,55 @@ public sealed class GpuWorldState public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> LandblockEntries + => EnumerateLandblockEntries(includeAnimatedIndex: true); + + /// + /// Per-landblock render entries without the animated lookup dictionary. + /// Static render passes use this to avoid rebuilding an index they cannot + /// consume. + /// + public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> LandblockEntriesWithoutAnimatedIndex + => EnumerateLandblockEntries(includeAnimatedIndex: false); + + /// + /// Lightweight bounds-only enumeration for overlays and diagnostics. + /// Does not walk entity lists. + /// + 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(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 Entities, + IReadOnlyDictionary? AnimatedById)> EnumerateLandblockEntries( + bool includeAnimatedIndex) + { + foreach (var kvp in _loaded) + { + Dictionary? byId = null; + if (includeAnimatedIndex) + { + byId = new Dictionary(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); } } diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs index dfc837d..c5e3681 100644 --- a/src/AcDream.App/Streaming/LandblockStreamJob.cs +++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs @@ -37,15 +37,21 @@ public abstract record LandblockStreamResult(uint LandblockId) ) : LandblockStreamResult(LandblockId); /// - /// 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. /// public sealed record Promoted( uint LandblockId, - IReadOnlyList Entities - ) : LandblockStreamResult(LandblockId); + LoadedLandblock Landblock, + LandblockMeshData MeshData + ) : LandblockStreamResult(LandblockId) + { + public IReadOnlyList Entities => Landblock.Entities; + } public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId); public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId); diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index f71e0c0..143fab7 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -159,6 +159,9 @@ public sealed class LandblockStreamer : IDisposable private void WorkerLoop() { + var highPriority = new Queue(); + var lowPriority = new Queue(); + 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 highPriority, + Queue 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 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) { diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index ac74ae6..f0bc095 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -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: diff --git a/src/AcDream.Core/World/LandblockLoader.cs b/src/AcDream.Core/World/LandblockLoader.cs index c685496..80a4769 100644 --- a/src/AcDream.Core/World/LandblockLoader.cs +++ b/src/AcDream.Core/World/LandblockLoader.cs @@ -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(), 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; + } } diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index 0a70d81..e4fa670 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -56,13 +56,24 @@ public sealed class WorldEntity /// /// /// Read at draw time by WbDrawDispatcher's IndoorPass - /// 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. /// /// public bool IsBuildingShell { get; init; } + /// + /// 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. + /// + public uint? BuildingShellAnchorCellId { get; init; } + /// /// Uniform scale applied to this entity's mesh by the scenery pipeline. /// For scenery objects this is spawn.Scale (typically 0.8–1.3). For stabs diff --git a/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs b/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs index 2f5d2d5..5b5dd9f 100644 --- a/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs +++ b/tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs @@ -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 { visibleInnerCell }); + var allBuildingCells = PortalMeshBuilder.BuildTriangles(new List { 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 { cell }, new Vector3(1, 0, 0)); + var rejected = PortalMeshBuilder.BuildTriangles(new List { cell }, new Vector3(-1, 0, 0)); + + Assert.Equal(3, visible.Length); + Assert.Empty(rejected); + } + [Fact] public void BuildTriangles_TriangulatesAsFan() { diff --git a/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs index 9152231..4edb778 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs @@ -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 + { + new(0xFFFF, 0, 0), + }, + PortalPolygons = new List + { + 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(), + }; + + var info = MakeInfo((0x02000123u, new[] { 0x0150u })); + var reg = BuildingLoader.Build(info, 0xA9B40000u, + new Dictionary + { + { 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); + } } diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs index f31eadd..217d189 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -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()); + Assert.Equal(80, Marshal.SizeOf()); + + 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). // diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index a6e0619..4a6972c 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -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] diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs index 85af235..3182f5f 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs @@ -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()); + 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 diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs index 5b52f57..64406ab 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs @@ -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 { 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 { 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 { CellEnt(0x40000001u, 0xA9B40150u), - BuildingShell(0xC0000001u), + BuildingShell(0xC0000001u, 0xA9B40150u), }; var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds( entities, new HashSet(), 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); } } diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs index f779021..ded923f 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs @@ -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 { 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 { - 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 + { + 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 + { + BuildingShell(0xC0000001, 0xA9B40150u), + CellEnt(0x40000001, 0xA9B40143), + }; + + var visible = new HashSet { 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 { - 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 + { + BuildingShell(0xC0000001, 0xA9B40143u), + BuildingShell(0xC0000002, 0xA9B40999u), + OutdoorScenery(0xC0000003), + CellEnt(0x40000001, 0xA9B40143), + LiveDynamic(0x10000001, serverGuid: 0x50000123u), + }; + + var visible = new HashSet { 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() { diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs index 855a2ef..0d6f977 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs @@ -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 + { + 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] diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs index 24950fd..3f94a5a 100644 --- a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs @@ -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); + } + /// /// Phase Post-A.5 #53 (Task 12): the optional onLandblockUnloaded /// callback fires once when diff --git a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs index 7c5291c..3f8a4af 100644 --- a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs @@ -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(), + System.Array.Empty()); + + using var streamer = new LandblockStreamer( + loadLandblock: (id, kind) => + { + callOrder.Add((id, kind)); + return new LoadedLandblock(id, new LandBlock(), System.Array.Empty()); + }, + 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(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() + }; + + 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(), + System.Array.Empty()); + }); + + streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear); + streamer.Start(); + + var result = await DrainFirstAsync(streamer); + + var promoted = Assert.IsType(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(), + System.Array.Empty()); + + using var streamer = new LandblockStreamer( + loadLandblock: (id, kind) => + { + callOrder.Add((id, kind)); + return new LoadedLandblock(id, new LandBlock(), System.Array.Empty()); + }, + buildMeshOrNull: (_, _) => stubMesh); + + streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.LoadFar); + streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear); + streamer.Start(); + + var result = await DrainFirstAsync(streamer); + + var promoted = Assert.IsType(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 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."); + } } diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs index 2b86b6a..ad32ae0 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs @@ -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() } }); + var promotedMesh = new LandblockMeshData( + System.Array.Empty(), + System.Array.Empty()); + var promoted = new LandblockStreamResult.Promoted( + lbId, + promotedLb, + promotedMesh); var queue = new Queue(); 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); } diff --git a/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs b/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs index 21096dd..c87ecdd 100644 --- a/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs @@ -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] diff --git a/tools/A8CellAudit/A8CellAudit.csproj b/tools/A8CellAudit/A8CellAudit.csproj new file mode 100644 index 0000000..9e78180 --- /dev/null +++ b/tools/A8CellAudit/A8CellAudit.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + latest + + + + + diff --git a/tools/A8CellAudit/Program.cs b/tools/A8CellAudit/Program.cs new file mode 100644 index 0000000..e6f1810 --- /dev/null +++ b/tools/A8CellAudit/Program.cs @@ -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(envCellId); + if (envCell is null) + { + Console.WriteLine("missing EnvCell"); + return; + } + + var envId = 0x0D000000u | envCell.EnvironmentId; + var environment = dats.Get(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(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(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); +}