refactor(render): R-A1 — canonicalize outdoor-root detection on IsOutdoorNode

Replace ReferenceEquals(clipRoot, _outdoorNode) object-identity checks with the
documented LoadedCell.IsOutdoorNode flag (4 sites) so they survive R-A2 changing
the outdoor root's portals. Behavior-preserving (build + targeted suites green:
App PortalVisibilityBuilderTests 24/24, Core PlayerMovementControllerTests 14/14).

Right-sized from the planned 'collapse to one root': reading the live dispatch,
the viewerRoot ?? outdoorRoot split is already correct (viewerRoot feeds
cameraInsideCell/lighting via the older CellVisibility BFS; clipRoot is the render
root), and the 2026-06-07 cutover flip already made in-world frames single-path
DrawInside. The real flap fix is R-A2 (per-building floods). Dead exterior
DrawPortal look-in deletion deferred to R-A3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-08 18:25:58 +02:00
parent 6996e5645c
commit 7fe98098f5
2 changed files with 375 additions and 4 deletions

View file

@ -0,0 +1,371 @@
# Full Retail Render Port (Option A) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace acdream's synthetic-outdoor-node + unified-flood render orchestration with retail's one structural path — root the render at the *real* cell the camera eye occupies, run ONE `DrawInside`, and render building interiors as many small per-building floods (robust to the eye's ~36 µm rest jitter) — so the indoor doorway "flap" dies by construction, not by tuning.
**Architecture:** Retail (measured + decompiled) renders every in-world frame through a single `DrawInside(viewer_cell)`. `viewer_cell` is whatever cell the camera-collision sweep resolves — an outdoor `CLandCell` or an indoor `CEnvCell`; there is **no inside/outside branch**. The flood from that cell fills `outside_view` (full-screen for an outdoor root; the door-shaped region for an indoor root looking out); `outside_view > 0` is the single switch that draws terrain+sky+buildings via `LScape::draw`. Building **interiors** are flooded **separately and per-building** during the landscape draw (terrain BSP → `DrawPortal``ConstructView(CBldPortal)`), each touching ≈2 cells — that per-building granularity is what makes retail robust to a jittering eye. acdream's job is to reproduce this: one root, one path, per-building floods.
**Tech Stack:** C# / .NET 10, Silk.NET GL 4.3 (bindless + MDI). GL-free pure-logic flood (`PortalVisibilityBuilder`) unit-tested without a GPU. xUnit. Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` (Sept 2013 EoR PDB).
---
## A. The Oracle (measured live + decompiled — DO NOT RE-DERIVE)
This is the expensive, settled ground truth (handoff `docs/research/2026-06-08-full-retail-render-port-OPTION-A-handoff.md` §3, plus this session's trace closures). Cite it; never re-guess it.
### A.1 Retail render architecture: ONE path
- `SmartBox::RenderNormalMode` (`0x453aa0`, decomp:92635) **always** calls `RenderDevice::DrawInside(viewer_cell)`. The "outside" branch (`LScape::draw` directly) is dead code — the BN predicate `edi_2 = -(edi - edi)` is a compile-time `0`. `is_player_outside` (`0x451e80`) only gates sky/lighting, never the render path.
- "Entering a building" is NOT a render event — only the camera sweep resolving a different `viewer_cell` (outdoor `CLandCell` → indoor `CEnvCell`). Same code path before/after the threshold → no seam → no flap.
### A.2 The flood (trace #2 — read verbatim this session)
- `PView::ConstructView(CObjCell*, 0xffff)` (`0x5a57b0`, decomp:433750): reset `outside_view`; LIFO worklist; seed root; pop cell → append to `cell_draw_list` (membership) → `ClipPortals(cell, 0)` → if nonzero, `AddViewToPortals(cell)`.
- `PView::ClipPortals` (`0x5a5520`, decomp:433572): for each portal of the cell, if it survives the near clip: a portal leading **outside** (`other_cell_id == 0xffffffff`) copies its clipped region into the PView's **`outside_view`** (gated on `draw_landscape`, decomp:433664-433684); a portal to another cell does the reciprocal `OtherPortalClip` + `copy_view` into the neighbour's view slice.
- `PView::AddViewToPortals` (`0x5a52d0`, decomp:433446): first visit (`ecx_5==0`) → `InitCell`+`InsCellTodoList` (enqueue); already-visited but view **grew** (`ecx_5!=eax_2`) → `AddToCell`/`FixCellList` re-process **in place**. Retail DOES re-process grown cells; it does NOT re-enqueue them. (This is why acdream's `Build_ViewGrowthAfterDoneCell_*` tests are correct and must stay green.)
### A.3 Indoor vs outdoor differ ONLY in the root (trace #3 — resolved this session)
- The fields `num_stabs`, `stab_list`, `seen_outside`, `num_view`, `portal_view`, `num_portals`, `portals`, `pos` are on the **`CObjCell` base** (`acclient.h:30925-30931`, the struct carrying `myLandBlock_`). So `DrawInside`/`ConstructView`/`ClipPortals` operate on BOTH `CLandCell` and `CEnvCell` — the BN `CEnvCell*` typing is heuristic; the real param is `CObjCell*`.
- `PView::DrawCells` (`0x5a4840`, decomp:432709): `if (outside_view.view_count > 0) { LScape::draw(lscape); <depth Clear>; <draw flooded env-cell interior surfaces> }` then a second lit pass over `cell_draw_list`. **`outside_view > 0` is the single terrain switch.**
- **Outdoor root** (`CLandCell`): the flood trivially "sees outside" → `outside_view` full → `LScape::draw` renders terrain+sky+**all buildings**. Buildings are flooded **separately**, per-building, by the terrain BSP walk: `BSPPORTAL::portal_draw_portals_only` (`0x53d870`, decomp:326881) → `DrawPortal` (`0x5a5ab0`, decomp:433895) → `ConstructView(CBldPortal*, …)` (`0x5a59a0`, decomp:433827). The land-cell root flood does **not** flood into buildings.
- **Indoor root** (`CEnvCell`): `outside_view` starts empty; the flood walks the building's cells; an exit portal (`0xffffffff`) adds a door-shaped region to `outside_view`, pulling in terrain-through-the-door.
### A.4 LIVE MEASUREMENTS (cdb on retail at the Holtburg doorway, handoff §3.4)
- Membership at rest is **stable**: `PView.cell_draw_num` settled to a long unbroken run of **2**; `viewer_cell` pointer = 1 distinct value.
- Retail does **per-building** floods: `ConstructView(CBldPortal*)` fired **~7×/frame**, each `cell_draw_num ≈ 2`. NOT one unified flood.
- Retail's **eye jitters ~36 µm at rest** (X≈15 µm, Y≈36 µm, Z≈8 µm; `pub == sought`, uncollided). Retail's eye is NOT byte-stable; its **membership** is stable anyway → robustness is structural, not a stable eye.
### A.5 Camera boom (trace #1 — decomp, secondary/R-A4)
- `viewer_sought_position` (`SmartBox+0x58`) is written per physics tick in `SmartBox::PlayerPhysicsUpdatedCallback` (`0x452d60`) from `CameraManager::UpdateCamera` (`0x456660`).
- `UpdateCamera` is **first-order exponential smoothing**: `alpha = clamp(stiffness · dt · 10, 0, 1)`; default `t_stiffness = r_stiffness = 0.45` → ~7.5%/frame at 60 Hz (~93 ms time constant). `viewer_offset.y = -3` (3 m behind pivot).
- The convergence early-exit (distance < 0.0004, rotation < 0.0002) **requires `r_stiffness ≥ 0.9998`**, which the 0.45 default never meets retail's boom chases forever the 36 µm rest jitter is structural. **Byte-stable eye is the wrong target.**
- `update_viewer`'s `viewer_sphere.radius = 0.3` (matches our `PhysicsCameraCollisionProbe.ViewerSphereRadius`).
---
## B. Refinement of handoff §6 (what reading the current code changed)
The handoff was written against the *pre-flip* mental model (a live inside/outside **branch toggle**). Reading the actual code (HEAD `9b1857a`, post the 2026-06-07 cutover flip) shows the flip **already** moved every in-world frame onto `DrawInside`:
- `clipRoot = viewerRoot ?? _outdoorNode` (`GameWindow.cs:7396`). When in-world, `viewerCellId != 0` → either `viewerRoot` (indoor cell, registered) or `_outdoorNode` (built when `viewerRoot is null && viewerCellId != 0`, `GameWindow.cs:7357-7381`) is non-null → `clipRoot` is non-null → `DrawInside` (`GameWindow.cs:7498`).
- Terrain draws outdoors via DrawInside's full-screen `OutsideView` slice (the `IsOutdoorNode` seed at `PortalVisibilityBuilder.cs:88-89``DrawLandscapeThroughOutsideView`), NOT via the `if (clipRoot is null)` outdoor block (`GameWindow.cs:7445-7486`, which now runs only at `viewerCellId == 0` = pre-spawn/login).
- The `else { … DrawPortal/BuildFromExterior … }` branch (`GameWindow.cs:7613-7719`) is effectively dead in normal play (it requires `clipRoot is null`, i.e. `viewerCellId == 0`, where there are no candidate cells, so it falls to `_wbDrawDispatcher.Draw`).
**Therefore the residual divergence is NOT a branch toggle.** It is:
- **D2/D3 (the live flap source):** the outdoor root is the **synthetic `_outdoorNode`**, which carries **reverse portals into every nearby building** and floods them all in **ONE unified flood** gated by a **root-level portal-side knife-edge** (`CameraOnInteriorSide`). As the chase eye grazes a doorway, that knife-edge flips → the building cell set oscillates (the measured `flood 2↔6` / `1↔13`). Retail reaches buildings **spatially** (terrain BSP), per-building, with no root-level knife-edge — hence stable.
- **D1 (leftover):** the `if (clipRoot is null) … else …` structure and the `ReferenceEquals(clipRoot, _outdoorNode)` conditionals (`GameWindow.cs:7539, 7571, 7603`) still encode an inside/outside distinction by node identity.
- **D4/D5/D6 (band-aids):** `MaxReprocessPerCell` cap (`PortalVisibilityBuilder.cs:51`), `EyeInsidePortalOpening` (`:202/:243/:826`), reciprocal-on-`ProjectToNdc` (`:758`).
The phase mapping (below) reflects this: **R-A1** unifies the root and deletes the dead branch (behavior-preserving — *no flap fix yet*); **R-A2** replaces the unified knife-edge flood with per-building floods (*the flap fix*); **R-A3** removes the now-dead band-aids; **R-A4** (optional) tightens the camera/interp.
---
## C. Divergence → phase map
| # | Divergence | Where | Phase |
|---|---|---|---|
| D1 | Inside/outside structure + `ReferenceEquals(_outdoorNode)` conditionals | `GameWindow.cs:7396,7445,7539,7571,7603,7613-7719` | R-A1 |
| D2 | Synthetic `_outdoorNode` root (reverse-portals into buildings) | `GameWindow.cs:7357-7381`, `OutdoorCellNode.cs` | R-A1 (root unify) + R-A2 (drop reverse-portal building flood) |
| D3 | ONE unified flood gated by a root-level portal-side knife-edge | `PortalVisibilityBuilder.Build` from one root | R-A2 |
| D4 | `MaxReprocessPerCell = 16` cap | `PortalVisibilityBuilder.cs:51,331,509` | R-A3 |
| D5 | `EyeInsidePortalOpening` degenerate-portal hack | `PortalVisibilityBuilder.cs:202,243,826` | R-A3 |
| D6 | Reciprocal clip on `ProjectToNdc` not `ProjectToClip` | `PortalVisibilityBuilder.cs:758` | R-A3 |
| D7 | Render-position interpolation layer | `PlayerMovementController.ComputeRenderPosition` | R-A4 (reconsider; do NOT rip blindly) |
| D8 | Camera boom ~36× looser than retail | `RetailChaseCamera.cs` | R-A4 (tune toward stiffness 0.45) |
`ProjectToClip`/`ClipToRegion` (`PortalProjection.cs`) and `CellVisibility` side-test are **faithful** — KEEP. The clip math is never the problem; what feeds it (root + flood structure) is.
---
## D. File structure
**Modified:**
- `src/AcDream.App/Rendering/GameWindow.cs` — the render dispatch (~7185-7729). R-A1 unifies the root + deletes the dead branch; R-A2 adds the per-building flood call into the landscape draw path.
- `src/AcDream.App/Rendering/RetailPViewRenderer.cs``DrawInside`; R-A2 issues per-building floods during/after `DrawLandscapeThroughOutsideView`.
- `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` — R-A2 adds a per-building entry point (or formalizes `BuildFromExterior` as per-building); R-A3 removes D4/D5/D6.
- `src/AcDream.App/Rendering/OutdoorCellNode.cs` — R-A1 repurposes (land root, no reverse-portals after R-A2) or is deleted in R-A2.
- `src/AcDream.App/Rendering/CellVisibility.cs``LoadedCell` already has `IsOutdoorNode`/`SeenOutside`/`BuildingId`; no schema change expected.
- `src/AcDream.App/Rendering/RetailChaseCamera.cs` — R-A4 only.
**Created (tests):**
- `tests/AcDream.App.Tests/Rendering/PortalVisibilityRobustnessTests.cs` — the flap PRE-gate: membership stable under ~36 µm eye jitter; per-building flood ≈2 cells.
**Reference-only (oracle):** `docs/research/named-retail/acclient_2013_pseudo_c.txt`, `acclient.h`.
**Apparatus (throwaway — strip after the visual gate):** `ACDREAM_PROBE_PVINPUT` (`[pv-input]`), `ACDREAM_PROBE_PORTAL_CHURN`, `ACDREAM_PROBE_FLAP`, `tools/cdb/flap-*.cdb`.
---
## Task R-A1: Canonicalize outdoor-root detection on the `IsOutdoorNode` flag (behavior-preserving prep)
**Scope correction (found during execution — supersedes handoff §6's "collapse to one root"):** Reading the live dispatch, the `clipRoot = viewerRoot ?? outdoorRoot` structure is **already correct and must NOT be collapsed.** `viewerRoot` deliberately stays null outdoors because it feeds `cameraInsideCell` + lighting via the older `CellVisibility` BFS (`GameWindow.cs:7212`, `:7219`, `:7236`); `clipRoot` is the render root. Forcibly unifying them is a risky lighting/sky-gating refactor unrelated to the flap. Separately, the 2026-06-07 cutover flip already routed every in-world frame through ONE `DrawInside` — the `else` branch runs only at `viewerCellId == 0` (pre-spawn/login), not an inside/outside toggle. So R-A1 reduces to its genuinely useful, zero-risk core: replace the 4 `ReferenceEquals(clipRoot, _outdoorNode)` object-identity checks with the documented `LoadedCell.IsOutdoorNode` flag, so they survive R-A2 changing the node's portals. Dead-code deletion (the exterior `DrawPortal` look-in, `:7635-7711`) moves to **R-A3** (definitively dead only after R-A2). The deeper viewerRoot/clipRoot unification is a separate, optional faithfulness cleanup — out of scope for the flap fix.
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs``:7539` (ClearDepthSlice — FUNCTIONAL), `:7603` (LiveDynamic guard — FUNCTIONAL), `:7571` (`[pv-input]` probe), `:9219` (`[render-sig]` probe)
- Test: existing `PortalVisibilityBuilderTests` (24/24) + `PlayerMovementControllerTests` (14/14) — must stay green (behavior-preserving)
- [x] **Step 1: Swap the 4 outdoor-root checks** from `ReferenceEquals(clipRoot, _outdoorNode)` to `clipRoot.IsOutdoorNode` (the 3 sites inside `if (clipRoot is not null)`) / `clipRoot is { IsOutdoorNode: true }` (the null-reachable `:9219` probe). Equivalent for every functional path: `OutdoorCellNode.Build` is the only `IsOutdoorNode` setter, and registered `viewerRoot` cells are always indoor EnvCells. (Only difference: the pre-spawn `[render-sig]` `outRoot=` char flips `Y→n` when both are null — throwaway apparatus, irrelevant.)
- [ ] **Step 2: Build green.** `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug`
- [ ] **Step 3: Targeted suites green.** App `PortalVisibilityBuilderTests` 24/24; Core `PlayerMovementControllerTests` 14/14. No separate visual gate — behavior-preserving; the R-A2 doorway gate covers it.
- [ ] **Step 4: Commit** (code + this plan-doc scope correction together).
```bash
git add src/AcDream.App/Rendering/GameWindow.cs docs/superpowers/plans/2026-06-08-full-retail-render-port-option-a.md
git commit -m "refactor(render): R-A1 — canonicalize outdoor-root detection on IsOutdoorNode
Replace ReferenceEquals(clipRoot, _outdoorNode) object-identity checks with the
documented LoadedCell.IsOutdoorNode flag (4 sites) so they survive R-A2 changing
the outdoor root's portals. Behavior-preserving. Right-sized from the planned
'collapse to one root': the viewerRoot ?? outdoorRoot split is already correct
(viewerRoot feeds cameraInsideCell/lighting), and the cutover flip already made
in-world frames single-path DrawInside. Dead-code deletion deferred to R-A3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task R-A2: Per-building floods — the flap fix (remove D3, finish D2)
**Intent:** Replace the single unified flood from the outdoor land root (which reaches buildings through reverse portals gated by a root-level portal-side knife-edge → the oscillation) with retail's **per-building** floods: for each building near the camera, run a small `ConstructView` seeded at that building's entrance portal, touching ≈2 cells. The land-cell root then floods **nothing** into buildings — it is a pure terrain root (full-screen `OutsideView`). This makes building membership robust to the eye's ~36 µm jitter → the flap dies.
**Retail oracle:** `BSPPORTAL::portal_draw_portals_only` (`0x53d870`, decomp:326881) → `DrawPortal` (`0x5a5ab0`, decomp:433895) → `ConstructView(CBldPortal*, …)` (`0x5a59a0`, decomp:433827): viewpoint side-test vs the building portal plane (0.0002 epsilon), `GetClip`, `CEnvCell::GetVisible(other_cell_id)`, `copy_view`, recurse into the building's cells. acdream's `BuildFromExterior` (`PortalVisibilityBuilder.cs:373`) already implements this shape (seed from an exit portal, flood inward); R-A2 calls it **per building** instead of once over all candidates, and removes the root-level building reverse-portals.
**Files:**
- Modify: `src/AcDream.App/Rendering/OutdoorCellNode.cs` — stop adding reverse building portals (the land root keeps only `IsOutdoorNode/SeenOutside`; its flood touches just itself → full-screen `OutsideView`).
- Modify: `src/AcDream.App/Rendering/RetailPViewRenderer.cs` — in `DrawInside`, when `RootCell.IsOutdoorNode`, after the landscape slice, run one per-building flood per nearby building and draw each building's interior (shells + objects) clipped to that building's entrance-portal region.
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — pass the nearby-building set (grouped by `LoadedCell.BuildingId`) into the `RetailPViewDrawContext`.
- Create: `tests/AcDream.App.Tests/Rendering/PortalVisibilityRobustnessTests.cs`
- [ ] **Step 1: Write the failing robustness conformance test (the flap PRE-gate).** Encodes A.4: a building's per-building flood membership is identical under a ~36 µm eye perturbation at a grazing entrance, and touches ≈2 cells. Uses the existing fixture helpers' idiom.
```csharp
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class PortalVisibilityRobustnessTests
{
private static Matrix4x4 ViewProj()
{
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
return view * proj;
}
private static Vector3[] Quad(float cx, float cy, float halfW, float halfH, float z) => new[]
{
new Vector3(cx - halfW, cy - halfH, z), new Vector3(cx + halfW, cy - halfH, z),
new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z),
};
private static LoadedCell Cell(uint id, params CellPortalInfo[] portals) => new LoadedCell
{
CellId = id, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
Portals = new List<CellPortalInfo>(portals),
};
// A two-cell building: vestibule 0x0170 (entrance to outside + interior portal to room)
// and room 0x0171 (sealed). The entrance opening is small (a doorway), modelling the
// grazing-doorway scenario where the eye sits ~at the entrance plane.
private static (LoadedCell entrance, Dictionary<uint, LoadedCell> lookup) TwoCellBuilding()
{
const uint VEST = 0x0170, ROOM = 0x0171;
var vest = Cell(VEST,
new CellPortalInfo(0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0), // entrance to outside
new CellPortalInfo((ushort)ROOM, PolygonId: 1, Flags: 0, OtherPortalId: 0));
vest.PortalPolygons.Add(Quad(0f, 0f, 0.4f, 0.8f, -2f)); // doorway opening
vest.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.8f, -4f)); // vestibule->room
vest.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = 1.9f, InsideSide = 1 });
var room = Cell(ROOM, new CellPortalInfo((ushort)VEST, 0, 0, 1));
room.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.8f, -4f));
var all = new Dictionary<uint, LoadedCell> { [VEST] = vest, [ROOM] = room };
return (vest, all);
}
[Fact]
public void PerBuildingFlood_MembershipStableUnderMicrometreEyeJitter()
{
// Conformance to handoff §3.4: retail's per-building membership is stable while the eye
// jitters ~36 µm at rest. The per-building flood, seeded at the entrance, must return the
// SAME OrderedVisibleCells for an eye and an eye+36µm — no flap.
var (entrance, lookup) = TwoCellBuilding();
var vp = ViewProj();
var eye = new Vector3(0f, 0f, 0.5f); // just outside the entrance plane (z=1.9 inside)
var a = PortalVisibilityBuilder.ConstructViewBuilding(
entrance, eye, id => lookup.TryGetValue(id, out var c) ? c : null, vp);
var b = PortalVisibilityBuilder.ConstructViewBuilding(
entrance, eye + new Vector3(15e-6f, 36e-6f, 8e-6f),
id => lookup.TryGetValue(id, out var c) ? c : null, vp);
Assert.Equal(a.OrderedVisibleCells, b.OrderedVisibleCells); // robust to the 36 µm jitter — no flap
}
[Fact]
public void PerBuildingFlood_TouchesAboutTwoCells()
{
// Conformance to handoff §3.4: each retail per-building flood has cell_draw_num ≈ 2.
var (entrance, lookup) = TwoCellBuilding();
var vp = ViewProj();
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
entrance, new Vector3(0f, 0f, 0.5f),
id => lookup.TryGetValue(id, out var c) ? c : null, vp);
Assert.InRange(frame.OrderedVisibleCells.Count, 1, 3); // ≈2 (the 2-cell building)
}
}
```
- [ ] **Step 2: Run the test to verify it fails.**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~PerBuildingFlood" --nologo`
Expected: FAIL — `PortalVisibilityBuilder.ConstructViewBuilding` does not exist.
- [ ] **Step 3: Implement `ConstructViewBuilding`.** Add a per-building entry point to `PortalVisibilityBuilder` that seeds the flood from a single building's entrance portal(s) and floods only that building's cells. This is `BuildFromExterior` scoped to ONE building's entrance: reuse its body but seed from the supplied entrance cell's exit portal(s), and constrain the flood to the building (use `LoadedCell.BuildingId` so the flood never leaves the building — exactly retail's `CBldPortal` channel staying inside `bp->other_cell_id`). Faithful to `ConstructView(CBldPortal)` (decomp:433827): the seed is the entrance opening's near-clip region; recursion stays in-building.
```csharp
/// <summary>
/// Retail per-building flood: ConstructView(CBldPortal*, …) (decomp:433827) reached from the
/// terrain BSP at DrawPortal (decomp:433895). Seeds at <paramref name="entrance"/>'s exit
/// portal(s) (the building's CBldPortal opening) and floods ONLY this building's cells (bounded by
/// BuildingId), producing the small ≈2-cell view retail draws per visible building. Robust to eye
/// jitter because the seed is the finite entrance opening's projection, not a root-level
/// portal-side knife-edge over the whole building set.
/// </summary>
public static PortalVisibilityFrame ConstructViewBuilding(
LoadedCell entrance,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj)
{
uint? building = entrance.BuildingId;
// BuildFromExterior already seeds from a cell's exit portal and floods inward. Constrain it to
// this building: a neighbour outside `building` is not traversed (retail's CBldPortal flood
// never leaves bp->other_cell_id's building). Implemented by passing a building-membership
// predicate down into the shared flood body (extract the BuildFromExterior loop to accept one).
return BuildFromExterior(
new[] { entrance }, cameraPos, lookup, viewProj,
maxSeedDistance: float.PositiveInfinity,
buildingMembership: building is null ? null : id => lookup(id)?.BuildingId == building);
}
```
(If `BuildFromExterior` lacks a `buildingMembership` param, add it mirroring `Build`'s existing `buildingMembership` escape hatch at `PortalVisibilityBuilder.cs:62-68,273-279`.)
- [ ] **Step 4: Run the test to verify it passes.**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~PerBuildingFlood" --nologo`
Expected: PASS (both facts).
- [ ] **Step 5: Stop the outdoor land root from flooding buildings.** In `OutdoorCellNode.Build`, remove the reverse-portal loop (`OutdoorCellNode.cs:28-60`) — the land root now carries only `CellId/IsOutdoorNode/SeenOutside/identity transforms` and NO portals, so `PortalVisibilityBuilder.Build` from it floods just itself → full-screen `OutsideView` (the `IsOutdoorNode` seed at `:88-89`). Rename the param `nearbyBuildingCells` away or drop it (the buildings are now flooded per-building in Step 6, not from this root).
- [ ] **Step 6: Issue per-building floods during the landscape draw.** In `RetailPViewRenderer.DrawInside`, when `ctx.RootCell.IsOutdoorNode`, after `DrawLandscapeThroughOutsideView` (where retail's `LScape::draw` walks the terrain BSP), iterate the nearby buildings (grouped by `BuildingId` from `ctx`'s candidate cells), call `ConstructViewBuilding` per building, assemble each into the clip frame, and draw that building's shells + cell object lists clipped to its region — reusing the existing `DrawEnvCellShells` / `DrawCellObjectLists` paths per building. Pass the nearby-building set from `GameWindow` (the same Chebyshev≤1 gather the old `_outdoorNode` used, now grouped by `BuildingId`) via a new `RetailPViewDrawContext.NearbyBuildingEntrances` field.
- [ ] **Step 7: Build + full App suite green.**
Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug`
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --nologo`
Expected: build green; all App tests pass (24 existing + 2 new robustness).
- [ ] **Step 8: Visual gate (THE flap acceptance test).** Launch; walk slowly up to and through the Holtburg cottage doorway, and stand at the grazing angle that flapped at baseline. Expected: **no flap** — the doorway, terrain-through-the-door, and the cellar/interior render stably as the eye micro-jitters; building interiors are visible through the door from outside without oscillation. Capture `[pv-input]` (light: `launch-flap-verify.ps1`) and confirm `flood` no longer oscillates while standing still. If the flap persists, do NOT add hysteresis — capture and compare per-building `cell_draw_num` against the measured ≈2 (re-attach cdb per handoff §9 if needed).
- [ ] **Step 9: Commit.**
```bash
git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs src/AcDream.App/Rendering/RetailPViewRenderer.cs src/AcDream.App/Rendering/OutdoorCellNode.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/Rendering/PortalVisibilityRobustnessTests.cs
git commit -m "feat(render): R-A2 — per-building floods (the flap fix)
Outdoor land root no longer floods buildings through reverse portals (the
root-level portal-side knife-edge that oscillated as the chase eye grazed a
doorway). Buildings now flood per-building, seeded at each entrance (retail
ConstructView(CBldPortal) 0x5a59a0 via DrawPortal 0x5a5ab0), ≈2 cells each —
robust to the eye's ~36µm rest jitter (measured retail, handoff §3.4).
Conformance: PerBuildingFlood_MembershipStableUnderMicrometreEyeJitter +
PerBuildingFlood_TouchesAboutTwoCells.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task R-A3: Remove the band-aids made dead by R-A1/R-A2 (D4, D5, D6)
**Intent:** Per-building bounded floods (≈2 cells) make the unified-flood termination hacks unnecessary. Remove each **deliberately**, re-running the full conformance suite after each removal. Do NOT touch `ProjectToClip`/`ClipToRegion` (faithful).
**Files:** Modify `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`. Test: full `tests/AcDream.App.Tests/`.
- [ ] **Step 1: Remove the `MaxReprocessPerCell` cap (D4).** Delete the const (`:51`) and the `popCounts.GetValueOrDefault(...) < MaxReprocessPerCell` clause from both re-enqueue gates (`:331`, `:509`), keeping the `queued.Add(...)` enqueue-once guard. Run the full App suite — the cyclic/hub/diamond termination tests (`Builder_CyclicGraph_TerminatesWithBoundedPolys`, `Build_CyclicHub_TerminatesAndBounds`, `Build_IsDeterministic_*`) MUST stay green (enqueue-once is the real termination guarantee; the cap was belt-and-braces). If any hangs, STOP — the cap was load-bearing; revert and investigate before proceeding.
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --nologo` → all green.
- [ ] **Step 2: Remove `EyeInsidePortalOpening` (D5).** Delete the degenerate-portal substitution at `:241-250` and `:484-490`, the `eyeInsideOpening` locals (`:202,:301,:471,:497`), and the helper (`:826-855`) + `EyeStandingPerpDist` (`:815`). This hack covered the unified flood rooting in a thin doorway cell with a degenerate near-projection; per-building floods seed at the entrance opening (never root in a thin cell with a collapsed projection), so it is dead. Run the full suite. The tests that pinned the hack (`Build_EyeStandingInInteriorPortal_FloodsNeighbour`, `Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion`, `Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour`) describe the OLD unified-root behavior — update or remove them to match per-building rooting (they assert a non-bug under the new structure). If removal causes a real interior under-include at the visual gate, STOP and reassess (do NOT re-add as a blind guard).
- [ ] **Step 3: Move the reciprocal clip onto `ProjectToClip` (D6).** Change `ApplyReciprocalClip` (`:758`) from `PortalProjection.ProjectToNdc` to the homogeneous `ProjectToClip` + `ClipToRegion` path, matching the near-side clip, now that per-building floods don't re-enqueue across many drift rounds (the reason D6 used `ProjectToNdc` was unified-flood re-enqueue drift). Run the reciprocal tests (`Build_AppliesReciprocalOtherPortalClip`, `Build_ReciprocalClip_DegradesGracefully_WhenNoBackPortal`, `Build_MultiplePortalsToSameNeighbour_EachResolvesOwnReciprocal`) — they MUST stay green. If `Build_AppliesReciprocalOtherPortalClip` inflates (the drift the comment at `:751-757` warns about), the unified-flood drift is still present somewhere — STOP, keep `ProjectToNdc`, and note D6 as a documented retained divergence.
- [ ] **Step 4: Visual gate + commit.** Launch; confirm the doorway + interiors still render correctly (no new under-include, no flap regression).
```bash
git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs
git commit -m "refactor(render): R-A3 — remove unified-flood band-aids (D4/D5/D6)
Per-building bounded floods make MaxReprocessPerCell, EyeInsidePortalOpening,
and the ProjectToNdc reciprocal dead. Removed deliberately; enqueue-once is the
real termination guarantee, ProjectToClip is the faithful path (PView::GetClip
0x5a4320). Faithful clip math (ProjectToClip/ClipToRegion) untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task R-A4 (OPTIONAL — only if residual flicker remains and correlates with eye jitter > retail's ~36 µm)
**Intent:** Tighten the camera boom toward retail's exponential smoothing (D8) and reconsider — DO NOT blindly rip out — the render-position interpolation (D7). Gate: only do this if, after R-A1R-A3, the visual gate still shows flicker AND `[pv-input]` shows our eye jittering well beyond retail's ~36 µm.
**Files:** Modify `src/AcDream.App/Rendering/RetailChaseCamera.cs`. Reference: A.5.
- [ ] **Step 1: Conformance-pin retail's boom math.** Add a unit test asserting `RetailChaseCamera`'s per-frame convergence equals `alpha = clamp(0.45 · dt · 10, 0, 1)` (≈0.075 at 60 Hz) and that, with default stiffness 0.45, the convergence snap (distance < 0.0004 rotation < 0.0002) does NOT fire (it requires `r_stiffness ≥ 0.9998`). This pins retail-faithful behavior and prevents re-introducing a byte-stable-eye snap.
- [ ] **Step 2: Match the constants** (`t_stiffness = r_stiffness = 0.45`, the `·10` factor, `viewer_offset.y = -3`, viewer_sphere 0.3) and re-run. **Do NOT chase a byte-stable eye** (retail's isn't — A.4). Treat `ComputeRenderPosition` (D7) as suspect but do not remove it (it prevents 30 Hz judder; removing it regressed before — handoff §7).
- [ ] **Step 3: Visual gate + commit** (only if it measurably helps).
---
## E. Testing strategy (the PRE-gate discipline)
- **Conformance tests run WITHOUT the live client** and gate against the measured retail values in A.4: membership stable under ~36 µm eye jitter (`PerBuildingFlood_MembershipStableUnderMicrometreEyeJitter`), ≈2 cells per building (`PerBuildingFlood_TouchesAboutTwoCells`), flood determinism (existing `Build_IsDeterministic_*`).
- **All existing `PortalVisibilityBuilderTests` (24) + `PlayerMovementControllerTests` (14) stay green** at every step. Tests that pinned removed band-aids are updated to the new structure, not left red.
- **The visual gate is the acceptance test** (user at the doorway). But conformance is the PRE-gate — **never ship to the visual gate on a red/absent conformance test.**
- **Re-attach cdb to retail** (handoff §9 workflow, proven) to capture any NEW retail value an implementation step needs. MEASURE, don't infer.
## F. DO NOT (evidence-disproven — handoff §7)
- Byte-stable eye / render-position rest-snap (retail jitters ~36 µm; `cd974b2` failed + regressed → reverted `9b1857a`).
- Bounded-propagation / enqueue-once / "churn" fix (measured `maxPop=1`, 0 churn — REFUTED).
- Physics rest-jitter, viewer-cell dead-zone, two-pipe split, render-side debounce/hysteresis on the branch or clip.
- Trusting a decomp INFERENCE about runtime behavior without a live trace.
## G. Self-review
- **Spec coverage:** handoff §6 R-A1→R-A4 each map to a Task; D1-D8 each map to a phase (§C). The land-cell-as-floodable-root open design point (handoff §8 trace #3) is resolved: the outdoor root is a real `LoadedCell` with `IsOutdoorNode` → full-screen `OutsideView`, no building portals; buildings flood per-building (A.3).
- **Placeholder scan:** R-A1 steps quote real lines + code; R-A2 provides full conformance test code + the `ConstructViewBuilding` body + integration steps; R-A3 removals cite exact line ranges + guard tests; R-A4 cites measured constants. No "TODO/handle edge cases."
- **Type consistency:** `ConstructViewBuilding` (R-A2 Step 3) is the same name used by the R-A2 conformance test (Step 1) and referenced in §D. `BuildOutdoorLandRoot` (R-A1 Step 1) used consistently. `LoadedCell.IsOutdoorNode`/`BuildingId` exist in the current schema (`CellVisibility.cs:87,116`).
- **Open execution-time verifications** (each a ~10-min decomp read or cdb capture, NOT a plan blocker): the exact land-cell `outside_view` fill (full-screen seed vs portal-driven — A.3 says full-screen is faithful); the exact per-building draw ordering in `DrawCells` two-pass structure (decomp:432715-432848) when integrating R-A2 Step 6.
---
## Execution Handoff
Plan complete. Two execution options:
1. **Subagent-Driven (recommended)** — fresh subagent per task, two-stage review between tasks.
2. **Inline Execution** — execute in this session with checkpoints.
R-A1 and R-A2 each end at a **visual gate** (user at the doorway) — those are hard stops requiring the user's eyes regardless of execution mode.