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

37 KiB
Raw Permalink Blame History

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 → DrawPortalConstructView(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-89DrawLandscapeThroughOutsideView), 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.csDrawInside; 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.csLoadedCell 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)

  • 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).

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.

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.
/// <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.

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).

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-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.