37 KiB
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 callsRenderDevice::DrawInside(viewer_cell). The "outside" branch (LScape::drawdirectly) is dead code — the BN predicateedi_2 = -(edi - edi)is a compile-time0.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(outdoorCLandCell→ indoorCEnvCell). 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): resetoutside_view; LIFO worklist; seed root; pop cell → append tocell_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'soutside_view(gated ondraw_landscape, decomp:433664-433684); a portal to another cell does the reciprocalOtherPortalClip+copy_viewinto 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/FixCellListre-process in place. Retail DOES re-process grown cells; it does NOT re-enqueue them. (This is why acdream'sBuild_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,posare on theCObjCellbase (acclient.h:30925-30931, the struct carryingmyLandBlock_). SoDrawInside/ConstructView/ClipPortalsoperate on BOTHCLandCellandCEnvCell— the BNCEnvCell*typing is heuristic; the real param isCObjCell*. 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 overcell_draw_list.outside_view > 0is the single terrain switch.- Outdoor root (
CLandCell): the flood trivially "sees outside" →outside_viewfull →LScape::drawrenders 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_viewstarts empty; the flood walks the building's cells; an exit portal (0xffffffff) adds a door-shaped region tooutside_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_numsettled to a long unbroken run of 2;viewer_cellpointer = 1 distinct value. - Retail does per-building floods:
ConstructView(CBldPortal*)fired ~7×/frame, eachcell_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 inSmartBox::PlayerPhysicsUpdatedCallback(0x452d60) fromCameraManager::UpdateCamera(0x456660).UpdateCamerais first-order exponential smoothing:alpha = clamp(stiffness · dt · 10, 0, 1); defaultt_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'sviewer_sphere.radius = 0.3(matches ourPhysicsCameraCollisionProbe.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→ eitherviewerRoot(indoor cell, registered) or_outdoorNode(built whenviewerRoot is null && viewerCellId != 0,GameWindow.cs:7357-7381) is non-null →clipRootis non-null →DrawInside(GameWindow.cs:7498).- Terrain draws outdoors via DrawInside's full-screen
OutsideViewslice (theIsOutdoorNodeseed atPortalVisibilityBuilder.cs:88-89→DrawLandscapeThroughOutsideView), NOT via theif (clipRoot is null)outdoor block (GameWindow.cs:7445-7486, which now runs only atviewerCellId == 0= pre-spawn/login). - The
else { … DrawPortal/BuildFromExterior … }branch (GameWindow.cs:7613-7719) is effectively dead in normal play (it requiresclipRoot 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 measuredflood 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 theReferenceEquals(clipRoot, _outdoorNode)conditionals (GameWindow.cs:7539, 7571, 7603) still encode an inside/outside distinction by node identity. - D4/D5/D6 (band-aids):
MaxReprocessPerCellcap (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/afterDrawLandscapeThroughOutsideView.src/AcDream.App/Rendering/PortalVisibilityBuilder.cs— R-A2 adds a per-building entry point (or formalizesBuildFromExterioras 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—LoadedCellalready hasIsOutdoorNode/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)toclipRoot.IsOutdoorNode(the 3 sites insideif (clipRoot is not null)) /clipRoot is { IsOutdoorNode: true }(the null-reachable:9219probe). Equivalent for every functional path:OutdoorCellNode.Buildis the onlyIsOutdoorNodesetter, and registeredviewerRootcells are always indoor EnvCells. (Only difference: the pre-spawn[render-sig]outRoot=char flipsY→nwhen 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
PortalVisibilityBuilderTests24/24; CorePlayerMovementControllerTests14/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 onlyIsOutdoorNode/SeenOutside; its flood touches just itself → full-screenOutsideView). -
Modify:
src/AcDream.App/Rendering/RetailPViewRenderer.cs— inDrawInside, whenRootCell.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 byLoadedCell.BuildingId) into theRetailPViewDrawContext. -
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 toPortalVisibilityBuilderthat seeds the flood from a single building's entrance portal(s) and floods only that building's cells. This isBuildFromExteriorscoped 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 (useLoadedCell.BuildingIdso the flood never leaves the building — exactly retail'sCBldPortalchannel staying insidebp->other_cell_id). Faithful toConstructView(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 onlyCellId/IsOutdoorNode/SeenOutside/identity transformsand NO portals, soPortalVisibilityBuilder.Buildfrom it floods just itself → full-screenOutsideView(theIsOutdoorNodeseed at:88-89). Rename the paramnearbyBuildingCellsaway 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, whenctx.RootCell.IsOutdoorNode, afterDrawLandscapeThroughOutsideView(where retail'sLScape::drawwalks the terrain BSP), iterate the nearby buildings (grouped byBuildingIdfromctx's candidate cells), callConstructViewBuildingper building, assemble each into the clip frame, and draw that building's shells + cell object lists clipped to its region — reusing the existingDrawEnvCellShells/DrawCellObjectListspaths per building. Pass the nearby-building set fromGameWindow(the same Chebyshev≤1 gather the old_outdoorNodeused, now grouped byBuildingId) via a newRetailPViewDrawContext.NearbyBuildingEntrancesfield. -
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 confirmfloodno longer oscillates while standing still. If the flap persists, do NOT add hysteresis — capture and compare per-buildingcell_draw_numagainst 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
MaxReprocessPerCellcap (D4). Delete the const (:51) and thepopCounts.GetValueOrDefault(...) < MaxReprocessPerCellclause from both re-enqueue gates (:331,:509), keeping thequeued.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-250and:484-490, theeyeInsideOpeninglocals (: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). ChangeApplyReciprocalClip(:758) fromPortalProjection.ProjectToNdcto the homogeneousProjectToClip+ClipToRegionpath, matching the near-side clip, now that per-building floods don't re-enqueue across many drift rounds (the reason D6 usedProjectToNdcwas unified-flood re-enqueue drift). Run the reciprocal tests (Build_AppliesReciprocalOtherPortalClip,Build_ReciprocalClip_DegradesGracefully_WhenNoBackPortal,Build_MultiplePortalsToSameNeighbour_EachResolvesOwnReciprocal) — they MUST stay green. IfBuild_AppliesReciprocalOtherPortalClipinflates (the drift the comment at:751-757warns about), the unified-flood drift is still present somewhere — STOP, keepProjectToNdc, 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-A2c62663d(outside flap GONE), seam fix2ec189c(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 648clip=0events. 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 retailGetClip(0x5a4320) /ClipPortalsnear-edge-on handling to see how retail keeps it stable, THEN design + conformance-test + visual-gate. Canonical pinned diagnosis: memoryproject_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-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 equalsalpha = 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 requiresr_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·10factor,viewer_offset.y = -3, viewer_sphere 0.3) and re-run. Do NOT chase a byte-stable eye (retail's isn't — A.4). TreatComputeRenderPosition(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 (existingBuild_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;
cd974b2failed + regressed → reverted9b1857a). - 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
LoadedCellwithIsOutdoorNode→ full-screenOutsideView, 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
ConstructViewBuildingbody + 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/BuildingIdexist 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_viewfill (full-screen seed vs portal-driven — A.3 says full-screen is faithful); the exact per-building draw ordering inDrawCellstwo-pass structure (decomp:432715-432848) when integrating R-A2 Step 6.
Execution Handoff
Plan complete. Two execution options:
- Subagent-Driven (recommended) — fresh subagent per task, two-stage review between tasks.
- 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.