acdream/docs/research/2026-08-30-cathedral-review-opus.md
Erik 6d990d38b3 docs: the cathedral seven-report synthesis + adjudicated FW4 plan
Files the independent review corpus (fable/opus/external + audit,
DAT-format, F418 DAT-geometry, retail decomp reconstruction) and the
synthesis that adjudicates their disagreements: retail roots INTERIOR
at the posed terrace defect pose with a ONE-CELL flood and ov=1 (the
free captures rooted outdoor - both were real); the falls are outdoor
landblock emitters 0xCF418000-13; the "thin seam bands" claim is
DAT-refuted (full-size exits; the seam is a coincident double
exit-to-outside plane at y=48); clear+seals live inside retail''s ov>0
block with a one-frame seal latch. Carries the instrument-first plan
(falls-owner trace, depth checkpoints, pose conformance) and the
decomp-pinned repair sequence toward FW4''s single-owner goal.

Also sweeps in the probe scaffolding slice 5''s path-limited add had
missed (RenderingDiagnostics field + the launch-options row wording).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:52:14 +02:00

39 KiB
Raw Blame History

Cathedral rendering review — independent report (Opus)

Reviewer: Opus 5, independent. Mode: report-only (no edits, no builds, no client launches, no commits). Reviewed: 37febd1f (baseline) → bb9212c7 (HEAD), branch claude/campaign-w-retail-frame-walk, worktree peaceful-blackburn-5333f0. Date: 2026-08-30.

Evidence classes used, in order of authority: (1) the named retail decomp (docs/research/named-retail/acclient_2013_pseudo_c.txt + Ghidra MCP at 127.0.0.1:8081); (2) the installed DATs, read with the already-built tools/A8CellAudit/bin/Release/net10.0/A8CellAudit.exe; (3) the live probe logs in %LOCALAPPDATA%\Temp\fw3-*.log / fw4-*.log; (4) our source at both SHAs. Everything I could not verify is labelled UNVERIFIED inline.


