feat(render): indoor render WORKS — terminating portal flood + every-cell seal + look-in FPS

Checkpoint of the unified retail-faithful indoor render. The two-week HANG/grey is fixed and the
interior seals (live-verified by the user). Commits the session render-rewrite foundation together
with the fixes that made it functional.

- HANG fix: PortalVisibilityBuilder.Build portal flood did not terminate (the faithful ProjectToClip
  near-side clip drifts per round, defeating the CellView dedup; the BFS had no bound after U.2a removed
  MaxReprocessPerCell). Fix = drift-tolerant snapped/canonical CellView.Add dedup (PortalView.cs) plus
  restored MaxReprocessPerCell=16 bounded re-enqueue (PortalVisibilityBuilder.cs). Re-enqueue is kept
  (load-bearing for late-slice propagation, Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit);
  only its count is capped. CellViewDedupTests added.
- Seal (DrawCells Task 2): RetailPViewRenderer.DrawEnvCellShells draws EVERY visible cell via
  IndoorDrawPlan.ShellPass (was gated on the ClipFrameAssembler slot filter, leaving slot-less cells grey).
- Look-in FPS: GameWindow exterior look-in candidates limited to the player landblock +-1 (was all ~81
  loaded LBs iterated every outdoor frame). No behaviour change (far cells were >48m, already culled).

Remaining dominant issue = the FLAP at transitions: viewer-cell metastability (render roots at the
camera-eye cell, which oscillates outdoor-indoor as the 3rd-person boom drifts across the doorway,
confirmed in render-sig). SEPARATE fix, NOT the DrawCells port. Full handoff + flap fix plan + tracked
follow-ups (#78 terrain, look-in-from-inside, look-in FPS, L-spotlight):
docs/research/2026-06-07-indoor-render-session-handoff.md.

Baselines: build 0 err; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-07 10:14:43 +02:00
parent bff1955066
commit 1405dd8e90
27 changed files with 3635 additions and 814 deletions

View file

@ -0,0 +1,135 @@
# Retail PView Indoor Render Pseudocode (2013 EoR)
This note pins the indoor render port to the named retail decomp. The goal is
behavioral fidelity: modern GL renderers may supply the draw calls, but the
frame ownership, visibility graph, and draw order follow these functions.
## SmartBox::RenderNormalMode @ 0x00453aa0
```text
if render device has open scene:
outside = SmartBox::is_player_outside(player position)
seenOutside = outside || viewer_cell.seen_outside
set FOV/view distance
if !outside:
if seenOutside:
LScape::update_viewpoint(lscape, Position::get_outside_cell_id(viewer))
Render::update_viewpoint(viewer)
RenderDeviceD3D::DrawInside(viewer_cell)
else:
LScape::update_viewpoint(lscape, viewer.objcell_id)
Render::update_viewpoint(viewer)
Render::set_default_view()
Render::useSunlightSet(1)
LScape::draw(lscape)
FlushAlphaList()
run targeting/render callbacks
```
Important split: the top-level branch follows `is_player_outside`, while indoor
render calls `DrawInside(viewer_cell)`.
## RenderDeviceD3D::DrawInside @ 0x0059f0d0
```text
PView::DrawInside(RenderDeviceD3D::indoor_pview, viewer_cell)
```
This is a thin forwarder. The PView owns the indoor frame.
## PView::DrawInside @ 0x005a5860
```text
reset object scale
CEnvCell::curr_view_push(root_cell)
PView::add_views(root_cell.num_stabs, root_cell.stab_list)
Frame::cache()
Render::positionPush(root identity frame)
Render::copy_view(root_cell.portal_view[last], null, 4) # full-screen root view
forceClear = PView::ConstructView(root_cell, 0xffff)
PView::DrawCells(forceClear)
Render::framePop()
PView::remove_views(root_cell.num_stabs, root_cell.stab_list)
root_cell.num_view--
```
## PView::ConstructView(CEnvCell*) @ 0x005a57b0
```text
clear outside_view and cell draw/todo state
insert root cell into distance-priority todo list
while todo is not empty:
cell = pop nearest
append cell to cell_draw_list
InitCell(cell, otherPortalId)
project/clip each portal against the current cell view
exit portals append clipped polygons to outside_view
interior portals append clipped polygons to neighbor portal_view
newly discovered neighbors enter the todo list once
return forceClear flag
```
`cell_draw_list` is the only indoor membership source. Later growth can add view
polygons to a discovered cell, but does not create a second draw-list entry.
## PView::DrawCells @ 0x005a4840
```text
if outside_view.view_count > 0:
Render::useSunlightSet(1)
Render::PortalList = this
LScape::draw(lscape) # landscape clipped by outside_view
D3DPolyRender::FlushAlphaList(0)
render_device.frameStamp++
if forceClear || portalsDrawnCount != 0:
render_device.Clear(DepthOnly)
# Loop 1: exit portal masks, reverse cell_draw_list
for cell in reverse(cell_draw_list):
if cell.structure.drawing_bsp:
push cell frame and surfaces
for each current portal_view slice:
CEnvCell::setup_view(cell, slice)
for each exit portal:
DrawPortalPolyInternal(portal polygon)
pop frame
Render::useSunlightSet(0)
Render::restore_all_lighting()
# Loop 2: closed cell shells, reverse cell_draw_list
for cell in reverse(cell_draw_list):
if cell.structure.drawing_bsp:
push cell frame and surfaces
for each current portal_view slice:
CEnvCell::setup_view(cell, slice)
DrawEnvCell(cell)
pop frame
# Loop 3: cell object lists, reverse cell_draw_list
for cell in reverse(cell_draw_list):
Render::PortalList = cell.portal_view[last]
DrawObjCellForDummies(cell)
restore object scale
Render::useSunlightSet(1)
```
There is no global indoor object, terrain, sky, weather, or particle pass. Every
visible indoor object comes from the cell draw list, and the landscape appears
only through `outside_view`.
## RenderDeviceD3D::DrawObjCellForDummies @ 0x005a0760
```text
for object in cell.object_list:
draw object under Render::PortalList
attached effects/particles follow the owning object visibility
```
acdream maps this to per-cell `WorldEntity.ParentCellId` buckets. Parentless
live objects must not bypass the indoor PView graph.

View file

@ -0,0 +1,163 @@
# Indoor render HANG — root cause: `PortalVisibilityBuilder.Build` non-termination — 2026-06-06
> Report-only investigation (user chose "investigate more first"). **No code changed.**
> Worktree `thirsty-goldberg-51bb9b`. This blocks the verbatim-DrawCells port's Task 2
> visual gate: every indoor frame can freeze here.
## Symptom
Three launches of the client all **froze** (`AppHangB1`, Windows Event Log) within
seconds-to-minutes of the camera being indoors at the Holtburg cottage. Not a crash —
no access violation, no managed exception. The captured managed stack of the frozen
render thread (`hang-stack.txt`, via `dotnet-stack`) shows it **CPU-spinning**:
```
CPU_TIME
CellView.Add(ViewPolygon)
PortalVisibilityBuilder.AddRegion(CellView, List<ViewPolygon>)
PortalVisibilityBuilder.Build(...)
RetailPViewRenderer.DrawInside(...)
GameWindow.OnRender(...)
```
App.Tests 207/207 and Core 1331/4/1 are green; the bug is invisible to the suite (see §Evidence).
## Verdict
**It is NOT Task 2 (the verbatim-DrawCells / grey fix).** `Build(...)` runs at the very
top of `DrawInside` ([RetailPViewRenderer.cs:43](../../src/AcDream.App/Rendering/RetailPViewRenderer.cs)),
**before** any line Task 2 touched, and the call is byte-identical pre/post-change. Task 2's
draw logic was independently confirmed correct in the run-1 log: `[render-sig] draw=[…]`
equalled `ids=[…]` with `miss=[]`, and `[shell]` showed every visible cell drawing textured
(`zh=0`). The grey fix works.
**Root cause:** `PortalVisibilityBuilder.Build`'s portal BFS does not terminate for real
cottage geometry. It **re-enqueues a popped cell every time that cell's `CellView` grows**:
`queued.Remove(cell.CellId)` on pop ([:122](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs))
+ `if (grew && queued.Add(neighbourId))` on grow ([:289](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)).
Termination therefore depends entirely on growth stopping. Growth is gated only by
`CellView.Add`'s **exact-match dedup** (`SamePolygon`, eps `1e-4`,
[PortalView.cs:79](../../src/AcDream.App/Rendering/PortalView.cs)). The **near-side portal clip**
(`ClipPortalAgainstView``PortalProjection.ProjectToClip``ClipToRegion`,
[:474/:485](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)) produces a polygon
that is a hair different on each `A↔B` reciprocal round (float drift through the homogeneous
project→clip round-trip with a non-identity cell transform). The dedup never matches the
drifted near-duplicate → the region grows without bound → the cell re-enqueues forever →
`CellView.Polygons` grows to N, and `CellView.Add`'s O(N) dedup scan makes the whole thing
O(N²) → frozen.
## Evidence
1. **Captured stack** pins the spin to `CellView.Add ← AddRegion ← Build`, pure managed
`CPU_TIME` (not a GL call, not blocked, not a fault).
2. **The code already documents this exact failure** at
[:694-697](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs): the *reciprocal*
clip deliberately stays on the float-stable `ProjectToNdc` path *because*
"per-round float drift defeated the CellView SamePolygon dedup, inflating a tight A<->B
reciprocal view to ~4x its area." The **near-side** clip ([:474](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs))
did not get the same treatment — it uses `ProjectToClip`.
3. **The only bound was removed this session.** [:74](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs):
"Fixpoint termination replacing the old `MaxReprocessPerCell` hard cap." The fixpoint never
converges under drift; with the cap gone there is no other bound (no iteration cap, no
max-polygon cap, no time bound).
4. **It's the dirty-tree rewire the handoff said to KEEP.** `git diff --stat`:
`PortalVisibilityBuilder.cs +426/45` and `PortalProjection.cs +111` are **uncommitted**.
`ProjectToClip` is part of the new `PortalProjection` lines. The handoff
(`2026-06-06-verbatim-drawcells-port-pickup-handoff.md`) lists this rewire as the faithful
foundation to preserve and says "the clip math is already faithful — do not harden the
w-clip." The clip is faithful in the *picture* it computes; it is the *non-termination*
that is broken.
5. **Why the suite is green:** `PortalVisibilityBuilderTests` build cells with
`WorldTransform = Matrix4x4.Identity` and axis-aligned quads in 2-cell **chains**
(`cam → ground → exit`). No `A↔B` cycle, no transform-induced drift → the project→clip
round-trip is exact → the dedup collapses duplicates → the BFS converges. The real cottage
is a **cyclic** cell cluster (`0x016F0x0175`, mutual portals) with **non-identity**
transforms → drift + cycle → non-termination. The suite cannot reach the failing case.
6. **Why run 1 survived 113 frames then froze:** `Build` converges at most camera poses; only
specific poses create the non-converging drift cycle. The freeze coincided with the
metastable doorway flip (`[render-sig] stable` went 39→0, visible-cell count 5→4) one frame
before the log ended.
## Hypotheses (ranked)
1. **(confirmed)** Non-terminating BFS: re-enqueue-on-grow + `ProjectToClip` drift defeats the
`SamePolygon` dedup → unbounded `CellView` growth. Falsify: a re-process cap, a
drift-tolerant dedup, or `ProjectToNdc` on the near-side clip all make `Build` terminate.
2. *(ruled out)* GPU/driver hang from a malformed draw — the stack is pure managed `CPU_TIME`
in `CellView.Add`, never a GL call; no fault.
3. *(ruled out)* Probe-output stdout saturation — disproven: the probe-free run also hung.
4. *(ruled out)* Task 2 — `Build` is upstream of every Task 2 line and unchanged by it.
## Fix options (all additive — none reverts the dirty tree)
| | Fix | Touches | Pro | Con |
|---|---|---|---|---|
| **A** *(rec.)* | **Drift-tolerant dedup**: round clipped polygon vertices to a small grid (≈`1e-3`) before `AddRegion`, or widen/snaps `SamePolygon`'s match, so near-duplicates collapse → growth converges. | `CellView`/`AddRegion` | Fixes the actual root cause ("drift defeats dedup"); keeps the faithful `ProjectToClip`; preserves growth-propagation. ~10 lines. | Tolerance is a tuning constant (pick conservatively; over-merge = minor over-tighten). |
| **B** | **Restore a re-process bound** (`MaxReprocessPerCell`-style cap on the BFS). | `Build` loop | Smallest; guarantees termination; doesn't touch clip. | A guard, not a root fix; may under-include a late-growing view. The user's "no workarounds" rule applies — this is the band-aid. |
| **C** | **Near-side clip on `ProjectToNdc`** (what the reciprocal clip already uses). | `ClipPortalAgainstView` | Removes the drift source directly; consistent with `:694`. | Steps on this session's homogeneous near-eye clip work; the handoff's "don't harden the w-clip" is closest to here. |
**Recommended next step:** approve **A** (drift-tolerant dedup) — it closes the precise
mechanism the code half-acknowledges at `:694`, terminates structurally, and leaves the
faithful clip path intact. Implement in a follow-up (not report-only) session, then re-run the
Task 2 visual gate (probe-free) at the cottage + cellar.
## What this is NOT
- **NOT** Task 2 / the grey fix — that is verified working (`draw==ids`, `miss=[]`, textured shells).
- **NOT** a wrong-pixels / unfaithful-projection bug — it's a **termination** bug. The handoff's
"the clip math is faithful, don't harden the w-clip" is about projection *correctness*; this is
BFS *convergence*. Don't chase the w-clip.
- **NOT** a GPU/shader/driver hang and **NOT** the probe firehose (both ruled out by the stack
and the probe-free repro).
---
## Reassessment — is the dirty-tree builder rewire sound? (post Option A)
Option A (drift-tolerant `CellView.Add` dedup, `CellViewDedupTests` green) was implemented and the
client relaunched. Result: the hang **moved out of `CellView.Add`** (A worked for its target) but
**relocated to `ScreenPolygonClip.ClipByEdge`** via `ApplyReciprocalClip` (second captured stack,
`hang-stack2.txt`). `ScreenPolygonClip.Intersect`/`ClipByEdge` are **both bounded `for` loops**
they cannot spin on one call — so the spin is the **outer `Build` BFS** still not terminating and
calling them a runaway number of times. **Option A is necessary but not sufficient.**
### Git evidence (what the dirty rewire changed re: termination)
- **HEAD (committed)** near-side portal clip = `PortalProjection.ProjectToNdc` (float-stable;
`git show HEAD:` line 146). **The dirty rewire switched it to `ProjectToClip`** (`ClipPortalAgainstView`,
dirty line 474) — the homogeneous near-eye clip, introduced to fix the near/grazing-doorway flap/void.
- The `MaxReprocessPerCell` **hard cap was removed earlier** (committed Phase U.2a `d880775`), replaced
by "fixpoint termination." **Neither HEAD nor the dirty tree has a hard iteration bound.**
- The dirty rewire's own comment (`PortalVisibilityBuilder.cs:519-522`) documents that
`ProjectToClip` "produced per-round float drift that defeated the CellView SamePolygon dedup" — and
applied that lesson **only to the reciprocal clip** (kept on `ProjectToNdc`), leaving the **near-side**
clip on the drift-prone `ProjectToClip`.
### Soundness verdict
The builder's termination model is **unsound by construction.** It relies on the clipped regions
reaching a geometric fixpoint — re-clipping a cell's view reproduces *exactly-equal* polygons that the
dedup recognises — with **no hard iteration bound.** That only holds if the clip is float-stable.
`ProjectToClip` (needed for faithful near-doorway projection) injects per-round drift, so re-clipping
never reproduces an exactly-equal polygon, the dedup never catches it, and the re-enqueue-on-grow flood
never converges → infinite loop. **You cannot have BOTH faithful near-doorway projection (`ProjectToClip`)
AND convergence-via-exact-dedup-without-a-bound.** HEAD got away with it because `ProjectToNdc` was
stable enough to converge (and it sealed — user-verified); the dirty switch tipped it into non-termination.
The rewire fixed the *projection* and, apparently never having been launched, shipped a hang.
A's drift-tolerant dedup *narrows* the gap but cannot *close* it: for some geometry the per-round drift
exceeds any fixed snap grid, so growth still produces new keys forever. Only a **hard bound** guarantees
termination.
### Paths (for the user to choose)
| | Path | Termination | Projection fidelity | Risk |
|---|---|---|---|---|
| **1** *(rec.)* | Keep `ProjectToClip` + add **enqueue-once** bound (D) — the builder's own comment already calls enqueue-once "the hard termination guarantee"; the re-enqueue-on-grow is the bug. Keep A. | Guaranteed (≤N pops) | Full (faithful doorway clip kept) | Minor under-inclusion of late growth → visual-verify; widen to a cap if needed |
| **2** | Keep `ProjectToClip` + add a **re-process cap** (B, restore `MaxReprocessPerCell`). Keep A. | Guaranteed (≤N×K) | Full | Less faithful than enqueue-once; a tuning constant |
| **3** | **Revert** the near-side `ProjectToClip → ProjectToNdc` (back to HEAD). | Restored (HEAD converged) | **Loses** the rewire's near-doorway fix → reintroduces the flap/void (separate bug) | Throws away this session's projection work; contradicts the keep-the-dirty-tree directive |
A bound (paths 1/2) is the sound fix: it makes termination independent of clip drift, so the faithful
`ProjectToClip` projection AND guaranteed termination coexist. **Recommendation: path 1** (enqueue-once +
keep A), visual-verify for under-inclusion. Reverting (path 3) only trades the hang back for the
flap/void.

View file

