diff --git a/docs/superpowers/plans/2026-06-08-full-retail-render-port-option-a.md b/docs/superpowers/plans/2026-06-08-full-retail-render-port-option-a.md new file mode 100644 index 00000000..cc3792df --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-full-retail-render-port-option-a.md @@ -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); ; }` 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) " +``` + +--- + +## 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(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 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 { [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 +/// +/// Retail per-building flood: ConstructView(CBldPortal*, …) (decomp:433827) reached from the +/// terrain BSP at DrawPortal (decomp:433895). Seeds at '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. +/// +public static PortalVisibilityFrame ConstructViewBuilding( + LoadedCell entrance, + Vector3 cameraPos, + Func 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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-A1–R-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. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 63f3cc48..7c15c9a5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7536,7 +7536,7 @@ public sealed class GameWindow : IDisposable // player; building interiors through the ground). Outdoors the interiors must // depth-test against terrain+exteriors and appear only through real door openings, // so issue NO depth clear. Interior roots keep the doorway clear (unchanged). - ClearDepthSlice = ReferenceEquals(clipRoot, _outdoorNode) + ClearDepthSlice = clipRoot.IsOutdoorNode ? null : slice => { @@ -7568,7 +7568,7 @@ public sealed class GameWindow : IDisposable if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePvInputEnabled && pvFrame is not null) { var vp = envCellViewProj; - char pvOutRoot = ReferenceEquals(clipRoot, _outdoorNode) ? 'Y' : 'n'; + char pvOutRoot = clipRoot.IsOutdoorNode ? 'Y' : 'n'; // 2026-06-08: disambiguate the idle flap. eye=camera eye-point (drives the flood); // player=RenderPosition (Lerp of physics, what the eye chases); rawPlayer=raw physics // body Position; yaw=camera/player heading (F8 rad to catch micro-drift). If the flood @@ -7600,7 +7600,7 @@ public sealed class GameWindow : IDisposable // outdoor-node root so no live entity blinks out outdoors (spec section 10 regression // guard). DrawInside's tail clears entity clip routing and disables clip distances, so // visibleCellIds:null draws them unclipped — identical to the old outdoor path. - if (ReferenceEquals(clipRoot, _outdoorNode) + if (clipRoot.IsOutdoorNode && _interiorRenderer is not null && pviewResult.Partition.LiveDynamic.Count > 0) { @@ -9216,7 +9216,7 @@ public sealed class GameWindow : IDisposable // cells): if bshell=N/N and ids=[node only] but the wall is still see-through, the exterior is // failing to rasterize (draw/clip bug, not EnvCell sidedness); if ids includes interior cells, // the outdoor flood is drawing interiors over the exterior. - sb.Append(" outRoot=").Append(ReferenceEquals(clipRoot, _outdoorNode) ? 'Y' : 'n'); + sb.Append(" outRoot=").Append(clipRoot is { IsOutdoorNode: true } ? 'Y' : 'n'); if (partition is not null) { int shellTotal = 0, shellMesh = 0;