1. Executive verdict

  1. The ordering fix the last three slices were chasing already landed. On HEAD the falls' four emitters submit and drain pre-clear (fw4-slice6-gate.log:12786-12801). Phase is no longer the binding constraint, so slices 46 were repairing an already-repaired axis.
  2. The falls are {4f418012..4f418015} — four STAB STATICS of EnvCell 0xF4180106, reached on the failing frames as a building look-in, not as a root-flood cell. Slices 3 and 4 named the wrong objects (f4243/f4285 are ~9.8 m-radius dynamics).
  3. Slice 6's factual premise is refuted by the DAT. 0xF4180104's exit portals are the cell's full side faces (4-vert rectangles at ±12.00 m, 7-vert arches at ≈±5.03 m) — not "thin bands".
  4. The only depth-destroying operation in the pre-clear scope is the portal PUNCH (far-Z, ALWAYS+write). Retail bounds it with an unconditional full FlushAlphaList(0f) at every building (@0x0059f30b); ours is a distance-filtered partial flush. That is the highest-ranked unexamined suspect — but I could not prove it offline.
  5. Four other decomp-provable divergences are live at HEAD: unconditional clear+seals (retail gates both on ov>0), a clipped punch (retail punches the unclipped polygon), terrain drawn with no per-landcell in_view cull, and a sorted alpha drain (retail's has no sort).
  6. The "stalks" have a completed, decomp-cited fix that is simply not on this branch: fce250ec (marker-class degrade admission) is not an ancestor of bb9212c7. Nothing on this branch suppresses them.
  7. Why six fixes failed: three different object identities, zero depth measurements, two false retail citations, six commits in 105 minutes with no gate between them. The failure is a method failure, not a mechanism-knowledge failure.
  8. Recommendation: stop shipping fixes. Instrument the depth buffer and the punch census first (§7 Stage 0). Then land R1R7, each gated.
  9. Commit verdicts: keep 1/2/3 (with corrections), revert 4, revert 5, keep 6's shape but fix its cull.
  10. I could not determine the final residual pixel mechanism offline and say so rather than adding a seventh guess.

2. Retail frame reconstruction

Everything in this section is read from the decomp at the cited address.

2.1 The four (and only four) alpha drains

grep "FlushAlphaList" over the whole pseudo-C returns exactly five hits — the definition plus four call sites:

Address Site Argument Meaning
0x00452bf0 a SmartBox sibling (not the normal-mode path) 0f drain all
0x00453b8b SmartBox::RenderNormalMode epilogue 0f frame-end drain
0x0059f30b RenderDeviceD3D::DrawBuilding, before the portal pass 0f pre-punch drain
0x005a1a07 RenderDeviceD3D::DrawBlock, after DrawSortCell global flush pressure valve
0x005a4872 PView::DrawCells, after LScape::draw 0f pre-clear drain

D3DPolyRender::FlushAlphaList @0x0059d2e0 (Ghidra decompile, full body read):

if (((float)alphaedMeshCountClip  < param_1 * 3000.0f) &&
    ((float)alphaedMeshCountAlpha < param_1 * 3000.0f)) return false;   // 0x453B8000 = 3000.0f
... walk alphaedMeshListClip  [0..count) -> RenderMeshSubset ... ;  alphaedMeshCountClip  = 0;
... walk alphaedMeshListAlpha [0..count) -> RenderMeshSubset ... ;  alphaedMeshCountAlpha = 0;

Three load-bearing facts:

  • There are TWO lists, alphaedMeshListClip and alphaedMeshListAlpha (both capacity 0xbb8 = 3000), drained clip-first.
  • There is NO SORT. The drain is a straight forward walk of the arrays. D3DPolyRender::AddMeshToAlphaList @0x0059c230 stores only {MeshBuffer*, subsetIndex, CSurface*, flags, worldMatrix}no depth key exists. Retail's alpha order is the walk's submission order.
  • flush(0f) always drains (threshold 0 ⇒ count < 0 false). flush(0.75) drains only above 2250 queued.

The DrawBlock valve at 0x005a19ee-005a1a07 compares the global flush against 1.0f, not the queue size, and only calls through when the compare fails. At any flush ≤ 1.0 it never fires, and at flush > 1.0 the threshold flush*3000 exceeds the 3000-entry cap, so it can never drain either. The mid-walk valve is effectively inert. (The test ah,0x41 polarity is BN-unlifted; I read the idiom, not the raw bytes — polarity UNVERIFIED, but both readings give "inert", so it is not load-bearing.)

What defers, from D3DPolyRender::DrawMesh @0x0059d4a0: the decision is per mesh SUBSET, keyed on s_AlphaDelayMask & mesh->isStippledOrAlphaedMask[subset], with an early bail when render_device+0x7e0 != 0. It is a surface property, never a distance.

2.2 PView::DrawCells @0x005a4840 — byte-order confirmed

if (this->outside_view.view_count > 0) {                    // 005a4852
    Render::useSunlightSet(1);                              // 005a485a
    Render::PortalList = this;                              // 005a485f
    LScape::draw(this->lscape);                             // 005a486b
    D3DPolyRender::FlushAlphaList(0f);                      // 005a4872   <-- PRE-CLEAR DRAIN
    render_device->m_nFrameStamp += 1;                      // 005a4886   <-- dedupe RE-ARM
    if (forceClear == 0) { cond = (portalsDrawnCount == 0); portalsDrawnCount = 0; }   // 005a4893-9e
    if (forceClear != 0 || !cond)
        render_device->Clear(4, black, 1.0f);               // 005a48a9   <-- Z CLEAR
    for (cell = cell_draw_list[n-1] .. [0])                 // 005a48c9   far->near
        if (cell->structure->drawing_bsp != 0) {
            positionPush; for each view: setup_view;
            for each portal: if (other_cell_id == 0xffffffff)
                DrawPortalPolyInternal(portal.poly, 0);     // 005a49b7   <-- SEALS (own depth)
        }
}                                                            // 005a4852 block ENDS here
Render::useSunlightSet(0); Render::restore_all_lighting();   // 005a49f3
for (cell far->near) if (drawing_bsp) { per view: setup_view; DrawEnvCell(cell); }  // 005a4a05
for (cell far->near) { PortalList = cell top view; DrawObjCellForDummies(cell); }   // 005a4ae3

The depth clear AND the exit seals are both strictly inside outside_view.view_count > 0. There is no drain after DrawEnvCell and none after DrawObjCellForDummies — the interior scope's alpha survives to RenderNormalMode's frame-end drain @0x00453b8b (verified: that epilogue runs immediately after the root branch, before the target/rendering callbacks).

2.3 Buildings, punches, and the answer to (a) "clipped vs culled vs whole"

RenderDeviceD3D::DrawBuilding @0x0059f2a0, read verbatim:

outdoor_pview->outdoor_portal_list = b->portals;
CPhysicsPart::UpdateViewerDistance(part0);
if (part0->gfxobj[part0->deg_level] != 0) {          // 0059f2d3 — degrade gate
    ... set detail surface state ...
    D3DPolyRender::FlushAlphaList(0f);               // 0059f30b — FULL drain, unconditional
    CPhysicsPart::Draw(part0, 1);                    // 0059f31d — PORTAL pass
    ObjBuildingOrBuildingPart = 1;
    CPhysicsPart::Draw(part0, 0);                    // 0059f336 — SHELL pass
    ObjBuildingOrBuildingPart = 0;
}

PView::ConstructView(CBldPortal) @0x005a59a0 — the punch itself:

side = sidedness(viewer, poly.plane, F_EPSILON=0.000199999995f);   // 005a59c6
if (portal_side ? side==NEGATIVE : side==POSITIVE) {
    PView::GetClip(this, side, poly, &clip_view, &npts, pass);      // 005a5a23
    if (npts != 0 && (dest = CEnvCell::GetVisible(other_cell_id)) != 0
        && Render::copy_view(dest->portal_view[dest->num_view-1], &clip_view, npts) != 0) {
        if (arg5 != 2) DrawPortalPolyInternal(esi /* the RAW polygon */, arg5 == 1);   // 005a5a7b
        framePop();
        if (arg5 != 1) PView::ConstructView(this, dest, other_portal_id);              // 005a5a9c
        return 1;
    }
}
return 0;

Two things this settles:

  • Pass 1 punches (flag 1 = far-Z), pass 2 recurses. Pass 2 draws no polygon at all.
  • The punched polygon is esi — the ORIGINAL, UNCLIPPED authored polygon. clip_view/npts are used only to build the destination cell's view. Retail never hardware-clips or scissors the punch.

Answering the prompt's question (a) for an interior root with exit portals:

Content Treatment
terrain (DrawLandCell) view-CULLED per block and per 24 m landcell by LScape::draw_check_blocks @0x00505f80 / landcell_check @0x005050a0 against every active view; the surviving cells draw whole. Never clipped.
sky (GameSky::Draw) drawn once at the head of LScape::draw, whole.
building shells whole, per building, after that building's punch pass; gated only by gfxobj[deg_level] != 0.
EnvCell shells (DrawEnvCell) whole, per cell per installed view.
objects / cell statics / dynamics Render::viewconeCheck @0x0054c250 sphere-vs-view; PARTIAL draws whole.
portal-chain polygons only the only CPU-clipped geometry, via PView::GetClip @0x005a4320 → ACRender::polyClipFinish. And the clip result is used for the view, not for the drawn punch.

2.4 Where particles live (this was the missing piece)

ParticleEmitter::EmitParticle @0x0051d126:

Particle::Init(...);
CPhysicsObj::AddPartToShadowCells(this->physobj);      // 0051d126

Every emitted particle is registered as a CPhysicsPart in its owner object's SHADOW CELLS. It therefore draws at those cells' turns through DrawBlock (pass 1 updates in-view cells' shadow objects and insertion-sorts shadow parts by depth; pass 2 draws them via DrawSortCellDrawObjCell) and through DrawObjCellForDummies for interior cells — i.e. through the same CPhysicsPart::DrawDrawMesh path as any mesh, and so through the same per-subset alpha-delay branch.

CPhysicsObj::ShouldDrawParticles @0x0050fe60 is not a draw-order gate. Its single caller is ParticleEmitter::UpdateParticles @0x0051d1a6 — the simulation side. Body:

if (m_bExaminationObject == 0) {
    if (!(CYpt < arg2 /*degrade_distance*/)) return 0;
    if (cell == 0) return 0;
    if (cell->vtable->IsInView() == 0) return 0;
}
return 1;

2.5 Answers to (b), (c), (d)

(b) Draw order relative to the clear and the seals, interior root, ov>0:

sky                     ─┐
terrain (culled cells)   │
per building:            │  all inside LScape::draw  →  ALPHA QUEUED
   FlushAlphaList(0f)    │  (and drained at every building)
   punch (far-Z, raw)    │
   look-in DrawCells     │
   shell                 │
outdoor objects+particles┘
────────── FlushAlphaList(0f) @005a4872 ──────────   drain vs TRUE landscape depth
────────── m_nFrameStamp += 1 ──────────             per-part dedupe RE-ARMED
────────── Clear(z) @005a48a9 ──────────             gated on portalsDrawnCount
────────── exit seals @005a49b7 ──────────           own-depth panels at apertures
DrawEnvCell per flood cell                           ─┐
DrawObjCellForDummies per flood cell (objects+their   │ ALPHA QUEUED
   particles)                                        ─┘
────────── FlushAlphaList(0f) @00453b8b ──────────   drain vs interior depth + seals

(c) How the list drains. Two arrays, no sort, insertion order, drained at the four sites above; each drain zeroes its counts. The depth in place at each: pre-punch = everything drawn so far this landscape walk; pre-clear = complete landscape depth including punched apertures and whatever look-in content refilled them; frame-end = cleared depth seals interior shells interior objects.

(d) Depth at final drain. At the aperture the seals own it (portal surface's own projected depth, written DEPTHTEST_ALWAYS + z-write after the clear). At the vista (everything the landscape painted) depth is 1.0 — the clear erased it — and only colour survives. That is precisely why retail must drain the landscape alpha before the clear: any landscape-scope alpha that survived into the interior scope would z-pass across the whole vista. Retail's guarantee is structural: after FlushAlphaList(0f) at 005a4872 the list is empty, so nothing can.


3. DAT geometry findings (installed DATs, read live)

Run: tools/A8CellAudit/bin/Release/net10.0/A8CellAudit.exe portals <cell> (already built; no compilation performed).

3.1 Landblock 0xF418 inventory

  • 88 outdoor objects 0xCF4180000xCF418057. Ordinals 17 are model 0x0200085A — the spawn-marker Setup that retail never draws (see §6).
  • 9 buildings 0xCF4180580xCF418060. Four carry portals: 0x01001FB0→cells 0100-0102, 0x01001FB2→0103,0104, 0x01001FB3→0106-010B, 0x01001FB7→0112-0115.
  • 24 EnvCells 0xF41801000xF4180117.

3.2 The cells that matter

0xF4180104 (the owner's teleloc), localAABB min=(-5.70,-12.00,9.80) max=(5.02,12.00,20.00):

portal other flags polyId verts plane N centroid·N
0 0xFFFF EXIT 5 36 4 (0, 1, 0) 12.000
1 0xFFFF EXIT 5 39 7 (1, 0, 0.06) 5.028
2 0xFFFF EXIT 5 37 4 (0, 1, 0) 12.000
3 0x0105 ExactMatch, PortalSide 40 8 (0, 0, 1) 5.100
4 0xFFFF EXIT 5 38 7 (1, 0, 0.06) 5.035

This refutes slice 6's stated premise. Portals 0 and 2 are 4-vertex rectangles sitting exactly on the cell's ±Y faces at ±12.00 m — i.e. the entire 10.7 m × 10.2 m end faces. Portals 1 and 4 are 7-vertex arches on the ±X faces at ≈±5.03 m. This is an open-sided gallery, not a set of thin seam bands. The "seam band" the PV campaign observed is a screen-space artifact of how our clip slices are built, not an authored polygon shape.

0xF4180106 — the falls' owner cell, localAABB min=(-5.49,-12.00,9.80) max=(5.23,-6.00,20.00): 3 portals — interior →0x0110 (poly 78, 9 verts), interior →0x010F (poly 77, 9 verts), and one exit (poly 76, 4 verts, N=(0,1,0), centroid·N = 3.000, flags 7). One 4-vertex outdoor aperture.

0xF418010F: 2 portals, both interior (→0x0107, →0x0106), no exits.

3.3 The exit-portal flag

Across all 24 cells of 0xF418: every other == 0xFFFF portal has flags 5 or 7 (bit 0x4 always set); every interior portal has flags 1 or 3 (bit 0x4 never set). Bit 0x4 ≡ "leads outside" — exactly as claude-memory/project_pview_visibility_campaign.md records. Our tree detects exits purely by the 0xFFFF sentinel and never reads bit 0x4 (src/AcDream.App/Rendering/Walk/WalkCellFactory.cs:93); the two are redundant here, so this is correct-by-accident but not a defect.

3.4 The falls' identity — PINNED

%LOCALAPPDATA%\Temp\fw3-falls-probe.log, aggregated:

10303  [walk-part] cell-owners n=6 ids=[4f418012,4f418013,4f418014,4f418015,f4180106,f418010f]
  192  [walk-part] cell-owners n=5 ids=[4f418012,4f418013,4f418014,4f418015,f4180106]

{4f418012, 4f418013, 4f418014, 4f418015} never appear without cell f4180106. InteriorEntityIdAllocator.Base mints 0x40000000 | (lbX<<20) | (lbY<<12) = 0x4F418000 for landblock 0xF418 (src/AcDream.Core/World/InteriorEntityIdAllocator.cs:47-48), and the 0xC… sibling namespace covers landblock outdoor statics. So the falls are four EnvCell static objects of 0xF4180106 — confirmed interior-cell statics, not landblock objects and not unattached emitters.

Corroborating: the [walk-emit] interior-unattached dump added at slice 5 never printed a single line in any of the three gate logs (grep -c walk-emit = 0 in fw4-emit-probe.log, fw4-slice5-gate.log, fw4-slice6-gate.log) — the loop body is over an empty list. The unattached-emitter theory is dead.

Cross-check against retail: terrace-edge.log (retail's own walk at the #456 pose) punches DC pv=009d4b08 ov=0 n=2: f4180106 f418010f every frame, and cathedral-arrival.log roots at DI f4180106 on all 39 frames — so retail reaches this exact cell through the same two routes.

UNVERIFIED: the Setup/GfxObj ids of the four falls objects. The audit tool has no stab-dump verb and I did not build one. Also UNVERIFIED: where the lake water surface geometry lives (landcell terrain vs a separate surface). Both are cheap to establish and both matter to the fix plan.


4. Divergence table (ranked)

Rank = (how load-bearing for this defect) × (confidence it is real).

# Divergence Retail acdream @ bb9212c7 Evidence
D1 Per-building alpha barrier is partial, not full FlushAlphaList(0f) — drains the entire list before every punch pass AlphaBarrierFlushLandscapeAlphaFartherThan(d) — drains only entries with ViewerDistance >= d decomp 0x0059f30b; WalkFrameDriver.cs:655; RetailPViewPassExecutor.WalkLeaf.cs:246-247; RetailPViewPassExecutor.cs:649-650; RetailAlphaQueue.cs:261-278
D2 Clear + seals fire unconditionally both strictly inside if (outside_view.view_count > 0) emitted unconditionally at OnInteriorFloodDrawTurn, doc-commented as deliberate decomp 0x005a48520x005a49b7; WalkFrameDriver.cs:690-704 ("both unconditional … whether or not a landscape turn just ran")
D3 The punch is clipped, with a silent fall-open DrawPortalPolyInternal(esi, 1) on the raw polygon; no view clip, no scissor clipped to slices[activeViewIndex].Planes; when the index is out of range it silently falls back to no planes decomp 0x005a5a7b; RetailPViewPassExecutor.WalkLeaf.cs:145-158
D4 Interior-root terrain has no in_view cull draw_check_blocks/landcell_check mark per-block and per-24 m-cell visibility against the exit views; only marked cells draw _terrain.Draw(camera, frustum, clipPlanes: default, ndcClipAabb: full) — every frustum-visible landblock slot draws whole decomp 0x00505f80 / 0x005050a0; RetailPViewPassExecutor.WalkLeaf.cs:99-110; TerrainModernRenderer.cs:202-242
D5 The alpha drain sorts no sort — insertion order is the walk order; AddMeshToAlphaList stores no depth key SortRetailOrder() radix-sorts by viewer distance at every flush decomp 0x0059d2e0, 0x0059c230; RetailAlphaQueue.cs:162, 269
D6 Marker degrade admission absent gfxobj[deg_level] == 0 ⇒ part not drawn; spawn markers author a nothing-ladder and are never drawn at any distance no per-entity degrade selection on this branch git merge-base --is-ancestor fce250ec bb9212c7NO; walk degrade applies only to WalkBuilding (WalkBuildingFactory.cs:87-120)
D7 "Emit exactly once" invariant m_nFrameStamp += 1 after the pre-clear drain deliberately re-arms the per-part dedupe so an object drawn outdoors draws again in the interior pass slices 4/5 subtract pre-clear owners from both last-pass arms so each owner emits exactly once per frame decomp 0x005a4886; RetailPViewRenderer.cs:2184-2185, 2322-2324
D8 Straddle heuristics instead of shadow-cell membership an object's parts live in every cell its sphere overlaps (AddPartToShadowCells), and draw at each of those cells' turns SphereStraddlesExitPlane(cell, c, r) geometric test over flood cells decomp 0x0051d126; RetailPViewRenderer.cs:597-626; WalkPView.cs (slice 5)
D9 Particle visibility is one frame stale and now over-wide cell->IsInView() is the current frame's flag TerrainModernRenderer.VisibleCellIds + InViewCells are marked at frame end and consumed next frame; slice 6 widened the terrain set to the whole frustum WorldSceneRenderer.cs:263-264, 441-447; TerrainModernRenderer.cs:225-234
D10 Exit flag 0x4 never read flag 0x4 ≡ leads-outside detected only via the 0xFFFF sentinel WalkCellFactory.cs:93; A8CellAudit flags 5/7 vs 1/3 — redundant here, benign

D1D3 are the ones that can plausibly produce "alpha paints across pixels whose true occluder is in front of it". D4 and D9 are the ones that can plausibly produce the new post-slice-6 report (#3, "particles within the cathedral are also wrong"). D6 is the whole of the "stalks" half.


5. Per-commit verdicts

37febd1f — FW4 slice 1, interior outside-view slices from the walk

KEEP. The diagnosis is sound and the mechanism is retail's: retail has exactly one visibility structure, and having the walk decide whether terrain draws while PortalVisibilityBuilder decided where is a state that retail cannot produce. ClipFrameAssembler.ReassembleOutsideViewFromWalk is the right seam. The punch-fan index aliasing repair is real. Correction owed: it left the silent fall-open at RetailPViewPassExecutor.WalkLeaf.cs:146-148 — an out-of-range activeViewIndex produces an unclipped punch with no log. That violates the campaign's own binding rule 3 ("fail loud, never silently drop") and is the same bug class the commit was fixing.

c40aecfc — FW4 slice 2, exit seals stamp the walk's own flood

KEEP the mechanism; the stated diagnosis was wrong. Sealing from cell_draw_list is exactly 0x005a49b7, and taking that list from the walk rather than the old apparatus is correct. But the commit message's causal claim — "the falls … submit POST-depth-clear on interior roots … the drain z-passed across the unsealed opening" — is falsified by its own successor evidence: after slices 45 the falls submit and drain pre-clear and the symptom is unchanged. Corrections owed: (i) seals must be gated on ov>0 (D2); (ii) retail's seal is the authored polygon under the cell's setup_view, not a hardware slice clip — the "old assembly's per-cell slice clips where present" path is an invented hybrid; (iii) the commit says it seals "unclipped where the old apparatus had no entry (benign)" — that inconsistency means two different seal geometries exist in one frame depending on an unrelated data structure's contents.

5f7ccdea — FW4 slice 3, outside-stage predicate reads the walk flood

KEEP; wrong target. Feeding DynamicDrawsInOutsideStage the walk's VisitedCells instead of the old flood is right for the same one-visibility- structure reason. But the commit identifies "the same two emitter entities (f4243/f4285)" as the falls. The probes show f4243/f4285 are dynamic entities of radius ≈9.8-10 m parented at f4180104/f4180106 (fw4-slice3-gate.log, fw4-slice6-gate.log: [walk-dyn] … id=f4243 parent=f4180104 r=9.8 outside=1 cone=1), while the falls are the four 4f41801[2-5] cell statics. Slice 3 fixed a real desync for the wrong objects, and its success criterion was therefore never testable.

f3a03efc — FW4 slice 4, straddler dynamics emit pre-clear, once

REVERT. Three independent problems:

  1. Wrong objects — same misidentification as slice 3.
  2. Invented rule. Retail has no "exit-plane straddler" concept. An object's parts are in every shadow cell its sphere overlaps (AddPartToShadowCells @0x0051d126); membership, not a plane test, decides where it draws.
  3. Contradicts the decomp. The commit's core invariant is "every owner emits exactly once". Retail deliberately bumps m_nFrameStamp at 0x005a4886after the pre-clear drain — precisely so that a part drawn during LScape::draw draws again in the interior pass. The ExceptWith(_preClearParticleOwnerScratch) subtractions at RetailPViewRenderer.cs:2184 and :2322 implement the opposite of retail's rule.

0d6cd5c0 — FW4 slice 5, straddling cell statics' particles emit pre-clear

REVERT, but keep the insight. The retail citation is the right one — DrawBlock pc:430056-430064 / the shadow-cell draw is how an object overlapping outdoor landcells reaches the landscape scope. The implementation is not that rule: it is SphereStraddlesExitPlane over the walk's flood cells (RetailPViewRenderer.cs:604-626), gated with if ((cellId & 0xFFFF) < 0x100 || _lookInCellIds.Contains(cellId)) continue; — it explicitly skips look-in cells. On the failing frames 0xF4180106 is a look-in cell (fw4-slice6-gate.log:12786 shows it in walkCells while the root flood at :10352 does not contain it), so this slice does not touch the falls at all. Its own probe evidence shows the falls arriving on the cell-owners route, not the static-owners n=12 ids=[4f418007…] straddler set it created.

bb9212c7 — FW4 slice 6, interior roots draw landscape unclipped

KEEP THE SHAPE, FIX THE CULL, RETRACT THE PREMISE.

  • Right: collapsing slice 1's per-view terrain fan to one terrain turn is a genuine correction — LScape::draw runs its block walk once, and the active views feed only draw_check_blocks. Removing the hardware clip and scissor is also retail-correct ("retail never clips ordinary meshes per view").
  • Wrong premise: "at the #456 cathedral seam those authored portal polys are thin BANDS far narrower than the real opening" is refuted by the DAT (§3.2): 0xF4180104's exits are full ±12.00 m faces and 7-vertex arches; 0xF4180106's exit is a 4-vertex face. The narrow bands are a property of our clip-slice construction, not of the authored geometry — which means the slice builder is the thing to look at, and slice 6 papered over that by removing the consumer instead.
  • Regression risk: it replaced "clipped too tight" with "not culled at all". Retail restricts the landscape to in_view landcells; the walk already computes exactly that set (RetailFrameWalk.DrawLandscape calls landscape.CheckBlocks(ctx.CyPlane, activeViews) at RetailFrameWalk.cs:152 and emits per-cell turns), and the leaf renderer ignores it. Two consequences: terrain colour is painted over the whole frustum before the clear (so any hole in the interior repaint shows world where retail would show only the aperture), and TerrainModernRenderer.VisibleCellIds — which feeds next frame's ShouldDrawParticles cell-in-view gate — is widened to the whole frustum. Both are live candidates for the owner's new report #3.

6. The "stalks" — a separate, already-solved defect

The lake-edge marker "stalks" are not part of the falls story and do not need a new mechanism. fce250ec ("port retail marker-class degrade admission") implements the decomp rule — get_degrade @0x0051E4B0 selects a per-part LOD off the CYpt viewer distance, and both DrawBuilding @0x0059f2a0 (gfxobj[deg_level] != 0, verified above at 0059f2d3) and CPhysicsPart::Draw @0x0050D7A0 skip the draw when the selected id is 0; spawn markers author {self at MaxDist=0, id 0 to infinity} and are therefore never drawn by retail at any distance.

git merge-base --is-ancestor fce250ec bb9212c7 returns NO. That commit lives only on the quarantined claude/cathedral-rendering-debug-47a417. Nothing on the FW branch replaces it: the FW1 degrade work (e23a589a, c1a029ed, b3ac5872, all ancestors) selects a building's drawing-BSP inside WalkBuilding only (WalkBuildingFactory.cs:87-120, WalkBuilding.cs:101-114); no degrade gate is applied to cell statics or dynamics anywhere in src/ (grep -n "Degrade" Walk/*.cs and WalkStaticStreamPopulator.cs — zero hits in the populator).

The DAT confirms the class is present here: ordinals 17 of landblock 0xF418's outdoor object list are model 0x0200085A, the known marker Setup.

Caveat, honestly flagged: the prompt describes the observed stalks as "server-spawned weenies, e.g. entities parented at 0xF418010D". Weenies carry their own Setup, so whether restoring fce250ec covers these particular stalks depends on the Setup ACE gives them. claude-memory/project_pview_visibility_campaign.md already flags this: "If stalk-like objects still show at a gate, they're server weenies with a different model: identify by guid." UNVERIFIED until someone logs the guid → Setup → DIDDegrade chain for one visible stalk.


7. Why the symptom survived all six fixes

This is a method failure. Five specific causes, each with evidence:

  1. The object was never identified before the fixes started. Slice 2 called the falls "cell-owners route" emitters, slice 3 named f4243/f4285 (dynamics), slice 4 called them "interior-parented exit-plane straddler DYNAMICS", slice 5 called them "EnvCell STAB-LIST statics". Only slice 5 was close, and even it used the wrong sense of "stab" — in the DAT, numStabs/stab_list is the visible-cell list (references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs:36-68; our WalkCell.StabList is cell ids, WalkCellFactory.cs:105), while the placed objects are StaticObjects. The four falls were identifiable from fw3-falls-probe.log — which existed before slice 2 — in one grep.

  2. The depth buffer was never measured. Every slice reasoned about depth from code reading. Slice 6's own message asserts "the depth they test against is EMPTY" as a conclusion, not a measurement. The probe apparatus answers when (phase=pre/post) and who (ids=[…]) but has no verb for what depth is at these pixels. The single question that would discriminate between D1, D3, D4 and "the particle's own depth is wrong" has never been asked of the machine.

  3. Once phase=pre was achieved, the hypothesis space collapsed. By slice 5 the falls demonstrably drained pre-clear (fw4-slice6-gate.log:12793-12801). At that point the ordering axis was closed and every further ordering change was necessarily inert. Slice 6 correctly inferred "then the depth must be empty" but then guessed at why instead of measuring, and guessed a DAT fact that is false.

  4. Two retail citations were mis-scoped, and nothing caught it. Slice 4's "emit exactly once" is the opposite of m_nFrameStamp += 1 @0x005a4886. Slice 6's "thin bands" is contradicted by four lines of A8CellAudit output from an already-built tool. The campaign's binding rule 2 ("grep named first … pseudocode notes before C#") was followed for the shape of the mechanism and skipped for the specific numbers.

  5. Six commits in 105 minutes with no gate between them. 37febd1f 18:21 → bb9212c7 20:06. Each was validated by hermetic tests only (6,762/0), which cannot see a depth-composition defect. Each slice's premise was built on the previous slice's unvalidated conclusion, so the errors compounded: slice 4 built on slice 3's misidentification, slice 5 built on slice 4's invented rule, slice 6 built on slice 5's non-effect. The campaign's own rule 4 ("the transition checklist is a standing self-run gate … before the owner sees it") was not exercised between slices.


Ground rule: no code change until Stage 0 produces a number. The investigation has spent six rounds on plausible mechanisms; the cheapest remaining move is to make the frame observable.

Stage 0 — instrument (no behaviour change)

P1 — depth checkpoint readback. Under a new ACDREAM_PROBE_WALK_DEPTH=1 (documented in docs/launch-options.md in the same commit, per the binding rule), capture the depth attachment at three points in an interior-rooted frame: (i) after the terrain turn, (ii) after the last building turn, (iii) immediately before passes.FlushLandscapeAlpha() in the clearInteriorDepth closure (RetailPViewRenderer.cs:356). Emit a downsampled 64×36 depth grid per checkpoint plus a per-pixel min/max. Falsifiable prediction (D1/D3): at checkpoint (iii), the screen cells where the falls bleed hold ≈0.999999 (punched far-Z), while checkpoint (i) holds real terrain depth at the same cells. If (iii) already holds real terrain depth, D1 and D3 are dead and the residual is the particles' own depth — which the same dump measures by also logging each drained falls submission's projected NDC z.

P2 — punch census. Per punch fan, log building id, portal index, activeViewIndex, slices.Length, whether planes was empty (the silent fall-open), and the fan's screen-space AABB. Falsifiable prediction: on failing frames at least one punch of building 0x01001FB3 (the owner of 0xF4180106) is emitted with planes empty and a screen AABB covering the falls' region.

P3 — one grep, zero code: dump EnvCell 0xF4180106's StaticObjects (a stabs verb on A8CellAudit, ~20 lines) to get the four falls objects' Setup/GfxObj/DIDDegrade ids, and do the same for one visible stalk's weenie Setup. This closes the two UNVERIFIED items in §3.4 and §6 and tells you whether D6 alone fixes the stalks.

Stage 1 — decomp-provable repairs (each its own commit, each gated)

id change retail pin
R1 AlphaBarrier becomes a full FlushLandscapeAlpha(). Retire FlushLandscapeAlphaFartherThan from production. 0x0059f30bFlushAlphaList(0f), unconditional, inside the degrade gate
R2 Gate ClearInteriorDepth and ExitSeals on outsideView.ViewCount > 0; delete the "unconditional" doc claim at WalkFrameDriver.cs:695-699. 0x005a4852 opens the block; 0x005a48a9 and 0x005a49b7 are both inside it
R3 Punch the unclipped authored polygon; delete the slices[activeViewIndex] clip. Make an out-of-range activeViewIndex throw, not silently fall open. 0x005a5a7b draws esi (the raw CPolygon*); the clip result feeds only copy_view
R4 Terrain draws the walk's own in_view landcell set (already computed by WalkLandscape.CheckBlocks), whole and unclipped. Retire the frustum-only interior path. 0x00505f80 draw_check_blocks, 0x005050a0 landcell_check
R5 Drop SortRetailOrder once the walk owns submission order end to end; drain in insertion order. 0x0059d2e0 has no sort; 0x0059c230 stores no depth key
R6 Cherry-pick fce250ec's RetailDegradePolicy + WbMeshAdapter.IsNeverDrawnByDegrade onto this branch and apply it in the walk's static populator and the dynamic classifier. get_degrade @0x0051E4B0; gfxobj[deg_level] != 0 at 0x0059f2d3 and CPhysicsPart::Draw @0x0050D7A0
R7 Replace SphereStraddlesExitPlane + the "emit exactly once" subtractions with retail's rule: an object submits at every cell it is a shadow member of, with a frame-stamp dedupe that is re-armed after the pre-clear drain. AddPartToShadowCells @0x0051d126; m_nFrameStamp += 1 @0x005a4886; GetDrawnThisFrame/SetDrawnThisFrame @0x0059f388

Ordering: R2 and R3 are cheap and decomp-unambiguous — land them first; they may change the symptom on their own and they make P1/P2's readings easier to interpret. R1 next (it is the mechanism D1 names). R4 next (it also closes the D9 widening). R7 replaces slices 4 and 5 wholesale, so revert f3a03efc and 0d6cd5c0 as part of it rather than layering. R5 and R6 are independent and can land any time.

Gate for each: the two-checkpoint depth dump from P1 at the cathedral pose, plus the campaign's own transition checklist (portal-in, Holtburg house exit, teleport stopwatch, steady-state fps) — rule 4, which was skipped for all six slices. Do not stack two of these before an owner look.

What NOT to do

  • Do not add a seventh ordering fix. The ordering axis is closed (fw4-slice6-gate.log:12793-12801).
  • Do not suppress the falls emitters, add a depth bias, force particle depth writes, or scissor the drain. All are symptom-site workarounds and all are already on the render digest's DO-NOT-RETRY list in spirit.
  • Do not reintroduce per-view terrain clipping. Slice 6 is right that retail culls rather than clips; the fix is to add the cull, not to restore the clip.

9. Open questions

  1. What depth is actually at the failing pixels at the pre-clear drain? Unanswered. This is the whole investigation in one question and Stage 0/P1 answers it in one run.
  2. Setup/GfxObj/DIDDegrade of 0xF4180106's four static objects. UNVERIFIED — needs a stabs verb on A8CellAudit (no build of production code required).
  3. Where does the lake water surface live — landcell terrain polys, or a separate surface with its own pass? If it is a separate translucent pass, it shares the alpha list with the falls and their relative order is decided by D5 (our sort) rather than by the walk. UNVERIFIED.
  4. The DrawBlock alpha-valve polarity (test ah,0x41 @0x005a19fc). I read the idiom, not the raw bytes. Both readings make the valve inert at default flush, so it is not load-bearing here, but the FW pseudocode doc still lists it as owed and it should be closed from the binary.
  5. Are the observed stalks the marker class? Depends on the weenie Setup ACE assigns. If not, fce250ec will not cover them and the guid → Setup chain must be logged live.
  6. Why are our clip slices "thin bands" when the authored polygons are full faces? Slice 6 removed the consumer of those slices for terrain, but the slices still drive the punch fans and the dynamics apertures. If the slice builder is producing bands from full-face polygons, that is a defect in ClipFrameAssembler/ClipPlaneSet/WalkScreenClip that outlives this bug. Worth one focused offline check against 0xF4180104 poly 36 (a 4-vertex ±12.00 m face) — project it and compare against the produced slice.
  7. Does the one-frame lag in particle visibility matter here? WorldSceneRenderer.cs:441-447 marks visible cells at frame end for the next frame, while retail asks cell->IsInView() on the current frame. Combined with slice 6's frustum-wide VisibleCellIds, this is the most likely source of the owner's new "particles within the cathedral" report, but I have no capture of that symptom to test against. UNVERIFIED.

Appendix — commands used (all read-only)

git show --stat 37febd1f c40aecfc 5f7ccdea f3a03efc 0d6cd5c0 bb9212c7
git merge-base --is-ancestor fce250ec bb9212c7        # -> NO
tools/A8CellAudit/bin/Release/net10.0/A8CellAudit.exe portals F4180104|F4180106|F418010F
curl -s "http://127.0.0.1:8081/decompile_function?address=0x0059d2e0"
grep -n "FlushAlphaList|AddMeshToAlphaList|DrawPortalPolyInternal|ShouldDrawParticles" \
     docs/research/named-retail/acclient_2013_pseudo_c.txt
grep/awk over %LOCALAPPDATA%\Temp\fw3-falls-probe.log, fw4-slice3-gate.log,
     fw4-slice5-gate.log, fw4-slice6-gate.log, fw4-emit-probe.log