@ -0,0 +1,589 @@
# Handoff - M1.5 Indoor Render / Retail PView Replacement Attempts - 2026-06-06
This is a **stop-and-handoff** note for the next agent. It records what was tried, what changed on disk, what the user still sees, and what evidence should drive the next step.
The user explicitly stopped this thread after repeated visual regressions. Do **not** continue the same patching loop. Treat all current uncommitted render work as suspect until re-audited against named retail.
## Worktree And Rules
- Worktree: `C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b`
- Branch: `claude/thirsty-goldberg-51bb9b`
- Starting HEAD called out by the user: `8116d10`
- Do **not** branch or create a new worktree.
- Do **not** push without asking.
- Never run `git stash` or `git gc`.
- PowerShell on Windows.
- Launch logs are UTF-16.
- Build before launching.
- Use `apply_patch` for manual edits.
- Do not revert dirty changes unless the user explicitly asks.
Current child handoff thread created before this file:
- Child thread id: `019e9d5c-bb34-7fe3-85cc-6b9065b4e882`
- It was forked same-directory, not a new worktree.
- A follow-up prompt was sent there with the immediate evidence and constraints.
## User-Visible State At Stop
The latest user report, after the most recent relaunch:
- Transition flaps still happen between outdoor/indoor, room/room, and cellar.
- Ground floor became transparent instead of sealed.
- Cellar remains broken.
- Prior screenshots showed grey or black background filling cell openings.
- Prior screenshots showed indoor walls losing texture or drawing as clear/background color.
- Prior screenshots showed character cut in half on the cellar stairs.
- User explicitly says we are back to old bugs and nothing feels solid.
Important: **do not claim any current code is fixed**. Build/tests passed for some pieces, but visual acceptance failed.
## Current Dirty State
`git status --short --branch` showed these tracked files modified:
- `src/AcDream.App/Rendering/ClipFrameAssembler.cs`
- `src/AcDream.App/Rendering/ClipPlaneSet.cs`
- `src/AcDream.App/Rendering/GameWindow.cs`
- `src/AcDream.App/Rendering/InteriorEntityPartition.cs`
- `src/AcDream.App/Rendering/InteriorRenderer.cs`
- `src/AcDream.App/Rendering/ParticleRenderer.cs`
- `src/AcDream.App/Rendering/PortalView.cs`
- `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`
- `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs`
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
- `src/AcDream.Core/Rendering/RenderingDiagnostics.cs`
- `src/AcDream.Core/World/WorldEntity.cs`
- `tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs`
- `tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs`
- `tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs`
- `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs`
- `tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs`
- `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs`
- `tools/TextureDump/Program.cs`
Important untracked files include:
- `src/AcDream.App/Rendering/RetailPViewRenderer.cs`
- `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md`
- many probe logs and local scripts/images, including `launch-flap-shell-capture-relaunch.log`, `launch-pview-watermark-probe.log`, `a8-current-room-cellar-audit.txt`, `texture-current-room-surfaces.txt`, `analyze_*.py`, `retail-*-trace.log`, and several screenshots.
Diff size before this handoff file:
- 19 tracked files changed.
- About 1593 insertions and 773 deletions.
## Validation That Passed But Did Not Prove Visual Correctness
After the last attempted PortalVisibilityBuilder patch:
```powershell
dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter "FullyQualifiedName~PortalVisibilityBuilderTests|FullyQualifiedName~PortalProjectionTests"
```
Passed: 29/29.
```powershell
dotnet build -c Debug --no-restore
```
Succeeded, with 9 known warnings.
```powershell
dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build
```
Passed: 196/196.
These results only prove the pure/tested slices compile and pass. They did **not** solve the live render.
## Retail PView Reference Already Written
New pseudocode note exists:
- `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md`
It summarizes:
- `SmartBox::RenderNormalMode @ 0x00453aa0`
- `RenderDeviceD3D::DrawInside @ 0x0059f0d0`
- `PView::DrawInside @ 0x005a5860`
- `PView::ConstructView @ 0x005a57b0`
- `PView::DrawCells @ 0x005a4840`
- `RenderDeviceD3D::DrawObjCellForDummies @ 0x005a0760`
Core retail model from that note:
- Outdoor: `LScape::draw`, then portal/interior peering through PView portal paths.
- Indoor: `DrawInside(viewer_cell)`.
- `PView::ConstructView` builds `cell_draw_list`, per-cell `portal_view`, and `outside_view`.
- `PView::DrawCells` draws outside landscape through `outside_view`, then reverse `cell_draw_list` exit masks, reverse shells, reverse object lists.
- No global indoor terrain/entity/particle pass should bypass PView membership.
## Retail Functions That Still Matter
Re-read named retail before more code:
- `PView::AddViewToPortals @ 0x005a52d0`
- `PView::ConstructView @ 0x005a57b0`
- `PView::ClipPortals @ 0x005a5520`
- `PView::FixCellList @ 0x005a5250`
- `PView::AdjustCellView @ 0x005a5770`
- `PView::OtherPortalClip @ 0x005a5400`
- `PView::GetClip` around `0x005a4320`
- `SmartBox::RenderNormalMode @ 0x00453aa0`
- `SmartBox::update_viewer @ 0x00453ce0`
- `RenderDeviceD3D::DrawObjCellForDummies @ 0x005a0760`
The critical retail detail not faithfully settled yet:
- Retail tracks `view_count` and `update_count`.
- When a cell view grows after the cell was already processed, retail calls `FixCellList` / `AdjustCellView`.
- Current acdream code only approximates this. It may not match draw-list ordering or downstream propagation.
## What We Tried
### 1. Treated symptoms as separate render leaks
The session started with symptoms that looked separate:
- dynamic objects and particles visible through ground when looking out from inside;
- outside ground texture covering cellar entrance when looking in;
- grey flaps when crossing cell boundaries;
- missing cellar floor / grey cellar;
- transparent or textureless interior walls.
The user correctly pushed back that these are probably one render-pipeline failure: indoor/outdoor, cells, shells, terrain, objects, particles, and doors must all agree on one visible-cell graph.
### 2. Gated dynamic objects and particles by ownership
Attempt:
- `WorldEntity.ParentCellId` was populated for player/spawns/teleports/motion updates.
- `InteriorEntityPartition` was changed so live dynamic entities with an indoor `ParentCellId` go into their cell bucket instead of a global live-dynamic overlay.
- `WbDrawDispatcher.ResolveEntitySlot` was changed so `ServerGuid != 0` no longer always means "draw unclipped indoors".
- Particles were moved toward PView-scoped / owner-scoped behavior instead of a global indoor scene pass.
Effect:
- User reported this stopped much of the obvious dynamic-object/particle bleeding when looking out.
- It did **not** fix grey/background transition flaps.
- It did **not** fix cellar/floor/walls.
Current risk:
- This direction is probably correct, but the exact routing must be audited. A later attempt also cleared clip routing to avoid character/shell cutting, so "PView membership" and "GPU clip slot routing" are currently mixed/confused.
### 3. Added/used a `RetailPViewRenderer`
Attempt:
- Added `src/AcDream.App/Rendering/RetailPViewRenderer.cs`.
- Moved part of indoor draw orchestration into `RetailPViewRenderer.DrawInside`.
- Added `DrawPortal` for outdoor-looking-in through `PortalVisibilityBuilder.BuildFromExterior`.
- The renderer currently does:
- `PortalVisibilityBuilder.Build`
- `ClipFrameAssembler.Assemble`
- `_envCells.PrepareRenderBatches(filter: drawableCells)`
- `InteriorEntityPartition.Partition`
- landscape through outside slices
- exit masks
- EnvCell shells
- object buckets
Effect:
- This is not a full retail replacement yet.
- User repeatedly saw unchanged or worse symptoms.
- FPS was reported drastically down after one iteration.
- Subsequent attempts produced missing textures / white or grey wall panels.
Current risk:
- `RetailPViewRenderer` is not truly verbatim retail. It keeps modern infrastructure and approximates PView with GPU clip slots and callbacks.
- The user asked "have you ported retail verbatim?" and the honest answer remains no.
- `GameWindow` still has a lot of orchestration, diagnostics, and render routing around this. It is not a small caller yet.
### 4. Reworked `ClipFrameAssembler` from one clip per cell to per-slice clip slots
Attempt:
- `ClipFrameAssembler` was rewritten toward per-polygon/slice output:
- `CellIdToViewSlices`
- `OutsideViewSlices`
- per-slice `ClipViewSlice`
- `TerrainClipMode` for outside-view landscape
- The goal was to represent retail `portal_view` slices more closely.
Effect:
- The code is plausible as a draw assist, but it is not retail membership.
- User saw regressions including black covers during transitions.
Current risk:
- The next agent must ensure `ClipFrameAssembler` never decides PView membership.
- It should be draw-assist only.
- Several failures looked like GPU clip slots cutting shells or characters at door/stair boundaries.
### 5. Disabled clip routing for shells/entities to stop character/stair cutting
Attempt:
- `RetailPViewRenderer.UseIndoorMembershipOnlyRouting` clears `_envCells.SetClipRouting(null)` and `_entities.ClearClipRouting()`.
- Comment says retail portal views decide eligibility, but feeding those 2D views into GL clip distances slices characters and shells at stair/door boundaries.
Effect:
- This was a reaction to user screenshots where the character was cut in half on stairs.
- It may reduce character slicing.
- It may also mean shells/objects are currently only membership-gated, not portal-view clipped.
Current risk:
- This is not a settled retail copy. It is an emergency compromise.
- Retail does use per-view setup (`CEnvCell::setup_view`) around shell/object drawing. We need to know whether our GL clip-plane model is simply the wrong mechanism for that setup.
### 6. Tried EnvCell / DAT polygon side handling changes
Attempt:
- `ObjectMeshManager` changed CellStruct polygon side handling:
- DAT `CullMode` interpreted as retail `CPolygon::sides_type`.
- `0 = pos`
- `1 = pos twice with reversed winding`
- `2 = pos + neg surface`
- `NoPos` / `NoNeg` still suppress faces.
- Added explicit normal inversion / winding reversal logic.
Effect:
- User saw missing textures/white/grey interior panels after some launches.
- The attempt did not fix the cellar or transition flaps.
Current risk:
- This may be correct retail interpretation or may be partially wrong.
- Audit with DAT dumps and retail/ACME references before keeping.
- `a8-current-room-cellar-audit.txt` and `texture-current-room-surfaces.txt` may contain useful surface/cell evidence.
### 7. Tried outside-looking-in via `BuildFromExterior`
Attempt:
- `PortalVisibilityBuilder.BuildFromExterior` seeds interior cell views through outside-facing exit portals.
- `RetailPViewRenderer.DrawPortal` calls it from outdoor branch.
- Tests were added:
- seeds interior cell through outside portal;
- does not seed when camera is on interior side;
- traverses deeper interior portals;
- max seed distance skips distant exit portal.
Effect:
- User initially reported walls became visible looking in from outside, but ground/cellar entrance composition stayed wrong.
- Later launches regressed to transparent/grey panels and missing textures.
Current risk:
- This is probably needed, but the exterior portal path is not proven retail-faithful.
- `BuildFromExterior` may now have duplicated-looking test diff context; inspect file carefully.
### 8. Tried broad "no hybrid" render routing in `GameWindow`
Attempt:
- `GameWindow` was changed so indoor path should call `RetailPViewRenderer.DrawInside`.
- Outdoor path should draw world and call `DrawPortal`.
- Global indoor terrain/entity/particle passes were reduced or bypassed.
- New render signature diagnostics log:
- `branch`
- `root`
- `viewerRoot`
- `playerRoot`
- `viewerCell`
- `playerCell`
- `gate`
- `terrain`
- `skyGate`
- `zclear`
- `sceneParticles`
- `outSlices`
- `outPolys`
- `ids`
- `draw`
- object partition counts
Effect:
- User explicitly asked whether the hybrid was totally gone.
- It is not safe to answer "yes" without auditing `GameWindow`.
- Symptoms persisted, so either the routing is still hybrid or the PView graph/draw setup is wrong enough that "no hybrid" alone does not solve it.
Current risk:
- `GameWindow.cs` has a very large diff, around 1000 lines touched.
- Next agent should not blindly keep it.
- Audit all remaining global passes while `clipRoot != null`.
### 9. Tried PView `update_count`-style reprocessing
Attempt in the last aborted step:
- `PortalView.CellView.Add` now returns `bool` and deduplicates near-identical polygons.
- `PortalVisibilityBuilder.Build` replaced `seen` with:
- `queued`
- `drawListed`
- `processedViewCounts`
- A cell can be requeued when its view grows.
- Each processing pass clips portals against only newly added view polygons.
- Similar logic was added to `BuildFromExterior`.
- Added tests:
- `Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour`
- `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit`
Effect:
- Focused tests passed.
- Live probe after patch still showed `outPolys` toggling near root `0xA9B40172`.
- User then reported transition flaps still there and now ground floor transparent.
Current risk:
- This patch is **unaccepted** and may be wrong.
- It approximates retail `update_count`, but does not necessarily implement `FixCellList`, `AdjustCellPlace`, or retail draw-list ordering correctly.
### 10. Widened eye-standing-in-portal fallback
Attempt:
- `EyeStandingPerpDist` widened from `0.5f` to `1.75f`.
- Motivation: live cellar capture had `0174 -> 0175` traversable with `D=-1.41` but `ProjectToNdc` returned zero vertices.
- The fallback still requires the perpendicular projection to land inside the portal opening.
Effect:
- It made one unit test pass for the cellar-style collapsed portal.
- It did not solve live transitions.
Current risk:
- This may be a bandaid, not retail.
- It should be validated against `OtherPortalClip` / `GetClip` in named retail before keeping.
### 11. Tried reciprocal clip fallback for eye-in-opening
Attempt:
- Before `ApplyReciprocalClip`, code clones `clippedRegion` when `eyeInsideOpening`.
- If reciprocal clipping empties the region, it restores the pre-reciprocal region.
Effect:
- Tests passed.
- Live visual did not.
Current risk:
- This may over-include.
- It is not proven retail-faithful.
## Critical Evidence From Logs
### Cellar startup: root 0174 only sees itself
From `launch-flap-shell-capture-relaunch.log`:
```text
[flap] root=0xA9B40174 eye=(154.50,4.99,92.25) localEye=(7.43,2.51,-1.77) |
p0->0x0175 D=-1.41 TRV proj=0 clip=-1 || outPolys=0 vis=1
[flap-cam] root=0xA9B40174 viewerCell=0xA9B40174 playerCell=0xA9B40174
... terrain=Skip outVisible=False
[render-sig] frame=49 branch=RetailPViewInside root=0xA9B40174
... terrain=Skip/skip sky=n zclear=n sceneParticles=none
outSlices=0 outPolys=0 ids=[0xA9B40174] draw=[0xA9B40174]
```
Meaning:
- The player/viewer/root are in cellar cell `0174`.
- The only visible portal to stair connector `0175` is traversable.
- Projection produces zero vertices.
- The PView flood stops at the cellar.
- Only the cellar draws; stair/main-floor cells are not in the visible set.
This is a direct candidate cause for missing floor/grey composition.
### Root 0172: outside view toggles on/off
From `launch-pview-watermark-probe.log`:
```text
frame=3625 root=0xA9B40172
p0->0x0173 D=... TRV proj=4 clip=4
p1->0x016F D=5.28 TRV proj=0 clip=-1
outPolys=1 vis=6
ids include 0xA9B40170
terrain=Skip/draw sky=Y zclear=Y
frame=3626 root=0xA9B40172
p0->0x0173 D=... TRV proj=5 clip=5
p1->0x016F D=5.39 TRV proj=0 clip=-1
outPolys=0 vis=5
ids missing 0xA9B40170
terrain=Skip/skip sky=n zclear=n
frame=3647 root=0xA9B40172
outPolys=1 ids include 0xA9B40170 terrain draw
frame=3648/3649 root=0xA9B40172
outPolys=0 ids missing 0xA9B40170 terrain skip
```
Meaning:
- The same root cell can alternate between seeing outside and not seeing outside.
- `0x016F` is involved in the root flap line but projects to zero.
- Sometimes `0x0170` becomes reachable and outside terrain/sky/depth clear run; sometimes it disappears.
- The visible-cell list and outside-view list are not stable.
Open question:
- Is `0x016F` an outdoor/land cell, an env cell lookup miss, or a portal that retail handles differently?
- Is the toggling caused by projection/clip degeneracy, wrong portal reciprocal handling, update-count propagation, or camera/viewer-cell root?
### Earlier known evidence: root 0171 vs player 0174 contradiction
From the older `2026-06-05-shell-sealing-cellar-floor-handoff.md`:
```text
[flap-cam] root=0xA9B40171 viewerCell=0xA9B40171 playerCell=0xA9B40174
...
[flap] root=0xA9B40171 ... p1->0173 proj=0 ...
```
Meaning:
- Earlier, camera/root was the room while player was cellar.
- The flood did not seal the player's cell.
- Later, after branch/viewer changes, there are also frames where root/player/viewer are all `0174` but the flood still fails on `0174 -> 0175`.
This means the problem is probably not only "wrong root"; it also includes projection/portal traversal/flood propagation or mesh-shell handling.
## What Not To Retry Blindly
Do not simply:
- switch the root to player cell as a workaround;
- widen `EyeStandingPerpDist` further;
- globally draw all indoor shells;
- globally draw terrain/entities/particles while inside;
- turn off all clipping and hope depth sorts it;
- keep adding `if cellar` or Holtburg-cottage-specific handling;
- claim "no hybrid" without auditing all `GameWindow` indoor/outdoor passes;
- equate unit-test pass with visual correctness.
The user has explicitly asked for retail smoothness, not a new patch stack.
## Likely Root Problem Space
The next fix probably lives in one of these, but evidence must decide:
1. **PView graph construction is not retail-faithful.**
- Missing or wrong `update_count` / `FixCellList` / `AdjustCellView`.
- Wrong draw-list ordering when a processed cell receives new views.
- Downstream portal propagation incomplete.
2. **Portal projection/clip behavior differs from retail.**
- `0174 -> 0175` traversable but `proj=0`.
- `0172 -> 016F` traversable but `proj=0`.
- `OtherPortalClip` / `GetClip` may not match retail.
3. **Outdoor/exit portal classification is wrong.**
- `OtherCellId=0xFFFF` is treated as exit/outside, but `0x016F` may be another kind of outside/land portal or missing env cell.
- OutsideView may be created through a downstream path that acdream sometimes drops.
4. **Renderer draw setup is still hybrid or ordered wrong.**
- `GameWindow` may still draw or skip global passes inconsistently.
- Sky/terrain/depth clear decisions are visibly flapping with `outside_view`.
5. **EnvCell shell mesh/surface handling is wrong.**
- Missing/transparent/white walls and floors may be mesh/surface/cull-side regressions.
- Audit `ObjectMeshManager` side handling against retail and DAT dumps.
6. **GPU clip slots are being used as membership or hard clipping when retail uses view setup differently.**
- Character cut in half on stairs strongly suggests hard clip-plane use on avatars/shells is wrong or applied at wrong pass.
## Suggested Next Procedure
1. Stop patching. Inspect the dirty diff first.
2. Read `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md`.
3. Re-read named retail functions listed above.
4. Parse `launch-flap-shell-capture-relaunch.log` and `launch-pview-watermark-probe.log` around the cited frames.
5. Add better probes if needed:
- cell id;
- portal index;
- other cell id;
- portal flags;
- other portal id;
- traversable decision;
- standing distance;
- projection vertex count;
- clip vertex count;
- reciprocal clip result;
- outside-view add/skip reason;
- cell view count / processed count / update count;
- queue/requeue reason;
- draw-list insertion/reorder.
6. Decide from evidence whether `0174 -> 0175` and `0172 -> 016F` fail because of projection, reciprocal clip, cell lookup/classification, or update propagation.
7. Patch only the retail mismatch.
8. Build/test before launch.
9. Launch with probes once.
10. Then launch clean for FPS/visual feel.
11. Do not call it done until the user visually confirms retail smoothness.
## Launch Command
Use PowerShell:
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | 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"
$env:ACDREAM_PROBE_FLAP = "1"
$env:ACDREAM_PROBE_SHELL = "1"
$env:ACDREAM_PROBE_VIS = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch-next-pview.log"
```
For clean visual/FPS run, remove the probe env vars.
## Minimal Prompt For Next Agent
```text
Continue acdream M1.5 indoor render in SAME worktree:
C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b
branch claude/thirsty-goldberg-51bb9b. Do NOT branch/worktree. Do NOT push. NEVER stash/gc.
The current dirty render code is not visually accepted. The user stopped the prior agent after repeated regressions.
Read docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md first.
Current symptoms: transition flaps still happen indoor/outdoor, room/room, cellar; ground floor is now transparent; cellar broken; prior runs showed grey/black clear color, missing wall textures, and character cut on stairs.
Do not patch first. Audit dirty diff, read named retail PView, parse launch-flap-shell-capture-relaunch.log and launch-pview-watermark-probe.log. Determine exactly why:
1) cellar root 0174 fails to traverse 0174 -> 0175 when proj=0;
2) root 0172 toggles outside_view/0170 reachability while 0172 -> 016F has proj=0;
3) shell/object/terrain/depth-clear decisions disagree.
Patch only the retail mismatch. Build/test before relaunch. Do not claim success before user visual confirmation.
```

View file

@ -0,0 +1,160 @@
# Indoor Render — Session Handoff: HANG fixed + interior SEALS; the FLAP is next — 2026-06-07
> Worktree `thirsty-goldberg-51bb9b`, branch `claude/thirsty-goldberg-51bb9b`. PowerShell on Windows;
> launch logs UTF-16; build before launch; acceptance is the user's eyes. Live ACE `127.0.0.1:9000`,
> `testaccount`/`testpassword`, char `+Acdream` (spawns near the Holtburg / "Arcanum" cottage —
> landblock `0xA9B4`, cottage cells `0xA9B4016F0175`). Do NOT branch/worktree, push, or `git stash`/`gc`.
## TL;DR
The two-week indoor-render **HANG is FIXED** and the interior **SEALS** (walls/floor/ceiling draw,
textured) — both committed this session and live-verified by the user ("Ok now it runs!"). A
structured live test pinned the remaining dominant visible issue, the **FLAP at transitions**, as
**viewer-cell metastability**: the render roots at the camera-eye cell, which oscillates
outdoor↔indoor as the 3rd-person boom drifts across the doorway plane. **The flap is a SEPARATE,
already-designed fix — it is NOT the verbatim DrawCells port; finishing the port will not fix it.**
Next session: **fix the flap** (camera-boom stability + viewer-cell dead-zone). Tracked follow-ups:
#78 terrain gating, look-in-from-inside sealing, look-in FPS, L-spotlight.
## What shipped this session (committed — see `git log` on this branch)
### 1. The HANG fix (the blocker)
Indoor frames froze (`AppHangB1`; not a crash — captured the spinning managed stack via a
`dotnet-stack` hang-watcher). Root cause: `PortalVisibilityBuilder.Build`'s portal-visibility flood
**did not terminate** for real cottage geometry. Two layers, two fixes (both kept):
- **A — drift-tolerant `CellView.Add` dedup** (`src/AcDream.App/Rendering/PortalView.cs`). The flood
re-queues a cell every time its view GROWS; growth only stops when the dedup recognises a re-clipped
region as a duplicate. The faithful `ProjectToClip` near-side clip drifts per round, so the old
exact index-by-index match (eps 1e-4) never caught the near-duplicate → unbounded growth → O(n²)
CPU-spin in `CellView.Add`. Fix: key each polygon by its vertices **snapped to a 1e-3 NDC grid**,
consecutive-dedup'd, **canonically rotated** to a lex-min start → finite key space → convergence.
Tests: `tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs` (3).
- **B — bounded re-enqueue** (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`). A alone did not
fully converge (the spin relocated to `ScreenPolygonClip.ClipByEdge` — bounded loops — inside the
still-non-terminating BFS). Restored the **`MaxReprocessPerCell = 16`** hard cap that Phase U.2a
deleted ("fixpoint termination" left the loop with NO bound). **Kept the re-enqueue** — it is
load-bearing for late-slice propagation (`Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit`).
Pure enqueue-once was tried and **broke that test**, so re-enqueue is kept and merely bounded.
- Deep diagnosis + the reassessment that led to B: `docs/research/2026-06-06-indoor-render-hang-rootcause.md`.
- **Verified:** clean exit (255→0); runs indoors with no freeze; the indoor flood converges in ~1
round/cell at normal positions (measured 35 pops/frame, 1 view-poly/cell). The cap only bites at
the metastable doorway.
### 2. The SEAL (verbatim DrawCells port — Task 2)
`RetailPViewRenderer.DrawEnvCellShells` now iterates `IndoorDrawPlan.ShellPass(pvFrame)` — **every**
visible cell's shell draws (was gated on `ClipFrameAssembler`'s slot filter → cells without a slot
were silently dropped → grey clear-color void). Verified: interior seals + textured. (Task 1
`IndoorDrawPlan` + its test committed earlier as `bff1955`.)
### 3. Look-in FPS
`GameWindow` exterior-look-in candidate cells limited to the player's landblock **±1** (was **all
~81 loaded landblocks** iterated every outdoor frame just to discard them via the 48 m seed cutoff).
Provably no behavior change (excluded cells are >48 m, already culled). Outdoor FPS improved but
still **~110 fps / ~9 ms (was ~200)** — `DrawPortal` still draws ~12 building interiors/frame (see
follow-up).
## Baselines (must hold at next session start)
- `dotnet build -c Debug` **0 errors**.
- App.Tests **210/210** (205 baseline + IndoorDrawPlanTests 2 + CellViewDedupTests 3).
- Core.Tests **1331 pass / 4 fail / 1 skip** — the 4 are pre-existing Physics door/step-up, unrelated.
## Structured live test — findings (Holtburg/Arcanum cottage, 2026-06-07)
User walked a 6-step protocol (inside-still → camera-pan → doorway-threshold → just-outside →
looking-at-cottage → cellar) and reported 8 behaviours; `ACDREAM_PROBE_FLAP` `[render-sig]`
correlated each.
| # | Observed | Cause | Bucket |
|---|---|---|---|
| 2,3,6,8 | walls briefly transparent / window+entrance "covered by the world background" / abrupt "teleport" through the doorway — all **at transitions (camera crossing a threshold)** | **THE FLAP** | viewer-cell stability (NEXT) |
| 1 | outdoor grass covers the cellar-entrance hole (steady, looking in from outside) | outdoor terrain not gated over the indoor floor opening | **#78** terrain gating |
| 7 | from inside, a building seen through the doorway has transparent walls (world-bg shows); pops back when you step outside | look-out shows other buildings unsealed | look-in/look-out completeness |
| 5 | spotlight blobs on textures from the ceiling lamp (always been there) | point-light artifact | **L-spotlight** (separate) |
| FPS | inside very high; outside **110 fps / ~9 ms** (was ~200) | `DrawPortal` draws ~12 interiors/frame | look-in cost |
| 4 | cellar transitions **stable** ✓ | vertical transition doesn't cross the outdoor boundary | — |
### The FLAP — pinned (render-sig evidence)
`[render-sig]` over the doorway shows the render branch + the cell it roots at flip-flopping while the
**player cell stays inside**:
```
50× branch=OutdoorRoot viewer=0xA9B40031 (outdoor) player=0xA9B40171 (indoor) gate=in
16× branch=RetailPViewInside viewer=0xA9B40170 (indoor) player=0xA9B40171 gate=in
113× branch=RetailPViewInside viewer=0xA9B40171 (indoor) player=0xA9B40171 gate=in
... oscillates 0x0031 ↔ 0x0170 ↔ 0x0171 frame-to-frame ...
```
**Mechanism:** the render roots at the **viewer (camera-eye) cell** (`clipRoot = viewerRoot`, Phase W
"one viewpoint"). The 3rd-person boom drifts the eye across the doorway plane; acdream re-resolves the
viewer cell fresh each frame with **no hysteresis** → it flips between outdoor `0x0031` and indoor
`0x0170/0x0171` → the render flips `OutdoorRoot``RetailPViewInside` → the indoor seal drops (walls
transparent, outdoor world/grass shows) then re-seals → **flapping**. This is exactly the 2026-06-05
viewer-cell-flicker diagnosis, now confirmed against the live render branch.
## RECOMMENDED NEXT WORK — fix the FLAP (separate, already-designed)
Per `docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md`, 3 retail-faithful parts:
1. **Viewer-cell dead-zone (do this first)** — ±0.2 mm cell hysteresis so a sub-mm eye drift can't flip
the cell (`PhysicsCameraCollisionProbe.SweepEye`; retail `point_inside_cell_bsp` 0x53c1f0). Highest
leverage — likely kills most of the flap on its own.
2. **Camera-boom stability** — stop the boom drifting at rest (`RetailChaseCamera.UpdateCamera`; retail
`UpdateCamera` 0x456660).
3. **w-space (w=0) portal clip** — close-portal projection degeneracy (`PortalProjection` /
`PortalVisibilityBuilder`; retail `GetClip` 0x5a4320 / `polyClipFinish` 0x6b6d00). Lower priority.
Apparatus ready: `ACDREAM_PROBE_FLAP` emits `[render-sig]` (branch/viewer/player/gate per frame),
`[flap]`, `[flap-cam]`, `[flap-sweep]` — light enough to launch with (the heavy `ACDREAM_PROBE_SHELL`
firehose is what previously caused an I/O stall; avoid it).
## Tracked follow-ups (logged; not yet fixed)
- **#78 terrain gating** — outdoor terrain (grass) draws over the indoor cellar-entrance hole (and likely
other indoor floors). Decomp anchor `CEnvCell::find_visible_child_cell` (`acclient_2013_pseudo_c.txt:311397`).
- **Look-in-from-inside** — buildings seen through your door/window from inside render unsealed
(transparent walls); the look-out pass doesn't draw other buildings' shells. DrawCells port Task 5/7
territory (or R2 "outside-looking-in").
- **Look-in FPS**`DrawPortal` draws ~12 building interiors every outdoor frame (~110 vs ~200 fps).
Optimize: only look into buildings whose exit portals are frustum-visible; skip when no door is in view.
- **L-spotlight** — ceiling-lamp point light makes spotlight blobs on textures. Pre-existing, separate.
## verbatim DrawCells port — remaining tasks (deferred)
Plan: `docs/superpowers/plans/2026-06-06-verbatim-retail-indoor-render-port.md`. Task 1 + Task 2 done.
**Task 3** (objects no-clip) is effectively already satisfied (objects draw membership-gated with no
clip; no half-characters observed). **Tasks 48** (per-slice trim, look-out, delete `ClipFrameAssembler`,
look-in, final) are **cleanup with no current visible payoff** — the seal works and there is **no visible
bleed** (the "glitches between cells" were the FLAP, not bleed). **Task 4 (trim) is intricate** (its
per-slice `_clipFrame.Reset()` is coupled with the landscape/particle passes that still read
`clipAssembly` slots) and **risks re-slicing the working seal** — do it carefully, fresh, and only when
clean architecture is the priority.
## DO NOT re-litigate
- The HANG fix (A drift-dedup + B bounded re-enqueue) is correct + verified. **Do NOT try pure
enqueue-once** — it breaks `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` (late-slice
propagation needs the re-enqueue; the cap, not removal, is the termination guarantee).
- The grey was the `drawableCells` / `ClipFrameAssembler` slot filter; Task 2 fixed it. The clip math is
faithful — do not "harden the w-clip".
- **The FLAP is NOT the DrawCells port.** It is viewer-cell metastability (camera/membership). Tasks 48
will NOT fix it.
- The render roots at the VIEWER (camera-eye) cell intentionally (Phase W "one viewpoint"). The flap fix
is to STABILISE the viewer cell (dead-zone + boom), NOT to re-root at the player cell (superseded).
## Copy-paste pickup prompt (next session)
```
Pick up the indoor-render work in worktree thirsty-goldberg-51bb9b (branch
claude/thirsty-goldberg-51bb9b). PowerShell; launch logs UTF-16; build before launch; acceptance is
the user's eyes. Do NOT branch/worktree, push, git stash/gc, or revert the dirty tree.
Read first: docs/research/2026-06-07-indoor-render-session-handoff.md (state, what shipped, the FLAP
diagnosis, do-not-relitigate). Then docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md
(the flap fix plan).
Confirm baselines: build 0 errors; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip.
The indoor HANG is fixed and the interior SEALS (shipped + committed last session). The remaining
dominant visible issue is the FLAP at transitions — viewer-cell metastability: the render roots at the
camera-eye cell, which oscillates outdoor↔indoor as the 3rd-person boom drifts across the doorway (no
hysteresis), confirmed in [render-sig]. FIX THE FLAP, starting with the viewer-cell dead-zone
(PhysicsCameraCollisionProbe.SweepEye; retail point_inside_cell_bsp 0x53c1f0), then camera-boom
stability (RetailChaseCamera.UpdateCamera; retail UpdateCamera 0x456660). Launch with ACDREAM_PROBE_FLAP
only (NOT ACDREAM_PROBE_SHELL — it stalls on I/O). Gate on the user's eyes at the cottage doorway.
Do NOT: retry pure enqueue-once (breaks late-slice propagation); re-root render at the player cell
(viewer-cell rooting is intentional); finish DrawCells port Tasks 4-8 expecting it to fix the flap (it
won't). Tracked follow-ups (not the flap): #78 terrain gating (grass over cellar hole), look-in-from-
inside sealing, look-in FPS (DrawPortal ~12 interiors/frame), L-spotlight.
```

