acdream/docs/superpowers/plans/2026-06-08-full-retail-render-port-option-a.md
2026-06-09 08:10:06 +02:00

396 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)
**AS-BUILT (2026-06-08, conformance-green, pending visual gate):** `OutdoorCellNode.Build(uint)` is now
portal-less (reverse portals removed the land root floods only itself full-screen OutsideView for
terrain). `PortalVisibilityBuilder.ConstructViewBuilding` is the per-building contract (thin wrapper over
`BuildFromExterior`). `RetailPViewRenderer.DrawInside` groups the nearby building cells by `BuildingId`
(owned by the render layer a reused dict, keeps GameWindow thin) and merges each small per-building
flood into the frame before assembly (`MergeNearbyBuildingFloods` / `MergeBuildingFrame`; 48 m seed
cutoff); the existing draw path (assemble shells object lists) is unchanged. `GameWindow` passes the
flat `NearbyBuildingCells` only on outdoor-node frames. `UnifiedFloodTests` retired (its subject the
unified flood from the outdoor node is removed); its surviving full-screen-OutsideView coverage moved
to `OutdoorCellNodeTests`. Conformance + render suites green (App Rendering 207, Core movement 14,
incl. +3 `PortalVisibilityRobustnessTests`). The detailed steps below are the original design rationale;
this note is the as-built. **Visual gate (grazing doorway) is the acceptance test for "flap gone."**
**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 Chebyshev1 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>"
```
---
> **STATUS 2026-06-08 (late) — R-A4 RULED OUT by live measurement; remaining work is R-A2b (indoor-flood
> edge-on robustness).** Shipped + visual-confirmed: R-A1 `7fe9809`, R-A2 `c62663d` (outside flap GONE),
> seam fix `2ec189c` (missing textures GONE). The indoor crossing flicker is CONCLUSIVELY pinned to the
> **flood/clip being non-monotonic near a doorway's EDGE-ON angle** — NOT the camera: on a clean one-way
> pass the eye glided smoothly (3 X / 18 Y direction-changes over 25.7k frames) and is ~1µm stable at
> rest (more stable than retail's settled tens-of-µm), yet the visible-cell count oscillated 414× with
> 648 `clip=0` events. So R-A4 (camera/eye-jitter) is OFF. Next = **R-A2b**: make the "is the room behind
> this opening visible?" decision robust when the opening is near edge-on (its on-screen area hovers at
> zero — coin-on-edge). FIRST read retail `GetClip` (0x5a4320) / `ClipPortals` near-edge-on handling to
> see how retail keeps it stable, THEN design + conformance-test + visual-gate. Canonical pinned
> diagnosis: memory `project_indoor_flap_rootcause` (2026-06-08 late CORRECTION).
## Task R-A4 (OPTIONAL — SUPERSEDED: eye-jitter ruled out; see STATUS note above. Kept for history.)
**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-A1R-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.