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>
673 lines
39 KiB
Markdown
673 lines
39 KiB
Markdown
# 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 4–6 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 R1–R7, 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):
|
||
|
||
```c
|
||
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
|
||
|
||
```c
|
||
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:
|
||
|
||
```c
|
||
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:
|
||
|
||
```c
|
||
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:
|
||
|
||
```c
|
||
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 `DrawSortCell`
|
||
→ `DrawObjCell`) and through `DrawObjCellForDummies` for interior cells —
|
||
i.e. through the same `CPhysicsPart::Draw` → `DrawMesh` 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:
|
||
|
||
```c
|
||
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** `0xCF418000`–`0xCF418057`. **Ordinals 1–7 are model
|
||
`0x0200085A`** — the spawn-marker Setup that retail never draws (see §6).
|
||
- **9 buildings** `0xCF418058`–`0xCF418060`. Four carry portals:
|
||
`0x01001FB0`→cells 0100-0102, **`0x01001FB2`→0103,0104**,
|
||
**`0x01001FB3`→0106-010B**, `0x01001FB7`→0112-0115.
|
||
- **24 EnvCells** `0xF4180100`–`0xF4180117`.
|
||
|
||
### 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 | `AlphaBarrier` → `FlushLandscapeAlphaFartherThan(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 `0x005a4852`…`0x005a49b7`; `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 bb9212c7` → **NO**; 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** |
|
||
|
||
D1–D3 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 4–5 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
|
||
`0x005a4886` — *after* 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 1–7 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.
|
||
|
||
---
|
||
|
||
## 8. Recommended fix plan
|
||
|
||
**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. | `0x0059f30b` — `FlushAlphaList(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
|
||
```
|