View file

@ -1,251 +1,224 @@
// ClipFrameAssembler.cs
//
// Phase U.4: assemble a per-frame ClipFrame (the GPU-side shared clip data) +
// a cellId→slot map from a PortalVisibilityFrame. This is the CPU policy that
// turns the portal-visibility BFS result into the slot indices the mesh shader
// (binding=2 CellClip + binding=3 per-instance slot) and the terrain UBO read.
// Retail PView assembly policy. PortalVisibilityBuilder produces a retail-like
// view graph: one portal_view list per visible cell plus an outside_view list.
// This assembler packs each visible polygon as an individual GPU clip slot so
// the renderer can draw the exact PView order:
//
// GL-free: ClipFrame's CPU byte-packing (AppendSlot / SetTerrainClip) runs here;
// the GL upload (ClipFrame.UploadShared) happens at the call site. That keeps the
// whole slot/gate policy unit-testable without a GPU context — see
// ClipFrameAssemblerTests.
// outside_view landscape slices
// reverse cell_draw_list exit masks
// reverse cell_draw_list EnvCell shells
// reverse cell_draw_list object lists
//
// === The slot/gate policy (implemented EXACTLY as the U.4 spec dictates) ======
// slot 0 = no-clip (count 0). ALWAYS present (ClipFrame.NoClip seeds it).
//
// Per visible interior cell (PortalVisibilityFrame.OrderedVisibleCells):
// ClipPlaneSet.From(CellView) has THREE Count==0 meanings (see ClipPlaneSet):
// • IsNothingVisible ⇒ DO NOT map the cell. Its instances/shell won't draw
// (the cull is deliberate — retail culls it too).
// • Count > 0 ⇒ append a real planes slot; cellIdToSlot[cell] = slot.
// • UseScissorFallback⇒ cellIdToSlot[cell] = 0 (no-clip / over-include).
// Per-cell glScissor would break MDI batching, and
// over-inclusion is the SAFE direction; counted in
// ScissorFallbacks for the probe.
//
// OutsideView feeds TWO consumers:
// • mesh "outdoor slot" (outdoor scenery / building shells drawn while the
// camera is indoors): Count>0 ⇒ planes slot (OutdoorSlot); scissor ⇒ slot 0
// (no-clip, counted); IsNothingVisible ⇒ OutdoorVisible=false (CULL these
// instances — the camera can't see outdoors through any portal chain).
// • terrain UBO: Count>0 ⇒ SetTerrainClip(planes); scissor ⇒ TerrainScissor
// (the call site sets glScissor around ONLY the terrain draw) + UBO count 0;
// IsNothingVisible ⇒ SKIP the terrain draw entirely (THIS is the bleed fix).
//
// Outdoor root (pvFrame == null) is handled by the caller, not here: terrain
// draws normally (UBO count 0, no scissor), every instance is slot 0. The caller
// only invokes Assemble when there IS an indoor root.
// Slot 0 is always no-clip. A slice whose polygon cannot be represented by the
// <=8 plane budget uses slot 0 and its NDC AABB; the renderer uses scissor for
// passes that need that fallback. Empty regions are omitted entirely.
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// How the terrain (single OutsideView region) should be drawn this frame.
/// How the landscape-through-outside_view pass should be interpreted.
/// </summary>
public enum TerrainClipMode
{
/// <summary>OutsideView reduced to convex planes — terrain gated via the UBO
/// (<see cref="ClipFrame.SetTerrainClip"/> already applied by the assembler).</summary>
/// <summary>All outside_view slices have convex plane clips.</summary>
Planes,
/// <summary>OutsideView exceeded the convex budget — the call site sets a
/// glScissor to <see cref="ClipFrameAssembly.TerrainScissorNdcAabb"/> around ONLY
/// the terrain draw; the UBO is left at count 0 (ungated).</summary>
/// <summary>At least one outside_view slice requires scissor fallback.</summary>
Scissor,
/// <summary>OutsideView is empty (no exit portal visible through any chain) —
/// the call site SKIPS the terrain draw entirely. This is the bleed fix: an
/// interior with no view outdoors draws no terrain.</summary>
/// <summary>No outside_view slice is visible; skip landscape indoors.</summary>
Skip,
}
/// <summary>
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: the populated
/// <see cref="ClipFrame"/> (CPU bytes ready; caller does <c>UploadShared</c>) plus
/// the per-instance routing data the renderers + the terrain draw consume.
/// One retail portal_view slice mapped to a GPU clip slot. The AABB is retained
/// for passes that cannot write gl_ClipDistance and must use scissor.
/// </summary>
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
/// <summary>
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: populated clip buffers
/// plus routing data consumed by the render orchestration.
/// </summary>
public sealed class ClipFrameAssembly
{
/// <summary>The per-frame clip data. Caller uploads it via
/// <see cref="ClipFrame.UploadShared"/> then hands its
/// <see cref="ClipFrame.RegionSsbo"/> / <see cref="ClipFrame.TerrainUbo"/> to the
/// renderers.</summary>
public required ClipFrame Frame { get; init; }
/// <summary>Maps a visible cell id to its CellClip slot index. A cell that is
/// NOT a key (IsNothingVisible, or never visible) must NOT be drawn — its mesh
/// instances / shell are culled. A scissor-fallback cell maps to slot 0.</summary>
/// <summary>First drawable slice slot per visible cell. Compatibility map
/// for renderer APIs that can accept only one slot at a time.</summary>
public required Dictionary<uint, int> CellIdToSlot { get; init; }
/// <summary>Slot for outdoor scenery / building-shell instances (ParentCellId
/// == null) while the camera is indoors. Meaningful only when
/// <see cref="OutdoorVisible"/> is true. 0 ⇒ no-clip (scissor fallback or trivial).</summary>
/// <summary>Slot-only cell slices, retained for older renderer APIs.</summary>
public required Dictionary<uint, int[]> CellIdToViewSlots { get; init; }
/// <summary>Full retail portal_view slices per visible cell.</summary>
public required Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; init; }
/// <summary>Full retail outside_view slices.</summary>
public required ClipViewSlice[] OutsideViewSlices { get; init; }
public required int OutdoorSlot { get; init; }
/// <summary>False ⇒ the OutsideView is empty; outdoor scenery / shells are
/// CULLED this frame (camera sees no outdoors through any portal chain).</summary>
public required bool OutdoorVisible { get; init; }
/// <summary>How to draw terrain (planes already applied to the UBO / scissor /
/// skip). See <see cref="TerrainClipMode"/>.</summary>
public required TerrainClipMode TerrainMode { get; init; }
/// <summary>NDC AABB (minX,minY,maxX,maxY) for the terrain glScissor when
/// <see cref="TerrainMode"/> is <see cref="TerrainClipMode.Scissor"/>. Unused otherwise.</summary>
public required Vector4 TerrainScissorNdcAabb { get; init; }
/// <summary>True ⇒ the OutsideView (the exit-portal screen region) is meaningfully visible this
/// frame — the camera can see outdoors through a portal chain (<see cref="TerrainMode"/> is
/// <see cref="TerrainClipMode.Planes"/> or <see cref="TerrainClipMode.Scissor"/>). False ⇒ a
/// sealed interior with no exit portal in view (<see cref="TerrainClipMode.Skip"/>). Drives the
/// Stage 4 sky/weather draw + the conditional doorway Z-clear. Always false on the outdoor root
/// (the caller does not invoke <see cref="ClipFrameAssembler.Assemble"/> there).</summary>
public required bool HasOutsideView { get; init; }
/// <summary>NDC AABB (minX,minY,maxX,maxY) of the OutsideView screen region — the doorway
/// opening's bounding box. Computed whenever <see cref="HasOutsideView"/> is true, for BOTH the
/// Planes and Scissor terrain modes (unlike <see cref="TerrainScissorNdcAabb"/>, which is valid
/// only in Scissor mode). Stage 4 scissors the conditional doorway depth-only Z-clear (retail
/// PView::DrawCells:432731) and the sky/weather particle passes to this region. Degenerate
/// (<see cref="Vector4.Zero"/>) when <see cref="HasOutsideView"/> is false.</summary>
public required Vector4 OutsideViewNdcAabb { get; init; }
// ---- Probe data (ACDREAM_PROBE_VIS / RenderingDiagnostics.EmitVis) --------
/// <summary>Plane count the OutsideView reduced to (0 ⇒ scissor or empty).</summary>
// Probe data.
public required int OutsidePlaneCount { get; init; }
/// <summary>Per-cell clip-plane count (cell id → plane count) for the probe.
/// A scissor-fallback cell records 0 here (it maps to slot 0).</summary>
public required Dictionary<uint, int> PerCellPlaneCounts { get; init; }
/// <summary>Number of regions (cells + OutsideView) that fell back to a scissor
/// AABB → no-clip this frame.</summary>
public required int ScissorFallbacks { get; init; }
}
/// <summary>
/// Builds a <see cref="ClipFrameAssembly"/> from a <see cref="PortalVisibilityFrame"/>.
/// Pure CPU; no GL. The single entry point <see cref="Assemble"/> implements the U.4
/// slot/gate policy (file header).
/// </summary>
public static class ClipFrameAssembler
{
/// <summary>
/// Assemble the per-frame clip data + routing from a portal-visibility frame
/// INTO an existing <see cref="ClipFrame"/> — the long-lived GameWindow frame is
/// <see cref="ClipFrame.Reset"/>-and-repacked here every frame so its GL buffers
/// are reused (no per-frame buffer churn). The returned assembly's
/// <see cref="ClipFrameAssembly.Frame"/> is the same instance passed in.
/// </summary>
public static ClipFrameAssembly Assemble(ClipFrame frame, PortalVisibilityFrame pvFrame)
{
System.ArgumentNullException.ThrowIfNull(frame);
System.ArgumentNullException.ThrowIfNull(pvFrame);
frame.Reset(); // slot 0 = no-clip
frame.Reset();
var cellIdToSlot = new Dictionary<uint, int>();
var cellIdToViewSlots = new Dictionary<uint, int[]>();
var cellIdToViewSlices = new Dictionary<uint, ClipViewSlice[]>();
var perCellPlaneCounts = new Dictionary<uint, int>();
int scissorFallbacks = 0;
// ── Interior cells ───────────────────────────────────────────────────
foreach (uint cellId in pvFrame.OrderedVisibleCells)
{
if (!pvFrame.CellViews.TryGetValue(cellId, out var view))
continue; // defensive — OrderedVisibleCells is derived from CellViews
var cps = ClipPlaneSet.From(view);
if (cps.IsNothingVisible)
{
// Cell culled — do NOT map it; its instances/shell won't draw.
continue;
var slices = new List<ClipViewSlice>(view.Polygons.Count);
int maxPlaneCount = 0;
foreach (var poly in view.Polygons)
{
var cps = ClipPlaneSet.From(ViewOf(poly));
if (cps.IsNothingVisible)
continue;
int slot;
Vector4[] planes;
if (cps.Count > 0)
{
planes = ToPlaneSpan(cps);
slot = frame.AppendSlot(planes);
if (cps.Count > maxPlaneCount)
maxPlaneCount = cps.Count;
}
else
{
planes = System.Array.Empty<Vector4>();
slot = 0;
scissorFallbacks++;
}
slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
}
if (slices.Count == 0)
continue;
var sliceArray = slices.ToArray();
cellIdToViewSlices[cellId] = sliceArray;
cellIdToViewSlots[cellId] = ToSlots(sliceArray);
cellIdToSlot[cellId] = sliceArray[0].Slot;
perCellPlaneCounts[cellId] = maxPlaneCount;
}
var outsideSlicesList = new List<ClipViewSlice>(pvFrame.OutsideView.Polygons.Count);
int outsideMaxPlaneCount = 0;
bool outsideHasScissorFallback = false;
foreach (var poly in pvFrame.OutsideView.Polygons)
{
var cps = ClipPlaneSet.From(ViewOf(poly));
if (cps.IsNothingVisible)
continue;
int slot;
Vector4[] planes;
if (cps.Count > 0)
{
int slot = frame.AppendSlot(cps);
cellIdToSlot[cellId] = slot;
perCellPlaneCounts[cellId] = cps.Count;
planes = ToPlaneSpan(cps);
slot = frame.AppendSlot(planes);
if (cps.Count > outsideMaxPlaneCount)
outsideMaxPlaneCount = cps.Count;
}
else // UseScissorFallback (Count == 0, not nothing-visible)
else
{
// Over-include via no-clip (slot 0). Per-cell glScissor would break
// MDI batching; over-inclusion is the safe direction for M1.5.
cellIdToSlot[cellId] = 0;
perCellPlaneCounts[cellId] = 0;
planes = System.Array.Empty<Vector4>();
slot = 0;
outsideHasScissorFallback = true;
scissorFallbacks++;
}
outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
}
// ── OutsideView ──────────────────────────────────────────────────────
var ov = ClipPlaneSet.From(pvFrame.OutsideView);
var outsideViewSlices = outsideSlicesList.ToArray();
bool outdoorVisible = outsideViewSlices.Length > 0;
int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
TerrainClipMode terrainMode = !outdoorVisible
? TerrainClipMode.Skip
: (outsideHasScissorFallback ? TerrainClipMode.Scissor : TerrainClipMode.Planes);
int outdoorSlot;
bool outdoorVisible;
TerrainClipMode terrainMode;
Vector4 terrainScissor = Vector4.Zero;
if (ov.IsNothingVisible)
{
// No outdoors visible through any portal chain.
outdoorSlot = 0;
outdoorVisible = false; // mesh: CULL outdoor scenery / shells.
terrainMode = TerrainClipMode.Skip; // terrain: the bleed fix.
}
else if (ov.Count > 0)
{
// Convex planes — gate both the outdoor mesh slot and the terrain UBO.
outdoorSlot = frame.AppendSlot(ov);
outdoorVisible = true;
frame.SetTerrainClip(ToPlaneSpan(ov));
terrainMode = TerrainClipMode.Planes;
}
else // UseScissorFallback
{
// Mesh: no-clip over-include (slot 0), still visible. Terrain: scissor
// around the single terrain batch + UBO ungated (count 0 left as-is).
outdoorSlot = 0;
outdoorVisible = true;
terrainMode = TerrainClipMode.Scissor;
terrainScissor = ov.ScissorNdcAabb;
scissorFallbacks++;
}
// Stage 4: the doorway screen-space AABB (the OutsideView union bounds), available for
// BOTH Planes and Scissor modes — the sky/weather particle scissor + the conditional
// doorway Z-clear need it regardless of how the OutsideView reduced to a gate.
// TerrainScissorNdcAabb above is only valid in Scissor mode; the OutsideView CellView
// always tracks its Min/Max as polygons accumulate, so it is the single source here.
bool hasOutsideView = terrainMode != TerrainClipMode.Skip;
Vector4 outsideViewNdcAabb = (hasOutsideView && !pvFrame.OutsideView.IsEmpty)
Vector4 outsideViewNdcAabb = outdoorVisible
? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY,
pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY)
: Vector4.Zero;
Vector4 terrainScissor = terrainMode == TerrainClipMode.Scissor
? outsideViewNdcAabb
: Vector4.Zero;
return new ClipFrameAssembly
{
Frame = frame,
CellIdToSlot = cellIdToSlot,
CellIdToViewSlots = cellIdToViewSlots,
CellIdToViewSlices = cellIdToViewSlices,
OutsideViewSlices = outsideViewSlices,
OutdoorSlot = outdoorSlot,
OutdoorVisible = outdoorVisible,
TerrainMode = terrainMode,
TerrainScissorNdcAabb = terrainScissor,
HasOutsideView = hasOutsideView,
HasOutsideView = outdoorVisible,
OutsideViewNdcAabb = outsideViewNdcAabb,
OutsidePlaneCount = ov.Count,
OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0,
PerCellPlaneCounts = perCellPlaneCounts,
ScissorFallbacks = scissorFallbacks,
};
}
// Copy a ClipPlaneSet's planes into a heap array for SetTerrainClip's span
// parameter (the set exposes IReadOnlyList, not a contiguous span).
private static CellView ViewOf(ViewPolygon poly)
{
var view = new CellView();
view.Add(poly);
return view;
}
private static Vector4 AabbOf(ViewPolygon poly) =>
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);
private static int[] ToSlots(ClipViewSlice[] slices)
{
var slots = new int[slices.Length];
for (int i = 0; i < slices.Length; i++)
slots[i] = slices[i].Slot;
return slots;
}
private static Vector4[] ToPlaneSpan(ClipPlaneSet set)
{
int n = set.Count;
var planes = new Vector4[n];
for (int i = 0; i < n; i++) planes[i] = set.Planes[i];
for (int i = 0; i < n; i++)
planes[i] = set.Planes[i];
return planes;
}
}

View file

@ -66,7 +66,6 @@ public readonly struct ClipPlaneSet
// or point — zero screen coverage ⇒ nothing visible. A real portal opening has area far
// above this (e.g. the sliver-clip test region is 0.4); only an edge-on projection gets here.
private const float MinPolygonArea = 1e-7f;
private readonly Vector4[] _planes;
private ClipPlaneSet(Vector4[] planes, bool useScissorFallback, bool isNothingVisible, Vector4 scissorNdcAabb)

File diff suppressed because it is too large Load diff

View file

@ -5,18 +5,10 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Splits a frame's landblock entities into the three draw buckets the per-cell
/// <see cref="InteriorRenderer"/> needs, using the SAME precedence as
/// <see cref="Wb.WbDrawDispatcher.ResolveEntitySlot"/>:
/// <list type="number">
/// <item><b>ServerGuid != 0</b> (player / NPCs / items / doors) ⇒ <see cref="Result.LiveDynamic"/>
/// — drawn unclipped (depth only). These have no <c>ParentCellId</c> so they MUST be tested first.</item>
/// <item><b>ParentCellId</b> in the visible set ⇒ <see cref="Result.ByCell"/>[cell] — per-cell, portal-clipped.</item>
/// <item><b>ParentCellId == null</b> (outdoor scenery / building shell) ⇒ <see cref="Result.Outdoor"/>
/// — drawn through the doorway, clipped to OutsideView.</item>
/// </list>
/// A static whose <c>ParentCellId</c> is NOT in <paramref name="visibleCells"/> is dropped (its cell
/// isn't drawn this frame). Entities with no <c>MeshRefs</c> are skipped. Pure; GL-free; unit-tested.
/// Splits a frame's landblock entities into the draw buckets used by the
/// retail-style DrawInside flood. Indoor ownership wins for live dynamics too:
/// a player, NPC, door, or item with a current indoor ParentCellId belongs to
/// that cell's portal-clipped object list, not a global overlay pass.
/// </summary>
public static class InteriorEntityPartition
{
@ -40,18 +32,18 @@ public static class InteriorEntityPartition
{
if (e.MeshRefs.Count == 0) continue;
if (e.ServerGuid != 0) // live-dynamic — precedence first (no ParentCellId)
if (e.ServerGuid != 0)
{
result.LiveDynamic.Add(e);
if (e.ParentCellId is uint liveCell)
AddByCellOrOutdoor(e, liveCell, visibleCells, result);
else
result.LiveDynamic.Add(e);
}
else if (e.ParentCellId is uint cell)
{
if (!visibleCells.Contains(cell)) continue; // its cell isn't drawn this frame
if (!result.ByCell.TryGetValue(cell, out var list))
result.ByCell[cell] = list = new List<WorldEntity>();
list.Add(e);
AddByCellOrOutdoor(e, cell, visibleCells, result);
}
else // outdoor scenery / building shell
else
{
result.Outdoor.Add(e);
}
@ -59,4 +51,30 @@ public static class InteriorEntityPartition
}
return result;
}
private static void AddByCellOrOutdoor(
WorldEntity entity,
uint cellId,
HashSet<uint> visibleCells,
Result result)
{
if (!IsIndoorCellId(cellId))
{
result.Outdoor.Add(entity);
return;
}
if (!visibleCells.Contains(cellId))
return;
if (!result.ByCell.TryGetValue(cellId, out var list))
result.ByCell[cellId] = list = new List<WorldEntity>();
list.Add(entity);
}
private static bool IsIndoorCellId(uint cellId)
{
uint low = cellId & 0xFFFFu;
return low >= 0x0100u && low != 0xFFFFu;
}
}

View file

@ -17,6 +17,13 @@ public sealed class InteriorRenderContext
/// membership filter; <see cref="OrderedVisibleCells"/> supplies the draw ORDER.</summary>
public required IReadOnlySet<uint> DrawableCells { get; init; }
/// <summary>Per-cell portal_view slots, in the same order retail setup_view(cell, i)
/// selects them inside PView::DrawCells.</summary>
public required IReadOnlyDictionary<uint, int[]> CellClipSlots { get; init; }
public required int OutdoorSlot { get; init; }
public required bool OutdoorVisible { get; init; }
/// <summary>The 3-bucket entity split (<see cref="InteriorEntityPartition"/>). Only ByCell +
/// LiveDynamic are used here; Outdoor scenery is drawn by the caller's landscape-through-door
/// step (clipped to OutsideView).</summary>
@ -34,12 +41,11 @@ public sealed class InteriorRenderContext
}
/// <summary>
/// The per-cell interior render flood — a faithful port of retail PView::DrawCells' per-cell loops
/// (decomp 0x5a4840). Iterates the visible cells closest-first; per cell draws the closed shell +
/// that cell's static objects (portal-clipped via the clip routing the caller installed), then the
/// live-dynamics unclipped, then the transparent shells. The landscape-through-door (sky/terrain/
/// scenery) + the conditional Z-clear are the caller's responsibility, run BEFORE this. GL state is
/// self-contained inside each renderer (EnvCellRenderer / WbDrawDispatcher set their own).
/// The interior render flood, matching retail PView::DrawCells @ 0x005a4840:
/// after the caller handles outside_view terrain + the depth-only clear, DrawCells
/// walks cell_draw_list from the end back to zero in separate stages: cell shells,
/// then each cell's object_list. The transparent shell pass is split out because
/// the modern renderer batches opaque/transparent surfaces separately.
/// </summary>
public sealed class InteriorRenderer
{
@ -48,7 +54,6 @@ public sealed class InteriorRenderer
// Reused single-cell filter set — cleared + repopulated per cell to avoid per-frame allocs.
private readonly HashSet<uint> _oneCell = new(1);
public InteriorRenderer(EnvCellRenderer envCells, WbDrawDispatcher entities)
{
_envCells = envCells;
@ -57,54 +62,103 @@ public sealed class InteriorRenderer
public void DrawInside(InteriorRenderContext ctx)
{
// Loop A — per-cell OPAQUE shell + that cell's static objects (closest-first).
foreach (uint cellId in ctx.OrderedVisibleCells)
// Retail Loop 2: DrawEnvCell for each drawable cell, farthest-to-nearest
// (cell_draw_list[cell_draw_num - 1] down to 0).
for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
if (!ctx.DrawableCells.Contains(cellId)) continue; // no clip slot ⇒ assembler culled it
uint cellId = ctx.OrderedVisibleCells[i];
if (!TryBeginCell(ctx, cellId, out _)) continue;
_oneCell.Clear();
_oneCell.Add(cellId);
ApplyMembershipOnlyRouting();
_envCells.Render(WbRenderPass.Opaque, _oneCell);
if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0)
DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell);
}
// Live-dynamics (player / NPCs): unclipped (serverGuid != 0 → clip slot 0), depth-tested.
// Drawn AFTER opaque shells so wall depth occludes them correctly.
if (ctx.Partition.LiveDynamic.Count > 0)
DrawEntityBucket(ctx, ctx.Partition.LiveDynamic, visibleCellIds: null);
// Loop B — per-cell TRANSPARENT shells (stained glass / additive cell surfaces).
foreach (uint cellId in ctx.OrderedVisibleCells)
// Retail Loop 3: Render::PortalList = cell->portal_view; DrawObjCellForDummies(cell).
for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
if (!ctx.DrawableCells.Contains(cellId)) continue;
uint cellId = ctx.OrderedVisibleCells[i];
if (!TryBeginCell(ctx, cellId, out _)) continue;
_oneCell.Clear();
_oneCell.Add(cellId);
if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0)
{
ApplyMembershipOnlyRouting();
DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell);
}
}
// Modern split of DrawEnvCell's transparent/additive batches, same reverse cell order.
for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = ctx.OrderedVisibleCells[i];
if (!TryBeginCell(ctx, cellId, out _)) continue;
_oneCell.Clear();
_oneCell.Add(cellId);
ApplyMembershipOnlyRouting();
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
}
private bool TryBeginCell(InteriorRenderContext ctx, uint cellId, out int[] slots)
{
if (ctx.DrawableCells.Contains(cellId))
{
ctx.CellClipSlots.TryGetValue(cellId, out slots!);
slots ??= System.Array.Empty<int>();
return true;
}
slots = System.Array.Empty<int>();
return false;
}
private void ApplyMembershipOnlyRouting()
{
// PView membership controls which cell shell/object bucket is visited.
// Do not turn the 2D portal view into gl_ClipDistance for indoor meshes:
// that slices avatars and shell triangles at stairs/doorways instead of
// matching retail's DrawMesh view-check-then-draw behavior.
_envCells.SetClipRouting(null);
_entities.ClearClipRouting();
}
// Draws one bucket of entities via the existing dispatcher, scoped to a synthetic single-entry
// landblock list. visibleCellIds gates which entities pass the cell-membership walk (a single-cell
// set for per-cell statics; null for live-dynamics — they pass the gate and resolve to slot 0).
// set for per-cell objects; null only for fallback/outdoor buckets where clip-slot routing owns cull).
// The clip slot per entity comes from the SetClipRouting the caller installed (cellIdToSlot +
// outdoorSlot + outdoorVisible) via ResolveEntitySlot.
private void DrawEntityBucket(
InteriorRenderContext ctx, IReadOnlyList<WorldEntity> bucket, HashSet<uint>? visibleCellIds)
=> DrawEntityBucket(
ctx.Camera,
ctx.Frustum,
ctx.PlayerLandblockId,
ctx.AnimatedEntityIds,
bucket,
visibleCellIds);
public void DrawEntityBucket(
ICamera camera,
FrustumPlanes? frustum,
uint? playerLandblockId,
HashSet<uint>? animatedEntityIds,
IReadOnlyList<WorldEntity> bucket,
HashSet<uint>? visibleCellIds)
{
// LandblockId == neverCullLandblockId (PlayerLandblockId) ⇒ the degenerate (zero) AABB is
// never landblock-frustum-culled; per-entity AABB culling inside Draw still applies.
uint lbId = ctx.PlayerLandblockId ?? 0u;
uint lbId = playerLandblockId ?? 0u;
var entry = (lbId, Vector3.Zero, Vector3.Zero,
(IReadOnlyList<WorldEntity>)bucket,
(IReadOnlyDictionary<uint, WorldEntity>?)null);
_entities.Draw(
ctx.Camera,
camera,
new[] { entry },
ctx.Frustum,
neverCullLandblockId: ctx.PlayerLandblockId,
frustum,
neverCullLandblockId: playerLandblockId,
visibleCellIds: visibleCellIds,
animatedEntityIds: ctx.AnimatedEntityIds);
animatedEntityIds: animatedEntityIds);
}
}

View file

@ -120,7 +120,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
ParticleSystem particles,
ICamera camera,
Vector3 cameraWorldPos,
ParticleRenderPass renderPass = ParticleRenderPass.Scene)
ParticleRenderPass renderPass = ParticleRenderPass.Scene,
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter = null)
{
if (particles is null || camera is null)
return;
@ -128,7 +129,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
Matrix4x4.Invert(camera.View, out var invView);
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp);
var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp, emitterFilter);
if (draws.Count == 0)
return;
draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq));
@ -174,13 +175,16 @@ public sealed unsafe class ParticleRenderer : IDisposable
Vector3 cameraWorldPos,
ParticleRenderPass renderPass,
Vector3 cameraRight,
Vector3 cameraUp)
Vector3 cameraUp,
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter)
{
var draws = new List<ParticleDraw>(Math.Max(64, particles.ActiveParticleCount));
foreach (var (em, idx) in particles.EnumerateLive())
{
if (em.RenderPass != renderPass)
continue;
if (emitterFilter is not null && !emitterFilter(em))
continue;
ref var p = ref em.Particles[idx];
// `p.Position` is already in world coordinates: AttachLocal

View file

@ -70,6 +70,117 @@ public static class PortalProjection
return ndc;
}
/// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the w=0 clip of
/// ACRender::polyClipFinish, decomp 424310 / 702749): transform the portal to clip space and clip
/// ONLY the eye plane (w &gt;= <see cref="EyePlaneW"/>), keeping homogeneous coords — NO perspective
/// divide, NO frustum side-plane clamp. The screen bound is applied later by <see cref="ClipToRegion"/>
/// against the view region (the root region is the full screen), exactly as retail clips the portal
/// against the accumulated portal_view rather than fixed side planes. Keeping w means a near/grazing
/// portal never collapses to a zero-area edge sliver (the flap) nor blows up under an early divide
/// (the void). Returns &lt;3 verts when the portal is entirely behind the eye.</summary>
public static Vector4[] ProjectToClip(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
{
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector4>();
Matrix4x4 m = cellToWorld * viewProj;
var clip = new List<Vector4>(localPoly.Count);
foreach (var lp in localPoly)
clip.Add(Vector4.Transform(new Vector4(lp, 1f), m));
// Eye plane ONLY (w >= EyePlaneW), in clip space, homogeneous — no side planes, no divide.
// Retail's polyClipFinish clips at w = 0; EyePlaneW is a hair above 0 so the later divide in
// ClipToRegion never hits the w = 0 singularity. Everything in front of the eye is kept,
// including a portal the camera is standing in (it covers the screen) — the screen bound comes
// from ClipToRegion against the view region, not from a near plane here.
clip = ClipPlane(clip, v => v.W - EyePlaneW);
return clip.Count >= 3 ? clip.ToArray() : System.Array.Empty<Vector4>();
}
/// <summary>Clip a homogeneous (clip-space) portal polygon against an NDC view region
/// (CCW convex) with w-aware Sutherland-Hodgman edge tests, then divide the survivors to NDC and
/// normalize to CCW. Ports retail ACRender::polyClipFinish's view-region clip (decomp 702749): the
/// edge test multiplies through w (which is &gt; 0 after the eye-plane clip) so it never divides a
/// near-eye vertex, and the final divide runs only on survivors already bounded to the region —
/// stable by construction. Returns &lt;3 verts when the portal does not intersect the region.</summary>
public static Vector2[] ClipToRegion(IReadOnlyList<Vector4> subjectClip, IReadOnlyList<Vector2> regionCcwNdc)
{
if (subjectClip == null || regionCcwNdc == null || subjectClip.Count < 3 || regionCcwNdc.Count < 3)
return System.Array.Empty<Vector2>();
// Homogeneous Sutherland-Hodgman: clip the (w > 0) subject against each CCW edge of the NDC
// region. f(P) below is the NDC inside test cross(edge, P_ndc - a) multiplied through P.W,
// which is > 0 after the eye-plane clip — so the sign is the NDC sign yet no near-eye vertex
// is ever divided (retail polyClipFinish, decomp 702749).
var poly = new List<Vector4>(subjectClip);
int n = regionCcwNdc.Count;
for (int e = 0; e < n; e++)
{
if (poly.Count < 3) return System.Array.Empty<Vector2>();
poly = ClipHomogeneousEdge(poly, regionCcwNdc[e], regionCcwNdc[(e + 1) % n]);
}
if (poly.Count < 3) return System.Array.Empty<Vector2>();
// Divide survivors → NDC. They are inside the region now, so |x| ≤ |w| and |y| ≤ |w|: the
// divide is bounded by construction (this is why the homogeneous clip avoids the early-divide
// blow-up). Normalize to CCW so the result is a valid clip region for the next portal hop.
var ndc = new Vector2[poly.Count];
for (int i = 0; i < poly.Count; i++)
{
float w = poly[i].W;
ndc[i] = new Vector2(poly[i].X / w, poly[i].Y / w);
}
EnsureCcw(ndc);
return ndc;
}
// One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside
// (left) part of a HOMOGENEOUS polygon. Inside test for vertex P (clip space): the NDC cross
// product cross(b-a, P/P.W - a) scaled by P.W (> 0): ex·(P.Y - P.W·a.Y) - ey·(P.X - P.W·a.X) ≥ 0.
// Crossings interpolate in homogeneous coords (perspective-correct), via the shared Lerp.
private static List<Vector4> ClipHomogeneousEdge(List<Vector4> poly, Vector2 a, Vector2 b)
{
var result = new List<Vector4>(poly.Count + 1);
float ex = b.X - a.X, ey = b.Y - a.Y;
for (int i = 0; i < poly.Count; i++)
{
Vector4 cur = poly[i];
Vector4 prev = poly[(i + poly.Count - 1) % poly.Count];
float dCur = ex * (cur.Y - cur.W * a.Y) - ey * (cur.X - cur.W * a.X);
float dPrev = ex * (prev.Y - prev.W * a.Y) - ey * (prev.X - prev.W * a.X);
bool curIn = dCur >= 0f;
bool prevIn = dPrev >= 0f;
if (curIn)
{
if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur));
result.Add(cur);
}
else if (prevIn)
{
result.Add(Lerp(prev, cur, dPrev, dCur));
}
}
return result;
}
// Reverse vertex order in place if wound clockwise (signed area < 0). Mirrors the builder's
// EnsureCcw so a clipped region is always CCW for the next hop's ClipToRegion edge test.
private static void EnsureCcw(Vector2[] poly)
{
float area2 = 0f;
for (int i = 0; i < poly.Length; i++)
{
var p = poly[i]; var q = poly[(i + 1) % poly.Length];
area2 += p.X * q.Y - q.X * p.Y;
}
if (area2 < 0f) System.Array.Reverse(poly);
}
// Eye plane for the homogeneous clip — a hair above retail's w = 0 so the post-region divide in
// ClipToRegion never divides by zero. Far closer than any near plane: a portal the eye is standing
// in is kept (it covers the screen), so the cell behind it stays visible.
private const float EyePlaneW = 1e-4f;
// Minimum clip-space w (≈ metres in front of the eye) to keep a vertex. Excludes the eye
// (w=0) singularity and the ~5 cm right at it (bounding the perspective divide), but is
// INTENTIONALLY far closer than the projection's 1.0 m near plane so a doorway the camera is

View file

@ -5,6 +5,7 @@
// a cell's clip region is a SET of convex polygons in normalized device coords.
using System.Collections.Generic;
using System.Numerics;
using System.Text;
namespace AcDream.App.Rendering;
@ -40,6 +41,10 @@ public readonly struct ViewPolygon
public sealed class CellView
{
public readonly List<ViewPolygon> Polygons = new();
// Canonical (snapped) keys of the polygons in <see cref="Polygons"/>, backing the drift-tolerant
// dedup in <see cref="Add"/>. One entry per stored polygon; HashSet membership IS the dedup.
private readonly HashSet<string> _polygonKeys = new();
public float MinX { get; private set; } = float.MaxValue;
public float MinY { get; private set; } = float.MaxValue;
public float MaxX { get; private set; } = float.MinValue;
@ -59,13 +64,82 @@ public sealed class CellView
return v;
}
public void Add(ViewPolygon p)
public bool Add(ViewPolygon p)
{
if (p.IsEmpty) return;
if (p.IsEmpty) return false;
// Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). PortalVisibilityBuilder.Build
// re-queues a cell every time its CellView GROWS, so the flood only terminates when Add
// recognises a re-clipped region as a duplicate. Across BFS rounds the SAME region returns
// float-drifted, vertex-rotated, and/or with a ±1 vertex count (homogeneous Sutherland-Hodgman +
// EnsureCcw); the old exact index-by-index match (eps 1e-4) caught none of those, so the region
// grew without bound -> O(n^2) CPU-spin hang in this method. We instead key each polygon by its
// vertices SNAPPED to a small NDC grid, consecutive snap-duplicates removed, rotated to a
// canonical start. The snapped key space is finite, so a monotonically-growing CellView is
// bounded and the flood is GUARANTEED to converge. The stored polygon keeps full precision (only
// the key is snapped), so downstream clip geometry is unchanged, and the grid (1e-3 NDC ~ sub-
// pixel) is far finer than the gap between genuinely distinct openings, so real regions never merge.
string? key = CanonicalKey(p.Vertices);
if (key is null) return false; // degenerate after snap (< 3 distinct vertices)
if (!_polygonKeys.Add(key)) return false; // duplicate region (drift / rotation / count tolerant)
Polygons.Add(p);
if (p.MinX < MinX) MinX = p.MinX;
if (p.MinY < MinY) MinY = p.MinY;
if (p.MaxX > MaxX) MaxX = p.MaxX;
if (p.MaxY > MaxY) MaxY = p.MaxY;
return true;
}
// NDC dedup grid. 1e-3 is ~0.5 px at 1080p — finer than the gap between distinct portal openings
// (so real regions stay distinct) yet far coarser than the per-round float drift of a re-clipped
// region (so a drifted duplicate snaps onto its predecessor). The finite grid is what bounds growth.
private const float DedupGridNdc = 1e-3f;
// Canonical key for a view polygon: vertices snapped to the NDC grid, consecutive snap-duplicates
// removed (including wrap-around), then rotated to start at the lexicographically smallest vertex so
// a rotated emission of the same cycle yields the same key. Returns null when fewer than 3 distinct
// snapped vertices survive (a degenerate sliver, not a real region). Winding is already CCW for every
// builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step.
private static string? CanonicalKey(Vector2[]? verts)
{
if (verts is null || verts.Length < 3) return null;
var pts = new List<(int X, int Y)>(verts.Length);
foreach (var v in verts)
{
var q = ((int)System.MathF.Round(v.X / DedupGridNdc), (int)System.MathF.Round(v.Y / DedupGridNdc));
if (pts.Count == 0 || pts[^1] != q) pts.Add(q);
}
if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1);
if (pts.Count < 3) return null;
int n = pts.Count;
int best = 0;
for (int s = 1; s < n; s++)
if (RotationLess(pts, s, best, n)) best = s;
var sb = new StringBuilder(n * 10);
for (int i = 0; i < n; i++)
{
var q = pts[(best + i) % n];
sb.Append(q.X).Append(',').Append(q.Y).Append(';');
}
return sb.ToString();
}
// True when the rotation of `pts` starting at index a is lexicographically less than the rotation
// starting at b (compare X then Y, vertex by vertex around the cycle). Gives a unique canonical
// start even when two vertices share the minimum snapped coordinate.
private static bool RotationLess(List<(int X, int Y)> pts, int a, int b, int n)
{
for (int i = 0; i < n; i++)
{
var pa = pts[(a + i) % n];
var pb = pts[(b + i) % n];
if (pa.X != pb.X) return pa.X < pb.X;
if (pa.Y != pb.Y) return pa.Y < pb.Y;
}
return false;
}
}

View file

@ -37,6 +37,19 @@ public static class PortalVisibilityBuilder
{
private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon
// Bounded re-enqueue cap (restored 2026-06-07). The distance-priority portal flood re-enqueues a
// cell whenever its accumulated view GROWS, which is load-bearing — it propagates a late-discovered
// portal_view slice to that cell's exit portals (Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit).
// But the faithful near-side clip (ProjectToClip) drifts per round, so re-clipping a cell's view yields
// ever-smaller distinct sub-regions / drifted near-duplicates the dedup can't always collapse -> the
// grow flag never settles and the flood spins forever (the indoor hang). This cap bounds each cell to
// at most this many pops, so the flood terminates in <= N*cap pops regardless of drift while still
// allowing the few re-processes that legitimate late-slice propagation needs. The old hard cap removed
// in U.2a was 4; widened here because ProjectToClip drifts more than the old ProjectToNdc and Option A's
// CellView dedup already collapses most spurious growth, so the cap rarely binds. Tune from the visual
// gate if an interior view under-includes a slice.
private const int MaxReprocessPerCell = 16;
// TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the
// local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell.
private static readonly bool s_pvDump =
@ -81,7 +94,11 @@ public static class PortalVisibilityBuilder
// the instant a cell is popped). Enqueue-once across the cell set is the hard termination
// guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The
// camera cell is pre-marked so a portal looping back to it can never re-enqueue it.
var seen = new HashSet<uint> { cameraCell.CellId };
var queued = new HashSet<uint> { cameraCell.CellId };
var drawListed = new HashSet<uint>();
var processedViewCounts = new Dictionary<uint, int>();
var popCounts = new Dictionary<uint, int>(); // per-cell pop count for the MaxReprocessPerCell cap
var trace = PortalBuildTrace.Start(cameraCell, cameraPos);
bool pvDump = false;
if (s_pvDump)
@ -116,46 +133,81 @@ public static class PortalVisibilityBuilder
while (todo.Count > 0)
{
var cell = todo.PopNearest();
queued.Remove(cell.CellId);
// Bounded re-enqueue (2026-06-07 termination fix): count this pop. The re-enqueue gate below
// refuses to re-add a cell already popped MaxReprocessPerCell times, so the flood terminates
// even when ProjectToClip drift keeps a view growing forever. Re-enqueue itself is KEPT — it
// propagates late-discovered slices to exit portals (see MaxReprocessPerCell); only its count
// is capped.
popCounts.TryGetValue(cell.CellId, out int popsSoFar);
popCounts[cell.CellId] = popsSoFar + 1;
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
{
trace?.Add($"pop cell=0x{cell.CellId:X8} skip=no-view");
continue;
}
// `seen` guarantees each cell is inserted into the todo list exactly once, so this single
// pop IS the cell's closest-first draw position (retail appends to cell_draw_list once per
// pop, 433783) — no per-pop dedup needed, OrderedVisibleCells stays distinct by construction.
frame.OrderedVisibleCells.Add(cell.CellId);
if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId);
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
int endCount = currentView.Polygons.Count;
if (processedCount >= endCount)
{
trace?.Add($"pop cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}");
continue;
}
trace?.Add($"pop cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} drawPos={frame.OrderedVisibleCells.Count - 1}");
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
processedViewCounts[cell.CellId] = endCount;
for (int i = 0; i < cell.Portals.Count; i++)
{
if (i >= cell.PortalPolygons.Count) continue;
var portal = cell.Portals[i];
if (i >= cell.PortalPolygons.Count)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=no-poly-slot");
continue;
}
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3) continue;
if (poly == null || poly.Length < 3)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=degenerate-poly len={(poly?.Length ?? -1)}");
continue;
}
bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF;
bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos);
bool sideAllowed = true;
// Portal-side test: only traverse a portal the camera is on the interior side of
// (mirrors CellVisibility.GetVisibleCells + retail's 'seen' flag). Culls back-facing
// portals so we never feed a degenerate/wrong-facing projection downstream.
if (i < cell.ClipPlanes.Count && !CameraOnInteriorSide(cell, i, cameraPos))
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos)
&& !eyeInsideOpening)
{
sideAllowed = false;
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}");
continue;
}
// Project to NDC, then normalize to CCW for the CCW-only ScreenPolygonClip
// (ProjectToNdc preserves input winding; portal dat polygons may be CW).
Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj);
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2}) ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F2},{v.Y:F2})"))}]");
var clippedRegion = new List<ViewPolygon>();
if (portalNdc.Length >= 3)
{
EnsureCcw(portalNdc);
// Intersect the portal opening with every polygon of the current cell's view.
foreach (var vp in currentView.Polygons)
{
var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices);
if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped));
}
}
// Retail PView::ClipPortals calls GetClip(..., finish=1): transform to
// homogeneous clip space, clip at the eye, then clip against the current
// portal_view region before the divide. Do the same here; the old early
// ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways.
var clippedRegion = ClipPortalAgainstView(
poly,
cell.WorldTransform,
viewProj,
activeViewPolygons,
out int clipVerts);
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
// R1 void fix (2026-06-05): the projected+clipped region is empty — normally we cull the
@ -171,25 +223,26 @@ public static class PortalVisibilityBuilder
if (clippedRegion.Count == 0)
{
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty side={sideAllowed} eyeIn={eyeInsideOpening} clipVerts={clipVerts}");
continue; // portal not visible through this chain, and the eye is not standing in it
foreach (var vp in currentView.Polygons)
}
foreach (var vp in activeViewPolygons)
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
}
var portal = cell.Portals[i];
if (portal.OtherCellId == 0xFFFF)
{
if (pvDump)
{
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} clipPolys={clippedRegion.Count}");
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipVerts={clipVerts} clipPolys={clippedRegion.Count}");
Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]");
Console.WriteLine($"[pv-dump] ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F3},{v.Y:F3})"))}]");
foreach (var cp in clippedRegion)
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
}
// Exit portal -> outdoors visible through this (clipped) opening.
foreach (var cp in clippedRegion) frame.OutsideView.Add(cp);
AddRegion(frame.OutsideView, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts} eyeIn={eyeInsideOpening}");
continue;
}
@ -202,12 +255,17 @@ public static class PortalVisibilityBuilder
if (buildingMembership != null && !buildingMembership(neighbourId))
{
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
foreach (var cp in clippedRegion) xview.Add(cp);
bool grewCross = AddRegion(xview, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}");
continue;
}
var neighbour = lookup(neighbourId);
if (neighbour == null) continue;
if (neighbour == null)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=lookup-miss polys={clippedRegion.Count}");
continue;
}
// Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip
// decomp:433524). The portal opening seen from THIS cell may be wider than the
@ -222,12 +280,24 @@ public static class PortalVisibilityBuilder
// direct index is what lets a cell with TWO portals to the same neighbour clip each
// opening against its OWN reciprocal instead of the first one. Mutates clippedRegion
// in place before the union below.
var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null;
int preReciprocalCount = clippedRegion.Count;
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
if (clippedRegion.Count == 0) continue; // reciprocal opening doesn't overlap → not visible
if (clippedRegion.Count == 0)
{
if (preReciprocalClip is null)
{
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=reciprocal-empty pre={preReciprocalCount} otherPortal={portal.OtherPortalId}");
continue;
}
clippedRegion.AddRange(preReciprocalClip);
}
// Union the clipped region into the neighbour's accumulated view.
var nview = GetOrCreate(frame.CellViews, neighbourId);
foreach (var cp in clippedRegion) nview.Add(cp);
bool grew = AddRegion(nview, clippedRegion);
bool inserted = false;
float dist = float.NaN;
// Insert the neighbour into the distance-priority list — but ONLY on first discovery
// (retail enqueues via InsCellTodoList solely in the ecx_5==0 branch; growth into an
@ -237,11 +307,13 @@ public static class PortalVisibilityBuilder
// portal-opening vertex in world space (retail InitCell min-vertex distance,
// 432988-433004); derived from the portal geometry, so it works even when the cell's
// WorldPosition was never populated.
if (seen.Add(neighbourId))
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
{
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
inserted = true;
}
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} addCell polys={clippedRegion.Count} clipVerts={clipVerts} recip={preReciprocalCount}->{clippedRegion.Count} grew={grew} queued={inserted} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}");
}
}
@ -252,6 +324,161 @@ public static class PortalVisibilityBuilder
// root cell's per-portal side-test + projection + the frame's exit/visible counts.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
EmitFlapProbe(cameraCell, cameraPos, viewProj, frame);
trace?.Emit(frame);
return frame;
}
/// <summary>
/// Build a portal visibility frame for an OUTDOOR viewer looking into one or more
/// outside-facing cell portals. This is the reciprocal of <see cref="Build"/>:
/// the seed view is the projected exit-portal opening instead of a full-screen
/// camera cell. It keeps the same retail distance-priority traversal and
/// neighbour reciprocal clipping once inside the building.
/// </summary>
public static PortalVisibilityFrame BuildFromExterior(
IEnumerable<LoadedCell> candidateCells,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity)
{
var frame = new PortalVisibilityFrame();
var todo = new CellTodoList();
var queued = new HashSet<uint>();
var drawListed = new HashSet<uint>();
var processedViewCounts = new Dictionary<uint, int>();
var popCounts = new Dictionary<uint, int>(); // per-cell pop count for the MaxReprocessPerCell cap
foreach (var cell in candidateCells)
{
if (cell is null) continue;
for (int i = 0; i < cell.Portals.Count; i++)
{
var portal = cell.Portals[i];
if (portal.OtherCellId != 0xFFFF)
continue;
if (i >= cell.PortalPolygons.Count)
continue;
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3)
continue;
// Exterior peering starts from the OUTSIDE face of an exit portal.
// If the camera is on the cell-interior side, the normal indoor
// DrawInside path owns this portal instead.
if (i < cell.ClipPlanes.Count && CameraOnInteriorSide(cell, i, cameraPos))
continue;
float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
if (seedDistance > maxSeedDistance)
continue;
var clippedRegion = ClipPortalAgainstView(
poly,
cell.WorldTransform,
viewProj,
FullScreenRegion,
out _);
if (clippedRegion.Count == 0)
{
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
continue;
clippedRegion.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone()));
}
var seedView = GetOrCreate(frame.CellViews, cell.CellId);
bool grew = AddRegion(seedView, clippedRegion);
if (grew && queued.Add(cell.CellId))
todo.Insert(cell, seedDistance);
}
}
while (todo.Count > 0)
{
var cell = todo.PopNearest();
queued.Remove(cell.CellId);
// Bounded re-enqueue — see the matching note in Build(). Count this pop; the gate below caps
// re-enqueues at MaxReprocessPerCell so the look-in flood terminates under ProjectToClip drift.
popCounts.TryGetValue(cell.CellId, out int popsSoFar);
popCounts[cell.CellId] = popsSoFar + 1;
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
continue;
if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId);
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
int endCount = currentView.Polygons.Count;
if (processedCount >= endCount)
continue;
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
processedViewCounts[cell.CellId] = endCount;
uint lbMask = cell.CellId & 0xFFFF0000u;
for (int i = 0; i < cell.Portals.Count; i++)
{
if (i >= cell.PortalPolygons.Count)
continue;
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3)
continue;
var portal = cell.Portals[i];
if (portal.OtherCellId == 0xFFFF)
continue; // already outdoors; exterior terrain was drawn by the caller.
bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos);
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos)
&& !eyeInsideOpening)
continue;
var clippedRegion = ClipPortalAgainstView(
poly,
cell.WorldTransform,
viewProj,
activeViewPolygons,
out _);
if (clippedRegion.Count == 0)
{
if (!eyeInsideOpening)
continue;
foreach (var vp in activeViewPolygons)
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
}
uint neighbourId = lbMask | portal.OtherCellId;
var neighbour = lookup(neighbourId);
if (neighbour == null)
continue;
var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null;
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
if (clippedRegion.Count == 0)
{
if (preReciprocalClip is null)
continue;
clippedRegion.AddRange(preReciprocalClip);
}
var nview = GetOrCreate(frame.CellViews, neighbourId);
bool grew = AddRegion(nview, clippedRegion);
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
{
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
}
}
}
return frame;
}
@ -260,6 +487,117 @@ public static class PortalVisibilityBuilder
private static readonly Vector2[] FullScreenQuad =
{ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
private static readonly ViewPolygon[] FullScreenRegion =
{ new ViewPolygon(FullScreenQuad) };
private static List<ViewPolygon> ClipPortalAgainstView(
Vector3[] localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj,
IReadOnlyList<ViewPolygon> viewPolygons,
out int clipVertexCount)
{
var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj);
clipVertexCount = portalClip.Length;
var clippedRegion = new List<ViewPolygon>();
if (portalClip.Length < 3)
return clippedRegion;
foreach (var vp in viewPolygons)
{
if (vp.IsEmpty)
continue;
var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices);
if (clipped.Length >= 3)
clippedRegion.Add(new ViewPolygon(clipped));
}
return clippedRegion;
}
private const int PortalTraceEmitLimit = 160;
private static readonly object s_portalTraceLock = new();
private static readonly Dictionary<uint, string> s_portalTraceLastSignature = new();
private static int s_portalTraceEmits;
private sealed class PortalBuildTrace
{
private readonly uint _rootCellId;
private readonly Vector3 _eye;
private readonly List<string> _lines = new();
private PortalBuildTrace(uint rootCellId, Vector3 eye)
{
_rootCellId = rootCellId;
_eye = eye;
}
public static PortalBuildTrace? Start(LoadedCell root, Vector3 eye)
{
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
return null;
if (!IsHoltburgIndoorProbeCell(root.CellId))
return null;
return new PortalBuildTrace(root.CellId, eye);
}
public void Add(string line)
{
if (_lines.Count < 96)
_lines.Add(line);
}
public void Emit(PortalVisibilityFrame frame)
{
string signature = BuildSignature(frame);
lock (s_portalTraceLock)
{
if (s_portalTraceEmits >= PortalTraceEmitLimit)
return;
if (s_portalTraceLastSignature.TryGetValue(_rootCellId, out var last) &&
string.Equals(last, signature, StringComparison.Ordinal))
return;
s_portalTraceLastSignature[_rootCellId] = signature;
s_portalTraceEmits++;
}
Console.WriteLine($"[pv-trace] root=0x{_rootCellId:X8} eye=({_eye.X:F2},{_eye.Y:F2},{_eye.Z:F2}) {signature}");
foreach (var line in _lines)
Console.WriteLine("[pv-trace] " + line);
}
}
private static bool IsHoltburgIndoorProbeCell(uint cellId)
{
if ((cellId & 0xFFFF0000u) != 0xA9B40000u)
return false;
uint low = cellId & 0xFFFFu;
return low >= 0x016F && low <= 0x0175;
}
private static string BuildSignature(PortalVisibilityFrame frame)
{
var sb = new System.Text.StringBuilder(160);
sb.Append("outPolys=").Append(frame.OutsideView.Polygons.Count);
sb.Append(" cells=[");
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
{
if (i != 0) sb.Append(',');
sb.Append("0x").Append((frame.OrderedVisibleCells[i] & 0xFFFFu).ToString("X4"));
}
sb.Append("] views=[");
bool first = true;
foreach (var kvp in frame.CellViews)
{
if (!first) sb.Append(',');
first = false;
sb.Append("0x").Append((kvp.Key & 0xFFFFu).ToString("X4")).Append(':').Append(kvp.Value.Polygons.Count);
}
sb.Append(']');
return sb.ToString();
}
// Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal
// signed distance D (eye→portal plane), traverse/cull decision, and NDC projection
// vertex count, plus the frame's OutsideView polygon count + visible-cell count.
@ -288,10 +626,10 @@ public static class PortalVisibilityBuilder
d = Vector3.Dot(pl.Normal, localEye) + pl.D;
side = CameraOnInteriorSide(cameraCell, i, cameraPos);
}
// Replicate the walk's project → EnsureCcw → Intersect(FullScreen) exactly, so a
// portal that PROJECTS (proj>=3) but still fails to ADD its neighbour shows WHY:
// clip=0 with ndc inside [-1,1] ⇒ winding/self-intersection degeneracy; clip=0 with
// ndc outside [-1,1] ⇒ genuinely off-screen. The ndc coords expose a near-plane bowtie.
// Replicate the walk's faithful path exactly (ProjectToClip → ClipToRegion(FullScreen)) so
// proj/clip mean the same as production: proj = clip-space verts in front of the eye,
// clip = verts surviving the screen-region clip. clip=0 with proj>=3 ⇒ the portal is
// genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands.
int projN = -1, clipN = -1;
string ndcText = "";
if (i < cameraCell.PortalPolygons.Count)
@ -299,12 +637,12 @@ public static class PortalVisibilityBuilder
var poly = cameraCell.PortalPolygons[i];
if (poly != null && poly.Length >= 3)
{
var ndc = PortalProjection.ProjectToNdc(poly, cameraCell.WorldTransform, viewProj);
projN = ndc.Length;
if (ndc.Length >= 3)
var clip = PortalProjection.ProjectToClip(poly, cameraCell.WorldTransform, viewProj);
projN = clip.Length;
if (clip.Length >= 3)
{
EnsureCcw(ndc);
clipN = ScreenPolygonClip.Intersect(ndc, FullScreenQuad).Length;
var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad);
clipN = ndc.Length;
var ns = new System.Text.StringBuilder(48);
foreach (var v in ndc) ns.Append('(').Append(v.X.ToString("F1")).Append(',').Append(v.Y.ToString("F1")).Append(')');
ndcText = ns.ToString();
@ -376,6 +714,13 @@ public static class PortalVisibilityBuilder
// Project the reciprocal opening through the NEIGHBOUR's transform (retail positionPush(3,
// &other_cell_ptr->pos) at 005a54d2), then normalize winding for the CCW-only clipper.
// NOTE: this stays on the divide-then-clip ProjectToNdc path on purpose. The reciprocal is a
// back-portal one hop away — never near the eye — so the homogeneous clip buys nothing here,
// and ProjectToNdc is float-stable across the BFS re-enqueue rounds. Routing it through
// ProjectToClip+ClipToRegion produced per-round float drift that defeated the CellView
// SamePolygon dedup, inflating a tight A<->B reciprocal view to ~4x its area
// (Build_AppliesReciprocalOtherPortalClip). The near-side clip (ClipPortalAgainstView) IS the
// homogeneous path; this secondary tightening is not.
Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj);
if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op
EnsureCcw(reciprocalNdc);
@ -395,11 +740,27 @@ public static class PortalVisibilityBuilder
return v;
}
private static bool AddRegion(CellView view, List<ViewPolygon> region)
{
bool grew = false;
foreach (var poly in region)
grew |= view.Add(poly);
return grew;
}
// Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal
// min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list:
// it walks the portal's vertices, transforms each to world space, and keeps the smallest
// straight-line distance to the camera viewpoint. Keying on the portal opening (not the cell
// origin) is both retail-faithful and robust to cells whose WorldPosition was never populated.
private static List<ViewPolygon> CloneViewPolygons(List<ViewPolygon> source)
{
var clone = new List<ViewPolygon>(source.Count);
foreach (var poly in source)
clone.Add(new ViewPolygon((Vector2[])poly.Vertices.Clone()));
return clone;
}
private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos)
{
float best = float.MaxValue;
@ -413,10 +774,11 @@ public static class PortalVisibilityBuilder
}
// "Eye standing in the opening": the eye is within this perpendicular distance of a portal's
// plane. At the cottage doorway the live capture measured D=0.16 m for the portal the chase
// camera was standing in; 0.5 m comfortably covers a doorway-standing eye while excluding portals
// the eye is merely facing from across a room (their projection is non-degenerate anyway).
private const float EyeStandingPerpDist = 0.5f;
// plane. Live captures hit two retail-valid degenerate cases: cottage doorway D=0.16 m and
// cellar->stair portal D=1.41 m, both traversable but ProjectToNdc returned zero vertices. We still
// require the perpendicular projection to land inside the opening, so side/offscreen portals stay
// culled; this only covers active portals whose 2D projection collapses near the chase camera.
private const float EyeStandingPerpDist = 1.75f;
/// <summary>
/// True when the camera eye is "standing in" <paramref name="localPoly"/>'s opening: within

View file

@ -0,0 +1,374 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// App-layer port of the retail indoor render orchestration:
/// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside ->
/// PView::DrawInside -> ConstructView -> DrawCells.
/// </summary>
public sealed class RetailPViewRenderer
{
private readonly GL _gl;
private readonly ClipFrame _clipFrame;
private readonly EnvCellRenderer _envCells;
private readonly WbDrawDispatcher _entities;
private static readonly ClipViewSlice NoClipSlice =
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>());
private readonly HashSet<uint> _oneCell = new(1);
private readonly Dictionary<uint, int> _oneCellSlot = new(1);
public RetailPViewRenderer(
GL gl,
ClipFrame clipFrame,
EnvCellRenderer envCells,
WbDrawDispatcher entities)
{
_gl = gl;
_clipFrame = clipFrame;
_envCells = envCells;
_entities = entities;
}
public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx);
var pvFrame = PortalVisibilityBuilder.Build(
ctx.RootCell,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection);
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
UploadClipFrame(ctx.SetTerrainClipUbo);
// R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the
// assembler handed a clip-slot. This feeds the Prepare filter + entity partition,
// so every visible cell's shell has a prepared batch and seals — killing the grey
// (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells).
// Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained).
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
UseIndoorMembershipOnlyRouting();
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
filter: drawableCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries);
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,
ClipAssembly = clipAssembly,
DrawableCells = drawableCells,
Partition = partition,
};
ctx.EmitDiagnostics?.Invoke(result);
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition);
UseIndoorMembershipOnlyRouting();
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition);
return result;
}
public RetailPViewFrameResult? DrawPortal(RetailPViewPortalDrawContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx);
var pvFrame = PortalVisibilityBuilder.BuildFromExterior(
ctx.CandidateCells,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection,
ctx.MaxSeedDistance);
if (pvFrame.OrderedVisibleCells.Count == 0)
{
RestoreNoClip(ctx.SetTerrainClipUbo);
return null;
}
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
UploadClipFrame(ctx.SetTerrainClipUbo);
var drawableCells = new HashSet<uint>(clipAssembly.CellIdToSlot.Keys);
UseIndoorMembershipOnlyRouting();
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
filter: drawableCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries);
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,
ClipAssembly = clipAssembly,
DrawableCells = drawableCells,
Partition = partition,
};
ctx.EmitDiagnostics?.Invoke(result);
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition);
RestoreNoClip(ctx.SetTerrainClipUbo);
return result;
}
private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition)
{
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
_entities.SetClipRouting(clipAssembly.CellIdToSlot, slice.Slot, outdoorVisible: true);
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, partition.Outdoor));
}
foreach (var slice in clipAssembly.OutsideViewSlices)
ctx.ClearDepthSlice?.Invoke(slice);
UseIndoorMembershipOnlyRouting();
}
private void DrawExitPortalMasks(
IRetailPViewCellDrawCallbacks ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells)
{
if (ctx.DrawExitPortalMasks is null)
return;
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = pvFrame.OrderedVisibleCells[i];
if (!drawableCells.Contains(cellId))
continue;
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty<WorldEntity>()));
}
}
private void DrawEnvCellShells(
IRetailPViewCellDrawCallbacks ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells) // param kept this task; removed in Task 4
{
// Retail DrawCells Loop 2: every visible cell's shell, reverse cell_draw_list
// (far→near), per portal_view slice. No drawableCells filter — a cell without a
// clip-slot falls through GetCellSlicesOrNoClip to NoClipSlice and draws unclipped
// (sealed; per-slice trim returns in Task 4).
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
uint cellId = entry.CellId;
_oneCell.Clear();
_oneCell.Add(cellId);
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
{
UseShellClipRouting(cellId, slice);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
}
}
private void DrawCellObjectLists(
IRetailPViewCellDrawContext ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
InteriorEntityPartition.Result partition)
{
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = pvFrame.OrderedVisibleCells[i];
if (!drawableCells.Contains(cellId))
continue;
if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0)
continue;
_oneCell.Clear();
_oneCell.Add(cellId);
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, bucket, _oneCell);
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, bucket));
}
}
private static ClipViewSlice[] GetCellSlicesOrNoClip(
ClipFrameAssembly clipAssembly,
uint cellId)
{
if (clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices)
&& slices.Length > 0)
return slices;
return new[] { NoClipSlice };
}
private void UseIndoorMembershipOnlyRouting()
{
// Retail's PView portal views decide which cells/objects are eligible,
// but DrawMesh only performs portal-view visibility checks before drawing
// the mesh. Feeding those 2D views into gl_ClipDistance slices characters
// and cell shells at stair/door boundaries, which retail does not do.
_envCells.SetClipRouting(null);
_entities.ClearClipRouting();
}
private void UseShellClipRouting(uint cellId, ClipViewSlice slice)
{
_oneCellSlot.Clear();
_oneCellSlot[cellId] = slice.Slot;
_envCells.SetClipRouting(_oneCellSlot);
_entities.ClearClipRouting();
}
private void DrawEntityBucket(
IRetailPViewCellDrawContext ctx,
IReadOnlyList<WorldEntity> bucket,
HashSet<uint>? visibleCellIds)
{
uint lbId = ctx.PlayerLandblockId ?? 0u;
var entry = (lbId, Vector3.Zero, Vector3.Zero,
(IReadOnlyList<WorldEntity>)bucket,
(IReadOnlyDictionary<uint, WorldEntity>?)null);
_entities.Draw(
ctx.Camera,
new[] { entry },
ctx.Frustum,
neverCullLandblockId: ctx.PlayerLandblockId,
visibleCellIds: visibleCellIds,
animatedEntityIds: ctx.AnimatedEntityIds);
}
private void RestoreNoClip(Action<uint> setTerrainClipUbo)
{
_clipFrame.Reset();
UploadClipFrame(setTerrainClipUbo);
UseIndoorMembershipOnlyRouting();
}
private void UploadClipFrame(Action<uint> setTerrainClipUbo)
{
_clipFrame.UploadShared(_gl);
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
setTerrainClipUbo(_clipFrame.TerrainUbo);
}
}
public interface IRetailPViewCellDrawCallbacks
{
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; }
}
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
{
public ICamera Camera { get; }
public FrustumPlanes? Frustum { get; }
public uint? PlayerLandblockId { get; }
public HashSet<uint>? AnimatedEntityIds { get; }
}
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
{
public required LoadedCell RootCell { get; init; }
public required Vector3 ViewerEyePos { get; init; }
public required Matrix4x4 ViewProjection { get; init; }
public required Func<uint, LoadedCell?> CellLookup { get; init; }
public required ICamera Camera { get; init; }
public required Vector3 CameraWorldPosition { get; init; }
public required FrustumPlanes? Frustum { get; init; }
public required uint? PlayerLandblockId { get; init; }
public required HashSet<uint>? AnimatedEntityIds { get; init; }
public required int RenderCenterLbX { get; init; }
public required int RenderCenterLbY { get; init; }
public required int RenderRadius { get; init; }
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<uint> SetTerrainClipUbo { get; init; }
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
public Action<ClipViewSlice>? ClearDepthSlice { get; init; }
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}
public sealed class RetailPViewPortalDrawContext : IRetailPViewCellDrawContext
{
public required IEnumerable<LoadedCell> CandidateCells { get; init; }
public required Vector3 ViewerEyePos { get; init; }
public required Matrix4x4 ViewProjection { get; init; }
public required Func<uint, LoadedCell?> CellLookup { get; init; }
public required ICamera Camera { get; init; }
public required Vector3 CameraWorldPosition { get; init; }
public required FrustumPlanes? Frustum { get; init; }
public required uint? PlayerLandblockId { get; init; }
public required HashSet<uint>? AnimatedEntityIds { get; init; }
public required int RenderCenterLbX { get; init; }
public required int RenderCenterLbY { get; init; }
public required int RenderRadius { get; init; }
public required float MaxSeedDistance { get; init; }
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<uint> SetTerrainClipUbo { get; init; }
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}
public sealed class RetailPViewFrameResult
{
public required PortalVisibilityFrame PortalFrame { get; init; }
public required ClipFrameAssembly ClipAssembly { get; init; }
public required HashSet<uint> DrawableCells { get; init; }
public required InteriorEntityPartition.Result Partition { get; init; }
}
public readonly record struct RetailPViewLandscapeSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> OutdoorEntities);
public readonly record struct RetailPViewCellSliceContext(
uint CellId,
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> CellEntities);

View file

@ -1291,21 +1291,24 @@ namespace AcDream.App.Rendering.Wb {
ct.ThrowIfCancellationRequested();
if (poly.VertexIds.Count < 3) continue;
// Handle Positive Surface
if (!poly.Stippling.HasFlag(StipplingType.NoPos)) {
AddSurfaceToBatch(poly, poly.PosSurface, false);
// Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this
// DatReaderWriter "CullMode" as CPolygon::sides_type, not as a
// GL cull enum: 0 = pos, 1 = pos twice with reversed winding,
// 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still
// suppress hidden portal/cap faces before they reach our mesh.
bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos);
bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg);
if (hasPos)
AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false);
if (hasPos && poly.SidesType == CullMode.None) {
AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true);
}
else if (hasNeg && poly.SidesType == CullMode.Clockwise) {
AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false);
}
// Handle Negative Surface
bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) ||
poly.Stippling.HasFlag(StipplingType.Both) ||
(!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise);
if (hasNeg) {
AddSurfaceToBatch(poly, poly.NegSurface, true);
}
void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool isNeg) {
void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) {
if (surfaceIdx < 0) return;
uint surfaceId;
@ -1499,7 +1502,17 @@ namespace AcDream.App.Rendering.Wb {
// Helper for CellStruct vertices
bool batchHasWrappingUVs = batch.HasWrappingUVs;
BuildCellStructPolygonIndices(poly, cellStruct, UVLookup, vertices, batch.Indices, isNeg, transform, ref batchHasWrappingUVs);
BuildCellStructPolygonIndices(
poly,
cellStruct,
UVLookup,
vertices,
batch.Indices,
useNegUv,
invertNormal,
reverseWinding,
transform,
ref batchHasWrappingUVs);
batch.HasWrappingUVs = batchHasWrappingUVs;
}
}
@ -1516,8 +1529,10 @@ namespace AcDream.App.Rendering.Wb {
}
private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct,
Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup,
List<VertexPositionNormalTexture> vertices, List<ushort> indices, bool useNegSurface, Matrix4x4 transform, ref bool hasWrappingUVs) {
Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup,
List<VertexPositionNormalTexture> vertices, List<ushort> indices,
bool useNegUv, bool invertNormal, bool reverseWinding,
Matrix4x4 transform, ref bool hasWrappingUVs) {
var polyIndices = new List<ushort>();
@ -1525,9 +1540,9 @@ namespace AcDream.App.Rendering.Wb {
ushort vertId = (ushort)poly.VertexIds[i];
ushort uvIdx = 0;
if (useNegSurface && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
uvIdx = poly.NegUVIndices[i];
else if (!useNegSurface && poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
uvIdx = poly.PosUVIndices[i];
if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue;
@ -1536,7 +1551,7 @@ namespace AcDream.App.Rendering.Wb {
uvIdx = 0;
}
var key = (vertId, uvIdx, useNegSurface);
var key = (vertId, uvIdx, invertNormal);
if (!hasWrappingUVs) {
var uvCheck = vertex.UVs.Count > 0
@ -1553,7 +1568,7 @@ namespace AcDream.App.Rendering.Wb {
: Vector2.Zero;
var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform));
if (useNegSurface) {
if (invertNormal) {
normal = -normal;
}
@ -1568,18 +1583,18 @@ namespace AcDream.App.Rendering.Wb {
polyIndices.Add(idx);
}
if (useNegSurface) {
if (reverseWinding) {
for (int i = 2; i < polyIndices.Count; i++) {
indices.Add(polyIndices[0]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[i]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[0]);
}
}
else {
for (int i = 2; i < polyIndices.Count; i++) {
indices.Add(polyIndices[i]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[0]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[i]);
}
}
}

View file

@ -144,8 +144,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// each Draw. When _clipRoutingActive is false (the U.3 path / outdoor root /
// no portal frame), every instance maps to slot 0 (no-clip) and no instance is
// culled — identical to U.3. When active, each instance's slot is resolved by
// ResolveEntitySlot per the U.4 policy (live-dynamic unclipped; cell statics to
// their cell slot; outdoor scenery to the OutsideView slot; non-visible culled).
// ResolveEntitySlot per the U.4 policy (cell-owned entities to their cell slot;
// outdoor-owned entities to OutsideView; non-visible/unresolved indoors culled).
private bool _clipRoutingActive;
private IReadOnlyDictionary<uint, int>? _cellIdToSlot;
private int _outdoorSlot;
@ -310,8 +310,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// Phase U.4: install the per-frame clip-slot routing for an INDOOR root.
/// Call once per frame BEFORE <see cref="Draw"/> when the camera's root cell is
/// non-null; the next <see cref="Draw"/> resolves each instance's binding=3
/// clip slot via the U.4 policy (live-dynamic unclipped, cell statics to their
/// cell slot, outdoor scenery to the OutsideView slot, non-visible culled).
/// clip slot via the U.4 policy (cell-owned entities to their cell slot,
/// outdoor-owned entities to OutsideView, non-visible/unresolved indoors culled).
/// Pair with <see cref="ClearClipRouting"/> on outdoor-root frames so the
/// dispatcher reverts to the U.3 no-clip-everything behavior.
/// </summary>
@ -354,12 +354,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// Phase U.4: resolve the clip slot for one entity per the slot/gate policy.
/// Returns <see cref="ClipSlotCull"/> to drop the entity's instances entirely.
/// <list type="bullet">
/// <item>ServerGuid != 0 (live dynamic: player / NPC / items / doors) ⇒ slot 0
/// (UNCLIPPED — retail draws live-dynamic unclipped; depth only).</item>
/// <item>ParentCellId != null (cell static) ⇒ the cell's slot, or CULL when the
/// cell isn't in <paramref name="cellIdToSlot"/> (not visible / nothing-visible).</item>
/// <item>ParentCellId == null (outdoor scenery / building shell) ⇒ the OutsideView
/// slot when <paramref name="outdoorVisible"/>, else CULL.</item>
/// <item>Indoor ParentCellId: the cell's slot, or CULL when hidden.</item>
/// <item>Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot
/// when <paramref name="outdoorVisible"/>, else CULL.</item>
/// <item>ServerGuid != 0 with ParentCellId == null: CULL while routing is active.</item>
/// </list>
/// Only called when <c>_clipRoutingActive</c> (indoor root). On the U.3 / outdoor
/// path every instance is slot 0 and nothing is culled — see
@ -385,20 +383,37 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
int outdoorSlot,
bool outdoorVisible)
{
// Live-dynamic entities render unclipped regardless of cell — retail draws
// the player / NPCs / dropped items through the depth buffer without portal
// clipping. ServerGuid is the live-dynamic marker (0 for dat-hydrated).
if (serverGuid != 0)
return 0;
// Live-dynamic entities are not a global indoor overlay. When they
// have current cell ownership, route them through the same visible
// cell/OutsideView graph as every other object. Parentless live objects
// are unresolved indoors, so cull them while clip routing is active.
if (parentCellId is uint parentCell)
return cellIdToSlot.TryGetValue(parentCell, out int slot) ? slot : ClipSlotCull;
{
if (IsIndoorCellId(parentCell))
{
if (!cellIdToSlot.ContainsKey(parentCell))
return ClipSlotCull;
return cellIdToSlot[parentCell];
}
return outdoorVisible ? outdoorSlot : ClipSlotCull;
}
if (serverGuid != 0)
return ClipSlotCull;
// Outdoor scenery / building shell (no ParentCellId). Indoor root: gate to
// the OutsideView slot, or cull when nothing outdoors is visible.
return outdoorVisible ? outdoorSlot : ClipSlotCull;
}
private static bool IsIndoorCellId(uint cellId)
{
uint low = cellId & 0xFFFFu;
return low >= 0x0100u && low != 0xFFFFu;
}
/// <summary>
/// Phase U.4: the call-site clip-slot decision for one entity, returning the
/// <c>(Slot, Culled)</c> pair the per-entity loop body consumes. Wraps

View file

@ -280,9 +280,9 @@ public static class RenderingDiagnostics
/// DrawInside vs the outdoor <c>LScape::draw</c> on <c>is_player_outside</c> — the
/// <b>PLAYER's</b> cell (<c>(player-&gt;m_position.objcell_id &amp; 0xFFFF) &lt; 0x100</c>,
/// <c>SmartBox::is_player_outside</c> 0x451e80) — NOT the camera/viewer cell. When the
/// player is inside it then roots the flood at the <b>viewer</b> cell
/// (<c>this-&gt;viewer_cell</c>). So the inside/outside <i>decision</i> follows the player;
/// only the indoor <i>root</i> follows the camera.</para>
/// player is inside, acdream roots the portal flood at the player's transition-owned
/// physics cell and projects from the camera eye, so the shell around the player remains
/// sealed during chase-camera cell transitions.</para>
///
/// <para>acdream historically branched on the camera cell (a non-null
/// <c>visibility.CameraCell</c>). A 3rd-person chase camera lags the player, so when the
@ -292,9 +292,9 @@ public static class RenderingDiagnostics
/// only entities (which bypass the gate) showing through. Branching on the player removes it.</para>
///
/// <param name="playerCellId">The player's current cell id (0 if unresolved → outside).</param>
/// <param name="viewerCellResolved">Whether a viewer/camera cell is available to root
/// DrawInside at. Indoor render needs both: the player inside AND a cell to root at.</param>
/// <param name="renderRootResolved">Whether the player's indoor render root is loaded and
/// available to DrawInside.</param>
/// </summary>
public static bool ShouldRenderIndoor(uint playerCellId, bool viewerCellResolved)
=> viewerCellResolved && IsEnvCellId(playerCellId);
public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved)
=> renderRootResolved && IsEnvCellId(playerCellId);
}

View file

@ -37,12 +37,13 @@ public sealed class WorldEntity
public PaletteOverride? PaletteOverride { get; init; }
/// <summary>
/// EnvCell ID that owns this entity (room geometry or static object inside
/// EnvCell or outdoor cell ID that owns this entity (room geometry, static
/// object, or live object inside/outside a cell).
/// the cell). Used by portal visibility to filter interior entities — only
/// entities whose ParentCellId appears in the visible set are rendered.
/// Null for outdoor entities (stabs, scenery, live server spawns).
/// Null for outdoor dat scenery/building stabs or unresolved live entities.
/// </summary>
public uint? ParentCellId { get; init; }
public uint? ParentCellId { get; set; }
/// <summary>
/// True when this entity originates from <c>LandBlockInfo.Buildings[]</c>

View file

@ -0,0 +1,64 @@
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
// Regression tests for the indoor-render HANG (2026-06-06): the portal-visibility flood
// re-queues a cell whenever its CellView grows, so it only terminates when CellView.Add's
// dedup catches a duplicate. Across BFS rounds the same region comes back float-drifted,
// vertex-rotated, or with a ±1 vertex count; the old exact index-by-index SamePolygon
// (eps 1e-4) missed all three, so the region grew forever -> CPU-spin hang in CellView.Add.
// A drift-tolerant, rotation-invariant dedup makes the key space finite, so the flood
// converges (and these duplicates collapse).
public class CellViewDedupTests
{
private static ViewPolygon Quad(float ox, float oy) => new(new[]
{
new Vector2(ox - 0.5f, oy - 0.5f), new Vector2(ox + 0.5f, oy - 0.5f),
new Vector2(ox + 0.5f, oy + 0.5f), new Vector2(ox - 0.5f, oy + 0.5f),
});
[Fact]
public void Add_DropsSubGridDriftDuplicate()
{
var v = new CellView();
Assert.True(v.Add(Quad(0f, 0f)));
// Same quad, every vertex nudged 3e-4 — beyond the old 1e-4 SamePolygon eps,
// within the 1e-3 dedup grid. This is the per-round float drift that caused the hang.
var drifted = new ViewPolygon(new[]
{
new Vector2(-0.5f + 3e-4f, -0.5f - 3e-4f), new Vector2(0.5f + 3e-4f, -0.5f - 3e-4f),
new Vector2(0.5f + 3e-4f, 0.5f - 3e-4f), new Vector2(-0.5f + 3e-4f, 0.5f - 3e-4f),
});
Assert.False(v.Add(drifted));
Assert.Single(v.Polygons);
}
[Fact]
public void Add_DropsRotatedStartDuplicate()
{
var v = new CellView();
Assert.True(v.Add(Quad(0f, 0f)));
// Same 4 corners (same CCW cycle), but emitted starting at the 2nd vertex — what
// Sutherland-Hodgman can do when the subject order differs across rounds.
var rotated = new ViewPolygon(new[]
{
new Vector2(0.5f, -0.5f), new Vector2(0.5f, 0.5f),
new Vector2(-0.5f, 0.5f), new Vector2(-0.5f, -0.5f),
});
Assert.False(v.Add(rotated));
Assert.Single(v.Polygons);
}
[Fact]
public void Add_KeepsGenuinelyDistinctPolygons()
{
// The fix must NOT over-merge: two regions 0.4 NDC apart (far beyond the 1e-3 grid)
// remain distinct, so a real second portal opening is not silently dropped.
var v = new CellView();
Assert.True(v.Add(Quad(0f, 0f)));
Assert.True(v.Add(Quad(0.4f, 0f)));
Assert.Equal(2, v.Polygons.Count);
}
}

View file

@ -5,36 +5,27 @@ using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Phase U.4: GL-free proof that <see cref="ClipFrameAssembler"/> implements the
/// slot/gate policy — slot 0 no-clip, one CellClip slot per visible cell with a
/// convex region, OutsideView routed to the terrain decision + the outdoor mesh
/// slot, and the three Count==0 dispositions (nothing-visible cull, scissor
/// fallback → no-clip, planes). Hand-built <see cref="PortalVisibilityFrame"/>s
/// drive the assembler directly (no portal BFS needed) so each disposition is
/// exercised in isolation.
/// </summary>
public class ClipFrameAssemblerTests
{
// A convex NDC square → ClipPlaneSet.From yields 4 planes (Count > 0).
private static ViewPolygon Square(float cx, float cy, float half) => new(new[]
{
new Vector2(cx - half, cy - half), new Vector2(cx + half, cy - half),
new Vector2(cx + half, cy + half), new Vector2(cx - half, cy + half),
new Vector2(cx - half, cy - half),
new Vector2(cx + half, cy - half),
new Vector2(cx + half, cy + half),
new Vector2(cx - half, cy + half),
});
private static CellView ViewOf(params ViewPolygon[] polys)
{
var v = new CellView();
foreach (var p in polys) v.Add(p);
return v;
var view = new CellView();
foreach (var p in polys)
view.Add(p);
return view;
}
[Fact]
public void TwoVisibleCells_PlusOutsideView_ProducesCorrectSlotMapAndCounts()
{
// Two cells with single convex regions (→ planes, mapped to slots 1 and 2)
// and a single-convex OutsideView (→ planes, the outdoor slot 3).
const uint cellA = 0xA9B40100;
const uint cellB = 0xA9B40101;
@ -45,199 +36,154 @@ public class ClipFrameAssemblerTests
pv.OrderedVisibleCells.Add(cellB);
pv.OutsideView.Add(Square(0f, 0.5f, 0.25f));
var frame = ClipFrame.NoClip();
var asm = ClipFrameAssembler.Assemble(frame, pv);
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
// slot 0 reserved (no-clip) + 2 cells + 1 outdoor = 4 slots.
Assert.Equal(4, asm.Frame.SlotCount);
// Both cells mapped to NON-zero slots (real plane regions), distinct.
Assert.True(asm.CellIdToSlot.ContainsKey(cellA));
Assert.True(asm.CellIdToSlot.ContainsKey(cellB));
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cells + one outside slice
Assert.Contains(cellA, asm.CellIdToSlot.Keys);
Assert.Contains(cellB, asm.CellIdToSlot.Keys);
Assert.NotEqual(0, asm.CellIdToSlot[cellA]);
Assert.NotEqual(0, asm.CellIdToSlot[cellB]);
Assert.NotEqual(asm.CellIdToSlot[cellA], asm.CellIdToSlot[cellB]);
// Per-cell plane counts recorded (a convex square reduces to 4 planes).
Assert.Equal(4, asm.PerCellPlaneCounts[cellA]);
Assert.Equal(4, asm.PerCellPlaneCounts[cellB]);
// OutsideView: visible, convex → planes, a non-zero outdoor slot, terrain
// gated via planes.
Assert.True(asm.OutdoorVisible);
Assert.NotEqual(0, asm.OutdoorSlot);
Assert.Single(asm.OutsideViewSlices);
Assert.Equal(asm.OutdoorSlot, asm.OutsideViewSlices[0].Slot);
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
Assert.Equal(4, asm.OutsidePlaneCount);
Assert.Equal(0, asm.ScissorFallbacks);
// The outdoor slot differs from both cell slots and from slot 0.
Assert.NotEqual(asm.CellIdToSlot[cellA], asm.OutdoorSlot);
Assert.NotEqual(asm.CellIdToSlot[cellB], asm.OutdoorSlot);
}
[Fact]
public void NothingVisibleCell_IsExcludedFromSlotMap_AndNotAppended()
{
// cellA is a real convex region; cellB's CellView is EMPTY → ClipPlaneSet
// .IsNothingVisible → it must NOT be mapped and NOT consume a slot.
const uint cellA = 0xA9B40100;
const uint cellB = 0xA9B40101;
var pv = new PortalVisibilityFrame();
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
pv.CellViews[cellB] = new CellView(); // empty ⇒ nothing visible
pv.CellViews[cellB] = new CellView();
pv.OrderedVisibleCells.Add(cellA);
pv.OrderedVisibleCells.Add(cellB);
// OutsideView empty ⇒ terrain Skip (the bleed fix), outdoor not visible.
var frame = ClipFrame.NoClip();
var asm = ClipFrameAssembler.Assemble(frame, pv);
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
// slot 0 + cellA only = 2 slots. cellB consumed none.
Assert.Equal(2, asm.Frame.SlotCount);
Assert.True(asm.CellIdToSlot.ContainsKey(cellA));
Assert.False(asm.CellIdToSlot.ContainsKey(cellB)); // culled — not drawable
// Empty OutsideView ⇒ outdoor culled + terrain skipped (the bleed fix).
Assert.Contains(cellA, asm.CellIdToSlot.Keys);
Assert.DoesNotContain(cellB, asm.CellIdToSlot.Keys);
Assert.False(asm.OutdoorVisible);
Assert.Empty(asm.OutsideViewSlices);
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
Assert.Equal(0, asm.OutsidePlaneCount);
}
[Fact]
public void OutsideViewScissorFallback_MapsTerrainToScissor_AndCountsFallback()
public void OutsideViewMultiPolygon_PreservesRetailSlices()
{
// A MULTI-polygon OutsideView is a union (non-convex) ⇒ ClipPlaneSet falls
// back to the union AABB scissor: mesh outdoor slot 0 (no-clip over-include),
// terrain → Scissor, one fallback counted.
const uint cellA = 0xA9B40100;
var pv = new PortalVisibilityFrame();
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
pv.OrderedVisibleCells.Add(cellA);
pv.OutsideView.Add(Square(-0.5f, 0f, 0.1f));
pv.OutsideView.Add(Square(0.5f, 0f, 0.1f)); // 2 polys ⇒ scissor fallback
pv.OutsideView.Add(Square(0.5f, 0f, 0.1f));
var frame = ClipFrame.NoClip();
var asm = ClipFrameAssembler.Assemble(frame, pv);
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
Assert.True(asm.OutdoorVisible);
Assert.Equal(0, asm.OutdoorSlot); // no-clip over-include
Assert.Equal(TerrainClipMode.Scissor, asm.TerrainMode);
Assert.Equal(0, asm.OutsidePlaneCount); // scissor ⇒ 0 planes
Assert.Equal(1, asm.ScissorFallbacks);
// The terrain scissor AABB is a valid (min <= max) NDC box spanning both
// OutsideView squares: minX <= -0.6, maxX >= 0.6.
Assert.True(asm.TerrainScissorNdcAabb.X <= asm.TerrainScissorNdcAabb.Z);
Assert.True(asm.TerrainScissorNdcAabb.Y <= asm.TerrainScissorNdcAabb.W);
Assert.True(asm.TerrainScissorNdcAabb.X <= -0.59f);
Assert.True(asm.TerrainScissorNdcAabb.Z >= 0.59f);
Assert.NotEqual(0, asm.OutdoorSlot);
Assert.Equal(2, asm.OutsideViewSlices.Length);
Assert.NotEqual(asm.OutsideViewSlices[0].Slot, asm.OutsideViewSlices[1].Slot);
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
Assert.Equal(4, asm.OutsidePlaneCount);
Assert.Equal(0, asm.ScissorFallbacks);
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + cell + two outside slices
Assert.Equal(Vector4.Zero, asm.TerrainScissorNdcAabb);
}
[Fact]
public void CellScissorFallback_MapsCellToSlotZero_AndCountsFallback()
public void CellMultiPolygonView_PreservesRetailViewSlices()
{
// A cell with a MULTI-polygon region → scissor fallback → mapped to slot 0
// (no-clip over-include), recorded with 0 planes, one fallback counted. The
// OutsideView is a single convex region (planes) so only the CELL counts.
const uint cellA = 0xA9B40100;
var pv = new PortalVisibilityFrame();
pv.CellViews[cellA] = ViewOf(Square(-0.4f, 0f, 0.1f), Square(0.4f, 0f, 0.1f));
pv.CellViews[cellA] = ViewOf(
Square(-0.4f, 0f, 0.1f),
Square(0.4f, 0f, 0.1f));
pv.OrderedVisibleCells.Add(cellA);
pv.OutsideView.Add(Square(0f, 0f, 0.3f));
var frame = ClipFrame.NoClip();
var asm = ClipFrameAssembler.Assemble(frame, pv);
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
// cellA → slot 0 (no-clip). slot 0 + the OutsideView's planes slot = 2.
Assert.Equal(0, asm.CellIdToSlot[cellA]);
Assert.Equal(0, asm.PerCellPlaneCounts[cellA]);
Assert.Equal(2, asm.Frame.SlotCount); // slot0 + OutsideView
Assert.Equal(1, asm.ScissorFallbacks); // the cell fallback
Assert.True(asm.CellIdToSlot[cellA] > 0);
Assert.Equal(2, asm.CellIdToViewSlots[cellA].Length);
Assert.Equal(2, asm.CellIdToViewSlices[cellA].Length);
Assert.NotEqual(asm.CellIdToViewSlots[cellA][0], asm.CellIdToViewSlots[cellA][1]);
Assert.Equal(4, asm.PerCellPlaneCounts[cellA]);
Assert.Single(asm.OutsideViewSlices);
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cell slices + outside slice
Assert.Equal(0, asm.ScissorFallbacks);
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
}
// -----------------------------------------------------------------------
// Phase W Stage 4: HasOutsideView + OutsideViewNdcAabb
// -----------------------------------------------------------------------
[Fact]
public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds()
{
// A single convex quad in OutsideView reduces to Planes. HasOutsideView must
// be true and OutsideViewNdcAabb must match the polygon's own Min/Max values.
var pv = new PortalVisibilityFrame();
var poly = Square(-0.3f, 0.2f, 0.25f);
pv.OutsideView.Add(poly);
// No interior cells needed for this assertion.
pv.OutsideView.Add(Square(-0.3f, 0.2f, 0.25f));
var frame = ClipFrame.NoClip();
var asm = ClipFrameAssembler.Assemble(frame, pv);
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
Assert.True(asm.HasOutsideView);
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
Assert.Single(asm.OutsideViewSlices);
// The OutsideViewNdcAabb must equal the CellView's accumulated Min/Max.
var expected = new System.Numerics.Vector4(
var expected = new Vector4(
pv.OutsideView.MinX, pv.OutsideView.MinY,
pv.OutsideView.MaxX, pv.OutsideView.MaxY);
Assert.Equal(expected, asm.OutsideViewNdcAabb);
}
[Fact]
public void Assemble_OutsideViewMultiPolygon_ScissorMode_HasOutsideViewTrue_AabbValid()
public void Assemble_OutsideViewMultiPolygon_PreservesSlicesAndUnionAabb()
{
// Two polygons in OutsideView → union (non-convex) → ClipPlaneSet forces
// scissor fallback. HasOutsideView must still be true, TerrainMode must be
// Scissor, and OutsideViewNdcAabb must equal the union bounds (same values
// as TerrainScissorNdcAabb in this mode).
var pv = new PortalVisibilityFrame();
pv.OutsideView.Add(Square(-0.6f, 0f, 0.15f));
pv.OutsideView.Add(Square(0.6f, 0f, 0.15f));
var frame = ClipFrame.NoClip();
var asm = ClipFrameAssembler.Assemble(frame, pv);
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
Assert.True(asm.HasOutsideView);
Assert.Equal(TerrainClipMode.Scissor, asm.TerrainMode);
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
Assert.Equal(2, asm.OutsideViewSlices.Length);
// Union bounds from the CellView (spans both squares).
var expectedAabb = new System.Numerics.Vector4(
var expected = new Vector4(
pv.OutsideView.MinX, pv.OutsideView.MinY,
pv.OutsideView.MaxX, pv.OutsideView.MaxY);
Assert.Equal(expectedAabb, asm.OutsideViewNdcAabb);
// In Scissor mode OutsideViewNdcAabb and TerrainScissorNdcAabb are the same
// value (both are the union CellView bounds).
Assert.Equal(asm.TerrainScissorNdcAabb, asm.OutsideViewNdcAabb);
Assert.Equal(expected, asm.OutsideViewNdcAabb);
Assert.Equal(Vector4.Zero, asm.TerrainScissorNdcAabb);
}
[Fact]
public void Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero()
{
// An empty OutsideView means no exit portal was in view → TerrainMode.Skip,
// HasOutsideView false, OutsideViewNdcAabb degenerate zero.
const uint cellA = 0xA9B40100;
var pv = new PortalVisibilityFrame();
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
pv.OrderedVisibleCells.Add(cellA);
// OutsideView left empty (no exit portal).
var frame = ClipFrame.NoClip();
var asm = ClipFrameAssembler.Assemble(frame, pv);
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
Assert.False(asm.HasOutsideView);
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
Assert.Equal(System.Numerics.Vector4.Zero, asm.OutsideViewNdcAabb);
Assert.Equal(Vector4.Zero, asm.OutsideViewNdcAabb);
}
[Fact]
public void Reset_ReusesFrame_NoSlotLeakAcrossAssemblies()
{
// First assembly packs 2 cells + outdoor (4 slots). Re-assembling the SAME
// frame from a smaller pvFrame must Reset back to slot 0 — no leak.
var frame = ClipFrame.NoClip();
var pv1 = new PortalVisibilityFrame();
@ -249,17 +195,15 @@ public class ClipFrameAssemblerTests
var asm1 = ClipFrameAssembler.Assemble(frame, pv1);
Assert.Equal(4, asm1.Frame.SlotCount);
// Second assembly: a single cell, no OutsideView.
var pv2 = new PortalVisibilityFrame();
pv2.CellViews[0xA9B40200] = ViewOf(Square(0f, 0f, 0.25f));
pv2.OrderedVisibleCells.Add(0xA9B40200);
var asm2 = ClipFrameAssembler.Assemble(frame, pv2);
// slot 0 + 1 cell = 2 — the prior 4-slot state did not leak.
Assert.Equal(2, asm2.Frame.SlotCount);
Assert.True(asm2.CellIdToSlot.ContainsKey(0xA9B40200));
Assert.False(asm2.CellIdToSlot.ContainsKey(0xA9B40100)); // gone after Reset
Assert.False(asm2.OutdoorVisible); // no OutsideView this time
Assert.Contains(0xA9B40200, asm2.CellIdToSlot.Keys);
Assert.DoesNotContain(0xA9B40100, asm2.CellIdToSlot.Keys);
Assert.False(asm2.OutdoorVisible);
Assert.Equal(TerrainClipMode.Skip, asm2.TerrainMode);
}
}

View file

@ -252,4 +252,5 @@ public class ClipPlaneSetTests
// need a real AABB; a zero-area line has none).
Assert.True(cps.IsNothingVisible);
}
}

View file

@ -10,6 +10,8 @@ public class InteriorEntityPartitionTests
{
private const uint CellA = 0xA9B40170;
private const uint CellB = 0xA9B40171;
private const uint HiddenCell = 0xA9B40199;
private const uint OutdoorCell = 0xA9B40020;
private static WorldEntity Ent(uint id, uint serverGuid, uint? parentCell) => new()
{
@ -28,39 +30,44 @@ public class InteriorEntityPartitionTests
(IReadOnlyDictionary<uint, WorldEntity>?)null) };
[Fact]
public void Partitions_ByServerGuidThenParentCell_IntoThreeBuckets()
public void Partitions_LiveAndStaticEntities_ByCellOutdoorAndFallback()
{
var livePlayer = Ent(1, serverGuid: 0x5000000A, parentCell: null); // live-dynamic
var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); // live-dynamic WINS over cell
var staticA = Ent(3, serverGuid: 0, parentCell: CellA); // per-cell static
var staticB = Ent(4, serverGuid: 0, parentCell: CellB); // per-cell static
var scenery = Ent(5, serverGuid: 0, parentCell: null); // outdoor scenery
var unresolvedLive = Ent(1, serverGuid: 0x5000000A, parentCell: null);
var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA);
var staticA = Ent(3, serverGuid: 0, parentCell: CellA);
var staticB = Ent(4, serverGuid: 0, parentCell: CellB);
var scenery = Ent(5, serverGuid: 0, parentCell: null);
var liveOutdoor = Ent(6, serverGuid: 0x80005678, parentCell: OutdoorCell);
var visible = new HashSet<uint> { CellA, CellB };
var result = InteriorEntityPartition.Partition(
visible, OneLb(0xA9B4FFFF, livePlayer, liveNpcInCell, staticA, staticB, scenery));
visible, OneLb(0xA9B4FFFF, unresolvedLive, liveNpcInCell, staticA, staticB, scenery, liveOutdoor));
Assert.Equal(2, result.LiveDynamic.Count); // player + npc (serverGuid != 0)
Assert.Contains(livePlayer, result.LiveDynamic);
Assert.Contains(liveNpcInCell, result.LiveDynamic);
Assert.Single(result.LiveDynamic);
Assert.Contains(unresolvedLive, result.LiveDynamic);
Assert.Single(result.ByCell[CellA]); // only staticA (npc went live-dynamic)
Assert.Equal(2, result.ByCell[CellA].Count);
Assert.Contains(liveNpcInCell, result.ByCell[CellA]);
Assert.Contains(staticA, result.ByCell[CellA]);
Assert.Single(result.ByCell[CellB]);
Assert.Contains(staticB, result.ByCell[CellB]);
Assert.Single(result.Outdoor);
Assert.Equal(2, result.Outdoor.Count);
Assert.Contains(scenery, result.Outdoor);
Assert.Contains(liveOutdoor, result.Outdoor);
}
[Fact]
public void Static_InNonVisibleCell_IsDropped()
public void IndoorEntity_InNonVisibleCell_IsDropped()
{
var staticHidden = Ent(3, serverGuid: 0, parentCell: 0xA9B40199); // not in the visible set
var staticHidden = Ent(3, serverGuid: 0, parentCell: HiddenCell);
var liveHidden = Ent(4, serverGuid: 0x80001234, parentCell: HiddenCell);
var visible = new HashSet<uint> { CellA };
var result = InteriorEntityPartition.Partition(visible, OneLb(0xA9B4FFFF, staticHidden));
Assert.False(result.ByCell.ContainsKey(0xA9B40199));
var result = InteriorEntityPartition.Partition(
visible, OneLb(0xA9B4FFFF, staticHidden, liveHidden));
Assert.False(result.ByCell.ContainsKey(HiddenCell));
Assert.Empty(result.Outdoor);
Assert.Empty(result.LiveDynamic);
}

View file

@ -169,4 +169,175 @@ public class PortalProjectionTests
Assert.True(onScreen.Length >= 3,
"the cell behind a doorway you're standing in must stay visible (the void bug)");
}
// ---------------------------------------------------------------------------
// Faithful homogeneous (w-space) portal clip — port of retail PView::GetClip +
// PrimD3DRender::xformStart + ACRender::polyClipFinish (decomp 432344 / 424310 /
// 702749). The early divide + fixed side-plane clamp (ProjectToNdc) collapsed
// grazing/near portals to zero-area edge slivers (-> the flap) and near doorways
// to empty (-> the void/fallback). The faithful pipeline keeps homogeneous clip
// coords (ProjectToClip — eye-plane clip only, no divide) and runs Sutherland-
// Hodgman against the view region with w-aware edge tests, dividing the survivors
// only after they are bounded to the region (ClipToRegion). 2026-06-06.
// ---------------------------------------------------------------------------
private static Vector2[] FullScreenCcw() => new[]
{
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
};
[Fact]
public void ProjectToClip_QuadInFront_KeepsVertsWithPositiveW()
{
var poly = new[]
{
new Vector3(-1, -1, -5), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, -5),
};
var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj());
Assert.True(clip.Length >= 3);
foreach (var v in clip)
Assert.True(v.W > 0f, $"an in-front portal vertex must keep w>0 (homogeneous), got w={v.W}");
}
[Fact]
public void ProjectToClip_QuadFullyBehind_ReturnsEmpty()
{
var poly = new[]
{
new Vector3(-1, -1, 5), new Vector3(1, -1, 5), new Vector3(1, 1, 5), new Vector3(-1, 1, 5),
};
Assert.True(PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()).Length < 3);
}
[Fact]
public void ClipToRegion_OnScreenQuad_ReturnsBoundedNdc()
{
// A small 2x2 quad at z=-5 is fully on-screen; clipping against the full screen
// returns it bounded to [-1,1].
var poly = new[]
{
new Vector3(-1, -1, -5), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, -5),
};
var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj());
var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw());
Assert.True(ndc.Length >= 3);
foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); }
}
[Fact]
public void ClipToRegion_FullyOffScreen_ReturnsEmpty_NotSliver()
{
// The flap's root: a portal entirely off one screen edge. The old early-divide +
// side-plane clamp collapsed it to a zero-area sliver pinned to x=1.0 (proj=3) that
// propagated a degenerate region one hop and then died; the faithful clip returns
// EMPTY so the flood stops cleanly. Quad at z=-5, x in [3,5] -> ndc x ~[1.1,1.8], off right.
var poly = new[]
{
new Vector3(3, -1, -5), new Vector3(5, -1, -5), new Vector3(5, 1, -5), new Vector3(3, 1, -5),
};
var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj());
var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw());
Assert.True(ndc.Length < 3, $"fully off-screen portal must clip to empty, got {ndc.Length} verts (sliver)");
}
[Fact]
public void ClipToRegion_PartlyOffScreen_ClipsToBoundedNonEmpty()
{
// A quad straddling the right edge -> clipped to the on-screen part, bounded, non-empty.
var poly = new[]
{
new Vector3(0, -1, -5), new Vector3(4, -1, -5), new Vector3(4, 1, -5), new Vector3(0, 1, -5),
};
var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj());
var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw());
Assert.True(ndc.Length >= 3, "a partly-on-screen portal must produce a non-empty clipped region");
foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); }
}
[Fact]
public void ClipToRegion_DoorwayEyeLooksThrough_CoversScreen_WithoutFallback()
{
// The void frame: chase eye 0.28 m from a 2x2 m doorway, looking through it. The doorway
// subtends the whole screen. The faithful clip keeps the homogeneous verts (no early-divide
// blow-up) and clips to the screen quad -> covers the screen (non-empty). This is what makes
// the EyeInsidePortalOpening *fallback* unnecessary for an in-front doorway.
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1.0f, 5000f);
var viewProj = view * proj;
var doorway = new[]
{
new Vector3(-1f, -1f, -0.28f), new Vector3(1f, -1f, -0.28f),
new Vector3(1f, 1f, -0.28f), new Vector3(-1f, 1f, -0.28f),
};
var clip = PortalProjection.ProjectToClip(doorway, Matrix4x4.Identity, viewProj);
var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw());
Assert.True(ndc.Length >= 3, "a doorway the eye looks through must cover the screen, not collapse to the void");
foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); }
}
[Fact]
public void ClipToRegion_StraddlingEye_OnScreenBounded_NoBlowup()
{
// A portal spanning from behind (z=+2) to in front (z=-5). The faithful clip keeps the
// in-front part and bounds it to the screen — no perspective-inversion blow-up, non-empty.
var poly = new[]
{
new Vector3(-1, -1, 2), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, 2),
};
var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj());
var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw());
Assert.True(ndc.Length >= 3);
foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); }
}
private static float AbsArea(Vector2[] p)
{
if (p == null || p.Length < 3) return 0f;
float a2 = 0f;
for (int i = 0; i < p.Length; i++) { var u = p[i]; var w = p[(i + 1) % p.Length]; a2 += u.X * w.Y - w.X * u.Y; }
return MathF.Abs(a2) * 0.5f;
}
[Fact]
public void ClipToRegion_SubjectFullyInsideRegion_ReturnsSubjectNotRegion()
{
// Regression for Build_AppliesReciprocalOtherPortalClip: a NARROW subject fully inside a WIDE
// region must return the narrow (subject ∩ region = subject), NOT the wide region. The builder's
// reciprocal clip is exactly this shape (reciprocal opening ∩ near-side region).
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
var vp = view * proj;
var narrow = new[] { new Vector3(-0.3f, -0.9f, -3f), new Vector3(0.3f, -0.9f, -3f), new Vector3(0.3f, 0.9f, -3f), new Vector3(-0.3f, 0.9f, -3f) };
var wide = new[] { new Vector3(-0.9f, -0.9f, -3f), new Vector3(0.9f, -0.9f, -3f), new Vector3(0.9f, 0.9f, -3f), new Vector3(-0.9f, 0.9f, -3f) };
var narrowClip = PortalProjection.ProjectToClip(narrow, Matrix4x4.Identity, vp);
// Build the region exactly as the builder does (clip wide against the full screen → CCW region).
var wideRegion = PortalProjection.ClipToRegion(PortalProjection.ProjectToClip(wide, Matrix4x4.Identity, vp), FullScreenCcw());
var clipped = PortalProjection.ClipToRegion(narrowClip, wideRegion);
float narrowArea = AbsArea(PortalProjection.ClipToRegion(narrowClip, FullScreenCcw()));
float wideArea = AbsArea(wideRegion);
float clippedArea = AbsArea(clipped);
Assert.True(clippedArea <= narrowArea + 1e-3f,
$"subject∩region must be the narrow subject (area {narrowArea}), not the wide region (area {wideArea}); got {clippedArea}");
}
[Fact]
public void ClipToRegion_AgainstSubRegion_TightensToIntersection()
{
// The region clip is the propagation step: clipping a wide on-screen portal against a
// narrower view region must yield the intersection (the narrow region), not the wide portal.
var wide = new[]
{
new Vector3(-2, -2, -5), new Vector3(2, -2, -5), new Vector3(2, 2, -5), new Vector3(-2, 2, -5),
};
var clip = PortalProjection.ProjectToClip(wide, Matrix4x4.Identity, ViewProj());
var narrow = new[]
{
new Vector2(-0.3f, -0.3f), new Vector2(0.3f, -0.3f), new Vector2(0.3f, 0.3f), new Vector2(-0.3f, 0.3f),
};
var ndc = PortalProjection.ClipToRegion(clip, narrow);
Assert.True(ndc.Length >= 3);
foreach (var v in ndc) { Assert.InRange(v.X, -0.301f, 0.301f); Assert.InRange(v.Y, -0.301f, 0.301f); }
}
}

View file

@ -98,6 +98,61 @@ public class PortalVisibilityBuilderTests
"a degenerate portal the eye is NOT standing in must stay culled (no over-inclusion / #95 blowup)");
}
[Fact]
public void Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour()
{
// Live cellar capture (2026-06-06): 0174->0175 was traversable, but the portal projected to
// zero vertices while the chase camera was about 1.4 m from the opening plane. The flood must
// still reach the stair connector; otherwise the main-floor shell/floor disappears.
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
cam.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, 1.4f)); // behind eye: ProjectToNdc collapses
var stairs = Cell(0x0002);
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = stairs };
var vp = ViewProj();
Assert.True(PortalProjection.ProjectToNdc(cam.PortalPolygons[0], Matrix4x4.Identity, vp).Length < 3);
var frame = PortalVisibilityBuilder.Build(
cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp);
Assert.Contains(0x0002u, frame.OrderedVisibleCells);
}
[Fact]
public void Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit()
{
// Retail PView tracks update_count vs view_count. If B is processed through a LEFT slice, then
// a later path reaches B through a RIGHT slice, B must propagate that new RIGHT slice to its
// exit portal. Enqueue-once builders flap here: OutsideView stays empty until the camera moves
// enough to discover B in the other order.
const uint A = 0x0001, B = 0x0002, D = 0x0003;
var a = Cell(A,
new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 0),
new CellPortalInfo((ushort)D, PolygonId: 1, Flags: 0, OtherPortalId: 0));
a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // nearer LEFT path to B
a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // farther RIGHT path to D
var b = Cell(B,
new CellPortalInfo((ushort)A, PolygonId: 0, Flags: 0, OtherPortalId: 0),
new CellPortalInfo(0xFFFF, PolygonId: 1, Flags: 0, OtherPortalId: 0),
new CellPortalInfo((ushort)D, PolygonId: 2, Flags: 0, OtherPortalId: 0));
b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // reciprocal LEFT back to A
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // RIGHT exit, invisible from LEFT slice
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // reciprocal RIGHT back to D
var d = Cell(D, new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 2));
d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // later RIGHT path into B
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b, [D] = d };
var frame = Build(a, all);
Assert.Contains(B, frame.OrderedVisibleCells);
Assert.Contains(D, frame.OrderedVisibleCells);
Assert.False(frame.OutsideView.IsEmpty);
}
[Fact]
public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty()
{
@ -454,6 +509,117 @@ public class PortalVisibilityBuilderTests
"No exit portal in any reachable cell must leave OutsideView empty");
}
[Fact]
public void BuildFromExterior_SeedsInteriorCellThroughOutsidePortal()
{
var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
room.ClipPlanes.Add(new PortalClipPlane
{
Normal = new Vector3(0f, 0f, 1f),
D = 3f,
InsideSide = 1,
});
var frame = PortalVisibilityBuilder.BuildFromExterior(
new[] { room },
Vector3.Zero,
id => id == room.CellId ? room : null,
ViewProj());
Assert.Contains(room.CellId, frame.OrderedVisibleCells);
Assert.True(frame.CellViews.TryGetValue(room.CellId, out var view));
Assert.False(view!.IsEmpty);
Assert.True(view.MaxX - view.MinX < 1.0f,
"exterior seed should be clipped to the door opening, not full-screen");
}
[Fact]
public void BuildFromExterior_DoesNotSeedWhenCameraIsOnInteriorSide()
{
var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
room.ClipPlanes.Add(new PortalClipPlane
{
Normal = new Vector3(0f, 0f, 1f),
D = -1f,
InsideSide = 1,
});
var frame = PortalVisibilityBuilder.BuildFromExterior(
new[] { room },
Vector3.Zero,
id => id == room.CellId ? room : null,
ViewProj());
Assert.Empty(frame.OrderedVisibleCells);
Assert.Empty(frame.CellViews);
}
[Fact]
public void BuildFromExterior_TraversesDeeperInteriorPortals()
{
var entry = Cell(0x0001,
new CellPortalInfo(0xFFFF, 0, 0, 0),
new CellPortalInfo(0x0002, 1, 0, 0));
entry.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -3f));
entry.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, -5f));
entry.ClipPlanes.Add(new PortalClipPlane
{
Normal = new Vector3(0f, 0f, 1f),
D = 2f,
InsideSide = 1,
});
var backRoom = Cell(0x0002);
var all = new Dictionary<uint, LoadedCell>
{
[entry.CellId] = entry,
[backRoom.CellId] = backRoom,
};
var frame = PortalVisibilityBuilder.BuildFromExterior(
new[] { entry },
Vector3.Zero,
id => all.TryGetValue(id, out var c) ? c : null,
ViewProj());
Assert.Equal(new uint[] { 0x0001, 0x0002 }, frame.OrderedVisibleCells.ToArray());
Assert.True(frame.CellViews.ContainsKey(0x0002));
}
[Fact]
public void BuildFromExterior_MaxSeedDistanceSkipsDistantExitPortal()
{
var nearby = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
nearby.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
nearby.ClipPlanes.Add(new PortalClipPlane
{
Normal = new Vector3(0f, 0f, 1f),
D = 3f,
InsideSide = 1,
});
var distant = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
distant.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -200f));
distant.ClipPlanes.Add(new PortalClipPlane
{
Normal = new Vector3(0f, 0f, 1f),
D = 199f,
InsideSide = 1,
});
var frame = PortalVisibilityBuilder.BuildFromExterior(
new[] { nearby, distant },
Vector3.Zero,
id => id == nearby.CellId ? nearby : id == distant.CellId ? distant : null,
ViewProj(),
maxSeedDistance: 48f);
Assert.Contains(nearby.CellId, frame.OrderedVisibleCells);
Assert.DoesNotContain(distant.CellId, frame.OrderedVisibleCells);
}
[Fact]
public void Build_RootCellAlwaysFirstInOrderedVisibleCells()
{

View file

@ -1,20 +1,3 @@
// Tests for WbDrawDispatcher's Phase U.4 per-instance clip-slot resolution
// (ResolveEntitySlot / ResolveSlotForFrame). Code review of the U.4 commit
// (7993e06) flagged this gate-critical routing as untested: if it breaks,
// every indoor instance is sent to the wrong clip slot (or wrongly culled),
// producing total visual garbage at the portal-visibility gate. The logic is
// a pure function of (ServerGuid, ParentCellId, the clip-routing state), so we
// extract it to internal static helpers and test the branches directly — no GL
// context required.
//
// Branch map (ResolveSlotForFrame, the call-site policy):
// routing inactive (outdoor root) → slot 0, NOT culled (≡ U.3)
// ServerGuid != 0 (live dynamic) → slot 0, NOT culled (unclipped)
// ParentCellId in cellIdToSlot → that cell's slot
// ParentCellId NOT in cellIdToSlot → CULL
// ParentCellId == null, outdoorVisible → outdoorSlot
// ParentCellId == null, !outdoorVisible → CULL
using System.Collections.Generic;
using AcDream.App.Rendering.Wb;
using Xunit;
@ -23,13 +6,10 @@ namespace AcDream.App.Tests.Rendering.Wb;
public sealed class WbDrawDispatcherClipSlotTests
{
// Full cell-id space keys (lbMask | OtherCellId). 0xA9B4 is the Holtburg
// landblock prefix used throughout the indoor-walking work; the low word is
// the EnvCell index. ParentCellId on a cell static is the SAME full id — see
// the L.2e bare-low-byte finding (a 0x29 low-byte key would cull everything).
private const uint VisibleCellA = 0xA9B4_0164u;
private const uint VisibleCellB = 0xA9B4_0165u;
private const uint NotVisibleCell = 0xA9B4_0999u;
private const uint OutdoorCell = 0xA9B4_0020u;
private const int SlotA = 3;
private const int SlotB = 7;
@ -41,30 +21,44 @@ public sealed class WbDrawDispatcherClipSlotTests
[VisibleCellB] = SlotB,
};
// ── Raw resolver (ResolveEntitySlot): only reached when routing is active ──
[Fact]
public void RawResolve_LiveEntity_IsUnclippedSlot0_WhenParentCellNull()
public void RawResolve_LiveEntity_WithVisibleIndoorParent_GetsThatCellSlot()
{
// ServerGuid != 0 ⇒ unclipped (slot 0) regardless of cell state.
int slot = WbDrawDispatcher.ResolveEntitySlot(
serverGuid: 0x5000_000Au, parentCellId: null,
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
Assert.Equal(0, slot);
}
[Fact]
public void RawResolve_LiveEntity_IsUnclippedSlot0_EvenWhenParentCellVisible()
{
// A live entity whose ParentCellId IS a visible cell still goes to slot 0,
// NOT SlotA — the live-dynamic check must precede the cell lookup.
int slot = WbDrawDispatcher.ResolveEntitySlot(
serverGuid: 0x5000_000Au, parentCellId: VisibleCellA,
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
Assert.Equal(0, slot);
Assert.NotEqual(SlotA, slot); // guards against ordering regression
Assert.Equal(SlotA, slot);
}
[Fact]
public void RawResolve_LiveEntity_WithHiddenIndoorParent_IsCulled()
{
int slot = WbDrawDispatcher.ResolveEntitySlot(
serverGuid: 0x5000_000Au, parentCellId: NotVisibleCell,
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot);
}
[Fact]
public void RawResolve_LiveEntity_WithOutdoorParent_UsesOutsideViewWhenVisible()
{
int slot = WbDrawDispatcher.ResolveEntitySlot(
serverGuid: 0x5000_000Au, parentCellId: OutdoorCell,
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
Assert.Equal(OutsideViewSlot, slot);
}
[Fact]
public void RawResolve_LiveEntity_WithParentNull_IsCulledWhenRoutingActive()
{
int slot = WbDrawDispatcher.ResolveEntitySlot(
serverGuid: 0x5000_000Au, parentCellId: null,
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot);
}
[Fact]
@ -107,19 +101,9 @@ public sealed class WbDrawDispatcherClipSlotTests
Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot);
}
// ── Call-site policy (ResolveSlotForFrame): adds the clipRoutingActive gate ──
// Cases mirror the raw resolver but return the (slot, culled) pair the loop
// body consumes, and add the routing-inactive (outdoor-root) branch.
[Fact]
public void ForFrame_RoutingInactive_EveryEntityIsSlot0AndNotCulled()
{
// The bit-identical-to-U.3 property: when the camera is at an outdoor root
// (ClearClipRouting), ResolveEntitySlot is never consulted — every entity
// maps to slot 0 and nothing is clip-culled. Exercised here for BOTH a
// live entity and a cell static that would otherwise cull, with a null
// routing map to prove the resolver is bypassed entirely.
var live = WbDrawDispatcher.ResolveSlotForFrame(
clipRoutingActive: false, serverGuid: 0x5000_000Au, parentCellId: null,
cellIdToSlot: null, outdoorSlot: OutsideViewSlot, outdoorVisible: true);
@ -134,16 +118,27 @@ public sealed class WbDrawDispatcherClipSlotTests
}
[Fact]
public void ForFrame_RoutingActive_LiveEntity_Slot0NotCulled()
public void ForFrame_RoutingActive_LiveEntityVisible_GetsCellSlotNotCulled()
{
var r = WbDrawDispatcher.ResolveSlotForFrame(
clipRoutingActive: true, serverGuid: 0x5000_000Au, parentCellId: VisibleCellA,
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
Assert.Equal(0u, r.Slot);
Assert.Equal((uint)SlotA, r.Slot);
Assert.False(r.Culled);
}
[Fact]
public void ForFrame_RoutingActive_LiveEntityParentNull_Culled()
{
var r = WbDrawDispatcher.ResolveSlotForFrame(
clipRoutingActive: true, serverGuid: 0x5000_000Au, parentCellId: null,
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
Assert.True(r.Culled);
Assert.Equal(0u, r.Slot);
}
[Fact]
public void ForFrame_RoutingActive_CellStaticVisible_GetsCellSlotNotCulled()
{
@ -163,7 +158,6 @@ public sealed class WbDrawDispatcherClipSlotTests
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
Assert.True(r.Culled);
// When culled the loop body forces slot 0 (the value is never emitted).
Assert.Equal(0u, r.Slot);
}

View file

@ -96,40 +96,40 @@ public sealed class RenderingDiagnosticsTests
// PLAYER's cell, 0x451e80), NOT the camera cell. acdream previously branched on the
// camera cell, so a chase camera lagging in a doorway while the player was already
// outside took the DrawInside path and degenerated to a grey world + entities showing
// through walls. These pin the player-keyed branch (DrawInside root stays the viewer cell).
// through walls. These pin the player-keyed branch and loaded player-root requirement.
[Fact]
public void ShouldRenderIndoor_PlayerOutside_CameraInside_ReturnsFalse()
{
// THE doorway-grey regression: the player stepped onto a landcell (0x...0031) but the
// chase camera still resolves an interior EnvCell. Branch on the PLAYER → outdoor.
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: true));
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: true));
}
[Fact]
public void ShouldRenderIndoor_PlayerInside_CameraInside_ReturnsTrue()
{
Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: true));
Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: true));
}
[Fact]
public void ShouldRenderIndoor_PlayerInside_NoViewerCell_ReturnsFalse()
public void ShouldRenderIndoor_PlayerInside_RootNotLoaded_ReturnsFalse()
{
// Opposite lag (camera pulled outside while the player is inside): no viewer cell to
// root DrawInside at → outdoor. Defensive; matches prior null-CameraCell behavior.
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: false));
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: false));
}
[Fact]
public void ShouldRenderIndoor_PlayerOutside_CameraOutside_ReturnsFalse()
{
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: false));
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: false));
}
[Fact]
public void ShouldRenderIndoor_UnknownPlayerCell_TreatedAsOutside_ReturnsFalse()
{
// playerCellId == 0 (unresolved) → treat as outside (safe default: outdoor render).
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, viewerCellResolved: true));
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, renderRootResolved: true));
}
}

View file

@ -21,11 +21,19 @@ string outDir = Path.Combine(AppContext.BaseDirectory, "out");
Directory.CreateDirectory(outDir);
Console.WriteLine($"outDir = {outDir}");
// Original ask: 0x050016A4..0x050016A8. A4/A5/A7/A8 don't exist as files; widen
// to 0x050016A0..0x050016AF to catch any related precip textures.
var idList = new System.Collections.Generic.List<uint>();
for (uint i = 0x050016A0; i <= 0x050016AF; i++) idList.Add(i);
uint[] ids = idList.ToArray();
uint[] ids;
if (args.Length > 0)
{
ids = args.Select(ParseId).ToArray();
}
else
{
// Original ask: 0x050016A4..0x050016A8. A4/A5/A7/A8 don't exist as files; widen
// to 0x050016A0..0x050016AF to catch any related precip textures.
var idList = new System.Collections.Generic.List<uint>();
for (uint i = 0x050016A0; i <= 0x050016AF; i++) idList.Add(i);
ids = idList.ToArray();
}
(uint id, double densityFraction)? best = null;
@ -35,15 +43,25 @@ foreach (var id in ids)
Console.WriteLine($"=== 0x{id:X8} ===");
RenderSurface? rs = null;
uint lookupId = id;
if (dats.TryGet<Surface>(id, out var surface) && surface is not null)
{
lookupId = (uint)surface.OrigTextureId;
var color = surface.ColorValue is null
? "null"
: $"0x{surface.ColorValue.Alpha:X2}{surface.ColorValue.Red:X2}{surface.ColorValue.Green:X2}{surface.ColorValue.Blue:X2}";
Console.WriteLine($" Surface descriptor, type={surface.Type}, color={color}, origTexture=0x{lookupId:X8}");
}
// SurfaceTexture wrapper (0x05xxxxxx) → first inner RenderSurface (0x06xxxxxx).
if (dats.TryGet<SurfaceTexture>(id, out var st) && st is not null && st.Textures.Count > 0)
if (dats.TryGet<SurfaceTexture>(lookupId, out var st) && st is not null && st.Textures.Count > 0)
{
uint rsid = (uint)st.Textures[0];
Console.WriteLine($" SurfaceTexture wrapper, {st.Textures.Count} mip(s), first = 0x{rsid:X8}");
if (dats.TryGet<RenderSurface>(rsid, out var inner) && inner is not null)
rs = inner;
}
else if (dats.TryGet<RenderSurface>(id, out var direct) && direct is not null)
else if (dats.TryGet<RenderSurface>(lookupId, out var direct) && direct is not null)
{
rs = direct;
}
@ -226,3 +244,11 @@ static uint Adler32(byte[] data)
}
return (b << 16) | a;
}
static uint ParseId(string text)
{
text = text.Trim();
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return Convert.ToUInt32(text[2..], 16);
return Convert.ToUInt32(text, 16);
}